Skip to content

Commit 5d534f1

Browse files
authored
fix(cliprdr): release outgoing locks before initiating a file copy (#1375)
1 parent 985d353 commit 5d534f1

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

crates/ironrdp-cliprdr/src/lib.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,47 @@ impl<R: Role> Cliprdr<R> {
10271027
// Backend notification deferred until actual cleanup
10281028
}
10291029

1030+
/// Immediately sends `Unlock` PDUs for — and drops — every outgoing clipboard lock.
1031+
///
1032+
/// Outgoing locks are created when we download files from the remote: each asks the
1033+
/// Shared Clipboard Owner to retain File Stream data so we can keep pulling it even
1034+
/// after the clipboard changes ([MS-RDPECLIP] 2.2.4.1). They are normally released
1035+
/// lazily by the inactivity sweep in [`Self::drive_timeouts`], so concurrent downloads
1036+
/// that outlive a *remote* clipboard change aren't aborted.
1037+
///
1038+
/// When the **local** side takes clipboard ownership itself (initiating a file copy),
1039+
/// those locks point at data we are replacing. Leaving them held while we advertise a
1040+
/// fresh `FormatList` makes the server track a lock for a download that will never
1041+
/// finish; some servers (notably Windows `rdpclip.exe`) react badly to that overlap.
1042+
/// Releasing them up front keeps the lock/ownership state consistent.
1043+
///
1044+
/// Returns the `Unlock` PDUs to send (these must precede the new `FormatList` on the
1045+
/// wire); empty when no locks are held.
1046+
fn release_outgoing_locks(&mut self) -> Vec<SvcMessage> {
1047+
if self.outgoing_locks.is_empty() {
1048+
return Vec::new();
1049+
}
1050+
1051+
let cleared: Vec<u32> = self.outgoing_locks.keys().copied().collect();
1052+
self.outgoing_locks.clear();
1053+
self.current_lock_id = None;
1054+
1055+
info!(
1056+
count = cleared.len(),
1057+
"Releasing outgoing locks before taking clipboard ownership"
1058+
);
1059+
1060+
let messages = cleared
1061+
.iter()
1062+
.map(|id| into_cliprdr_message(ClipboardPdu::UnlockData(LockDataId(*id))))
1063+
.collect();
1064+
1065+
let lock_ids: Vec<LockDataId> = cleared.iter().map(|id| LockDataId(*id)).collect();
1066+
self.backend.on_outgoing_locks_cleared(&lock_ids);
1067+
1068+
messages
1069+
}
1070+
10301071
/// Lazily runs periodic cleanup during normal API activity.
10311072
///
10321073
/// Cleans up expired locks, stale file contents requests, and inactive
@@ -1537,7 +1578,15 @@ impl<R: Role> Cliprdr<R> {
15371578
let format_list = self.build_format_list(&formats).map_err(|e| encode_err!(e))?;
15381579
let pdu = ClipboardPdu::FormatList(format_list);
15391580

1540-
Ok(vec![into_cliprdr_message(pdu)].into())
1581+
// Release any outgoing download locks BEFORE advertising our file list. By
1582+
// initiating a file copy we take clipboard ownership, so locks placed for
1583+
// downloads from the previous owner are now stale; sending a new FormatList while
1584+
// they're still held desyncs the server's lock state.
1585+
// The Unlock PDUs must precede the FormatList on the wire.
1586+
let mut messages = self.release_outgoing_locks();
1587+
messages.push(into_cliprdr_message(pdu));
1588+
1589+
Ok(messages.into())
15411590
}
15421591
}
15431592

crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,77 @@ fn on_outgoing_locks_expired_callback_invoked() {
562562
// Cleared callback should NOT have fired yet (cleanup hasn't run)
563563
assert!(cleared_ids.lock().unwrap().is_empty());
564564
}
565+
566+
// -- Taking clipboard ownership releases stale download locks --------
567+
568+
/// When the local side initiates a file copy (an upload), it takes clipboard
569+
/// ownership — so the outgoing locks placed for downloads from the previous owner are
570+
/// now stale. They must be released with `Unlock` PDUs that PRECEDE our `FormatList` on
571+
/// the wire; otherwise the server keeps tracking a lock for a download that will never
572+
/// complete, which desyncs its clipboard state.
573+
#[test]
574+
fn initiate_file_copy_releases_outgoing_download_locks() {
575+
let mut cliprdr = ready_locking_client();
576+
577+
// Two remote file lists => two outgoing download locks (e.g. two in-flight downloads).
578+
let lock1 = process_file_format_list(&mut cliprdr);
579+
let lock2 = process_file_format_list(&mut cliprdr);
580+
assert_eq!(cliprdr.__test_outgoing_locks().len(), 2);
581+
582+
let messages: Vec<SvcMessage> = cliprdr
583+
.initiate_file_copy(vec![FileDescriptor::new("upload.txt")])
584+
.unwrap()
585+
.into();
586+
587+
// Every outgoing lock is released and the current lock id is cleared.
588+
assert!(
589+
cliprdr.__test_outgoing_locks().is_empty(),
590+
"outgoing locks must be released when taking clipboard ownership"
591+
);
592+
assert_eq!(cliprdr.__test_current_lock_id(), None);
593+
594+
// Wire order: an Unlock for each held lock, THEN our FormatList last.
595+
assert_eq!(messages.len(), 3, "expected 2 Unlock PDUs + 1 FormatList");
596+
597+
decode_pdu!(messages[0] => _b0, pdu0);
598+
let id0 = match pdu0 {
599+
ClipboardPdu::UnlockData(id) => id.0,
600+
other => panic!("expected UnlockData first, got {other:?}"),
601+
};
602+
decode_pdu!(messages[1] => _b1, pdu1);
603+
let id1 = match pdu1 {
604+
ClipboardPdu::UnlockData(id) => id.0,
605+
other => panic!("expected UnlockData second, got {other:?}"),
606+
};
607+
let mut unlocked = [id0, id1];
608+
unlocked.sort_unstable();
609+
let mut expected = [lock1, lock2];
610+
expected.sort_unstable();
611+
assert_eq!(unlocked, expected, "both download locks must be unlocked");
612+
613+
decode_pdu!(messages[2] => _b2, last_pdu);
614+
assert!(
615+
matches!(last_pdu, ClipboardPdu::FormatList(_)),
616+
"FormatList must come after the Unlock PDUs, got {last_pdu:?}"
617+
);
618+
}
619+
620+
/// With no outgoing locks held, `initiate_file_copy` sends only the `FormatList`
621+
/// (no spurious `Unlock`).
622+
#[test]
623+
fn initiate_file_copy_without_locks_sends_only_format_list() {
624+
let mut cliprdr = ready_locking_client();
625+
assert!(cliprdr.__test_outgoing_locks().is_empty());
626+
627+
let messages: Vec<SvcMessage> = cliprdr
628+
.initiate_file_copy(vec![FileDescriptor::new("upload.txt")])
629+
.unwrap()
630+
.into();
631+
632+
assert_eq!(messages.len(), 1, "expected only a FormatList when no locks are held");
633+
decode_pdu!(messages[0] => _b0, pdu);
634+
assert!(
635+
matches!(pdu, ClipboardPdu::FormatList(_)),
636+
"expected FormatList, got {pdu:?}"
637+
);
638+
}

0 commit comments

Comments
 (0)