@@ -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