Skip to content

Commit 1ffd13d

Browse files
authored
Merge pull request #305 from lahfir/fix/review-window-clipboard
fix: await queued windows and reclaim stale clipboard records
2 parents 566d88c + 1913e33 commit 1ffd13d

12 files changed

Lines changed: 299 additions & 99 deletions

File tree

‎crates/linux/src/input/clipboard/owner/owner_files.rs‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,31 +72,41 @@ pub(crate) fn read_named(dir: &Path, name: &str) -> io::Result<Vec<u8>> {
7272
read_file_at(&directory, std::ffi::OsStr::new(name))
7373
}
7474

75-
pub(crate) fn read_matching(dir: &Path, prefix: &str, suffix: &str) -> io::Result<Vec<Vec<u8>>> {
75+
pub(crate) fn read_matching(
76+
dir: &Path,
77+
prefix: &str,
78+
suffix: &str,
79+
deadline: agent_desktop_core::Deadline,
80+
mut retain: impl FnMut(&[u8]) -> bool,
81+
) -> io::Result<Vec<Vec<u8>>> {
7682
let directory = match open_private_dir(dir) {
7783
Ok(directory) => directory,
7884
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
7985
Err(error) => return Err(error),
8086
};
8187
let mut entries = Dir::read_from(&directory)?;
8288
let mut records = Vec::new();
83-
let mut matched_count = 0;
8489
let mut total_bytes: usize = 0;
8590
while let Some(entry) = entries.read() {
91+
deadline.ensure_remaining().map_err(|_| {
92+
io::Error::new(io::ErrorKind::TimedOut, "clipboard record scan timed out")
93+
})?;
8694
let entry = entry?;
8795
let name = entry.file_name();
8896
let bytes = name.to_bytes();
8997
if bytes.starts_with(prefix.as_bytes()) && bytes.ends_with(suffix.as_bytes()) {
90-
matched_count += 1;
91-
if matched_count > MAX_RECORD_COUNT {
92-
return Err(io::Error::new(
93-
io::ErrorKind::InvalidData,
94-
"the clipboard owner record count exceeds the limit",
95-
));
96-
}
9798
let name = std::ffi::OsStr::from_bytes(bytes);
9899
match read_file_at(&directory, name) {
99100
Ok(record) => {
101+
if !retain(&record) {
102+
continue;
103+
}
104+
if records.len() >= MAX_RECORD_COUNT {
105+
return Err(io::Error::new(
106+
io::ErrorKind::InvalidData,
107+
"the clipboard owner record count exceeds the limit",
108+
));
109+
}
100110
total_bytes = total_bytes.checked_add(record.len()).ok_or_else(|| {
101111
io::Error::new(
102112
io::ErrorKind::InvalidData,

‎crates/linux/src/input/clipboard/owner/owner_record.rs‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,40 @@ pub(crate) fn read_outcome(dir: &Path, display: &str, nonce: &str) -> Option<Out
140140
outcome_of(&owner_files::read_named(dir, &name).ok()?)
141141
}
142142

143-
pub(crate) fn records_in(dir: &Path, display: &str) -> std::io::Result<Vec<OwnerRecord>> {
143+
pub(crate) fn records_in(
144+
dir: &Path,
145+
display: &str,
146+
deadline: agent_desktop_core::Deadline,
147+
) -> std::io::Result<Vec<OwnerRecord>> {
144148
let wanted = normalise_display(display);
145-
Ok(owner_files::read_matching(dir, FILE_PREFIX, FILE_SUFFIX)?
146-
.into_iter()
147-
.filter_map(|bytes| OwnerRecord::from_bytes(&bytes))
148-
.filter(|record| record.display == wanted)
149-
.collect())
149+
let retain = |bytes: &[u8]| {
150+
let Some(record) = OwnerRecord::from_bytes(bytes) else {
151+
return false;
152+
};
153+
if record.display != wanted {
154+
return false;
155+
}
156+
let stale = match start_ticks_of(record.pid) {
157+
Some(ticks) => ticks != record.start_ticks,
158+
None => std::fs::metadata(format!("/proc/{}", record.pid))
159+
.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound),
160+
};
161+
if stale {
162+
remove_own(dir, &record);
163+
}
164+
!stale
165+
};
166+
Ok(owner_files::read_matching(
167+
dir,
168+
&format!("{FILE_PREFIX}{wanted}-"),
169+
FILE_SUFFIX,
170+
deadline,
171+
retain,
172+
)?
173+
.into_iter()
174+
.filter_map(|bytes| OwnerRecord::from_bytes(&bytes))
175+
.filter(|record| record.display == wanted)
176+
.collect())
150177
}
151178

152179
pub(crate) fn start_ticks_of(pid: u32) -> Option<u64> {

‎crates/linux/src/input/clipboard/owner/owner_record_tests.rs‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::*;
22
use crate::input::clipboard::test_nonce::test_nonce;
3+
use agent_desktop_core::Deadline;
34

45
fn record(nonce: &str) -> OwnerRecord {
56
OwnerRecord {
@@ -62,20 +63,26 @@ fn a_failure_outcome_carries_its_message_and_is_not_a_record() {
6263
read_outcome(&dir, ":7", &nonce),
6364
Some(Outcome::Failed("payload missing".into()))
6465
);
65-
assert!(records_in(&dir, ":7").expect("list").is_empty());
66+
assert!(
67+
records_in(&dir, ":7", Deadline::standard().unwrap())
68+
.expect("list")
69+
.is_empty()
70+
);
6671
}
6772

6873
#[test]
6974
fn records_are_listed_per_display_only() {
7075
let dir = scratch("listing");
7176
let seven_nonce = test_nonce();
7277
let mut seven = record(&seven_nonce);
78+
seven.pid = std::process::id();
79+
seven.start_ticks = start_ticks_of(seven.pid).expect("live process");
7380
seven.display = ":7".into();
7481
let mut eight = record(&test_nonce());
7582
eight.display = ":8".into();
7683
publish(&dir, &seven).expect("seven");
7784
publish(&dir, &eight).expect("eight");
78-
let found = records_in(&dir, ":7.0").expect("list");
85+
let found = records_in(&dir, ":7.0", Deadline::standard().unwrap()).expect("list");
7986
assert_eq!(
8087
found,
8188
[OwnerRecord {
@@ -88,13 +95,56 @@ fn records_are_listed_per_display_only() {
8895
);
8996
}
9097

98+
#[test]
99+
fn stale_and_other_display_records_do_not_block_the_live_owner() {
100+
let dir = scratch("stale-limit");
101+
let mut live = record(&test_nonce());
102+
live.pid = std::process::id();
103+
live.start_ticks = start_ticks_of(live.pid).expect("live process");
104+
for index in 0..300 {
105+
let mut stale = record(&format!("stale{index:08}"));
106+
stale.pid = if index % 2 == 0 { u32::MAX } else { live.pid };
107+
stale.start_ticks = live.start_ticks + 1;
108+
publish(&dir, &stale).expect("stale record");
109+
let mut foreign = live.clone();
110+
foreign.display = ":8".into();
111+
foreign.nonce = format!("foreign{index:08}");
112+
publish(&dir, &foreign).expect("other display");
113+
}
114+
publish(&dir, &live).expect("live owner");
115+
let found = records_in(&dir, ":7", Deadline::standard().unwrap())
116+
.expect("stale records must not block discovery");
117+
assert_eq!(found.len(), 1);
118+
assert_eq!(found[0].nonce, live.nonce);
119+
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 301);
120+
assert_eq!(
121+
records_in(&dir, ":8", Deadline::standard().unwrap())
122+
.unwrap_err()
123+
.kind(),
124+
std::io::ErrorKind::InvalidData
125+
);
126+
}
127+
128+
#[test]
129+
fn an_expired_scan_does_not_read_or_reclaim_records() {
130+
let dir = scratch("expired-scan");
131+
publish(&dir, &record(&test_nonce())).unwrap();
132+
let error = records_in(&dir, ":7", Deadline::after(0).unwrap()).unwrap_err();
133+
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
134+
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
135+
}
136+
91137
#[test]
92138
fn an_exiting_owner_removes_its_own_record() {
93139
let dir = scratch("own");
94140
let own = record(&test_nonce());
95141
publish(&dir, &own).expect("publish");
96142
remove_own(&dir, &own);
97-
assert!(records_in(&dir, ":7").expect("list").is_empty());
143+
assert!(
144+
records_in(&dir, ":7", Deadline::standard().unwrap())
145+
.expect("list")
146+
.is_empty()
147+
);
98148
}
99149

100150
#[test]
@@ -121,7 +171,10 @@ fn records_in_rejects_a_symlink_record() {
121171
let target = dir.join("target.json");
122172
std::fs::write(&target, record(&test_nonce()).to_bytes()).expect("target");
123173
symlink(&target, dir.join(name)).expect("symlink");
124-
assert!(records_in(&dir, ":7").is_err(), "symlinks are refused");
174+
assert!(
175+
records_in(&dir, ":7", Deadline::standard().unwrap()).is_err(),
176+
"symlinks are refused"
177+
);
125178
}
126179

127180
#[cfg(unix)]
@@ -141,7 +194,7 @@ fn records_in_rejects_a_fifo_without_waiting_for_a_writer() {
141194
)
142195
.expect("fifo");
143196
assert_eq!(
144-
records_in(&dir, ":7")
197+
records_in(&dir, ":7", Deadline::standard().unwrap())
145198
.expect_err("FIFOs are refused")
146199
.kind(),
147200
std::io::ErrorKind::InvalidData
@@ -160,7 +213,7 @@ fn records_in_rejects_an_oversized_record() {
160213
)
161214
.expect("large record");
162215
assert_eq!(
163-
records_in(&dir, ":7")
216+
records_in(&dir, ":7", Deadline::standard().unwrap())
164217
.expect_err("oversized records are refused")
165218
.kind(),
166219
std::io::ErrorKind::InvalidData
@@ -177,7 +230,7 @@ fn records_in_rejects_a_permissive_directory() {
177230
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777))
178231
.expect("permissive mode");
179232
assert_eq!(
180-
records_in(&dir, ":7")
233+
records_in(&dir, ":7", Deadline::standard().unwrap())
181234
.expect_err("permissive directory is refused")
182235
.kind(),
183236
std::io::ErrorKind::PermissionDenied

‎crates/linux/src/input/clipboard/set_clear.rs‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ fn failed(message: impl Into<String>, disposition: DeliverySemantics) -> Adapter
3333
AdapterError::new(ErrorCode::ActionFailed, message).with_disposition(disposition)
3434
}
3535

36+
fn record_read_error(error: std::io::Error, disposition: DeliverySemantics) -> AdapterError {
37+
let message = format!("Clipboard owner records could not be read: {error}");
38+
let error = if error.kind() == std::io::ErrorKind::TimedOut {
39+
AdapterError::timeout(message)
40+
} else {
41+
AdapterError::new(ErrorCode::ActionFailed, message)
42+
};
43+
error.with_disposition(disposition)
44+
}
45+
3646
fn discard(dir: &Path, display: &str, nonce: &str) {
3747
if let Some(name) = owner_record::file_name(display, nonce) {
3848
let _ = super::owner::owner_files::remove(dir, &name);
@@ -118,12 +128,9 @@ pub(crate) fn set_with(
118128
.server
119129
.owner(display, deadline)
120130
.map_err(|fault| fault_error(fault, None))?;
121-
let earlier = owner_record::records_in(env.record_dir, display).map_err(|error| {
122-
failed(
123-
format!("Clipboard owner records could not be read: {error}"),
124-
DeliverySemantics::not_delivered(),
125-
)
126-
})?;
131+
let earlier = owner_record::records_in(env.record_dir, display, deadline)
132+
.map_err(|error| record_read_error(error, DeliverySemantics::not_delivered()))?;
133+
deadline.ensure_remaining().map_err(not_delivered)?;
127134
let mut child = start_owner(env, display, &payload)?;
128135
let mut probe = ChildProbe {
129136
child: &mut child,
@@ -182,12 +189,8 @@ pub(crate) fn clear_with(
182189
env.server
183190
.release(display, deadline)
184191
.map_err(|fault| fault_error(fault, None))?;
185-
let records = owner_record::records_in(env.record_dir, display).map_err(|error| {
186-
failed(
187-
format!("Clipboard owner records could not be read after clearing: {error}"),
188-
DeliverySemantics::delivered_unverified(),
189-
)
190-
})?;
192+
let records = owner_record::records_in(env.record_dir, display, deadline)
193+
.map_err(|error| record_read_error(error, DeliverySemantics::delivered_unverified()))?;
191194
for record in records {
192195
retire(env, display, &record);
193196
}

‎crates/linux/src/input/clipboard/set_clear_live_support.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,14 @@ pub(super) fn foreign_read(display: &str, target: &str) -> Option<Vec<u8>> {
153153

154154
pub(super) fn records(display: &str) -> Vec<OwnerRecord> {
155155
owner_record::record_dir()
156-
.and_then(|dir| owner_record::records_in(&dir, display).ok())
156+
.and_then(|dir| {
157+
owner_record::records_in(
158+
&dir,
159+
display,
160+
agent_desktop_core::Deadline::standard().unwrap(),
161+
)
162+
.ok()
163+
})
157164
.unwrap_or_default()
158165
}
159166

‎crates/linux/src/input/clipboard/set_clear_tests.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,5 +344,40 @@ fn no_payload_descriptor_stays_open_after_a_failed_set() {
344344
#[path = "set_clear_clear_tests.rs"]
345345
mod clear_tests;
346346

347+
#[test]
348+
fn record_scan_timeout_preserves_set_and_clear_delivery_states() {
349+
for clear in [false, true] {
350+
let world = World::new(if clear { "scan-clear" } else { "scan-set" });
351+
write_record(&world, u32::MAX, 1, &test_nonce());
352+
let server = FakeServer::new(&[None], &[]);
353+
let parent = |name: &str| world.env_var(name);
354+
let env = Env {
355+
helper: Path::new("/nonexistent/helper"),
356+
record_dir: &world.records,
357+
server: &server,
358+
parent_env: &parent,
359+
nonce: &world.nonce,
360+
};
361+
let lease = lease(100);
362+
std::thread::sleep(std::time::Duration::from_millis(120));
363+
let result = if clear {
364+
clear_with(&env, DISPLAY, &lease)
365+
} else {
366+
set_with(&env, DISPLAY, &text("unchanged"), &lease)
367+
};
368+
let error = result.expect_err("expired record scan");
369+
assert_eq!(error.code, ErrorCode::Timeout);
370+
assert_eq!(server.released.get(), u32::from(clear));
371+
assert_eq!(
372+
error.disposition,
373+
if clear {
374+
DeliverySemantics::delivered_unverified()
375+
} else {
376+
DeliverySemantics::not_delivered()
377+
}
378+
);
379+
}
380+
}
381+
347382
#[path = "set_clear_reaper_tests.rs"]
348383
mod reaper_tests;

‎crates/windows/src/system/window/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ pub(crate) mod window_identity;
66
pub(crate) mod window_op;
77
pub(crate) mod window_op_readback;
88
pub(crate) mod window_op_verb;
9+
pub(crate) mod window_op_wait;
910
pub(crate) mod window_ops;
1011
pub(crate) mod window_resolve;

0 commit comments

Comments
 (0)