@@ -864,3 +864,199 @@ async fn upload_queue_friend_slot_bypasses_soft_limit_gate() {
864864 Ed2kUploadSessionStatus :: Waiting { .. }
865865 ) ) ;
866866}
867+
868+ #[ tokio:: test]
869+ async fn upload_queue_waiter_survives_disconnect_with_wait_time_intact ( ) {
870+ let root = unique_test_dir ( "ed2k-upload-queue-waiter-survives-disconnect" ) ;
871+ let runtime = Ed2kTransferRuntime :: load_or_create ( & root) . unwrap ( ) ;
872+ runtime. configure_upload_queue ( one_slot_config ( ) ) . await ;
873+ let file_hash = Ed2kHash :: from_bytes ( [ 0x7C ; 16 ] ) ;
874+ // Timeline in the past so the synthetic instants stay comparable to the
875+ // real `Instant::now()` used by snapshot-style entry points.
876+ let t0 = std:: time:: Instant :: now ( ) - std:: time:: Duration :: from_secs ( 20 ) ;
877+
878+ let ( _active_handle, active_status) = runtime
879+ . begin_upload_session_at ( upload_peer ( 1 , 0x11 , 0x0A00_0001 ) , & file_hash, t0)
880+ . await ;
881+ assert_eq ! ( active_status, Ed2kUploadSessionStatus :: Granted ) ;
882+ // Older waiter: queued first, so its accumulated wait outranks the later one.
883+ let ( older_handle, older_status) = runtime
884+ . begin_upload_session_at ( upload_peer ( 2 , 0x22 , 0x0A00_0002 ) , & file_hash, t0)
885+ . await ;
886+ assert_eq ! ( older_status, Ed2kUploadSessionStatus :: Waiting { rank: 1 } ) ;
887+ let ( younger_handle, younger_status) = runtime
888+ . begin_upload_session_at (
889+ upload_peer ( 3 , 0x33 , 0x0A00_0003 ) ,
890+ & file_hash,
891+ t0 + std:: time:: Duration :: from_secs ( 5 ) ,
892+ )
893+ . await ;
894+ assert_eq ! ( younger_status, Ed2kUploadSessionStatus :: Waiting { rank: 2 } ) ;
895+
896+ // The older waiter's connection drops: its queue entry must survive with
897+ // its wait-start time (master keeps US_ONUPLOADQUEUE clients on disconnect,
898+ // BaseClient.cpp:1229) instead of being erased with the connection.
899+ runtime. release_upload_session ( & older_handle) . await ;
900+ assert_eq ! (
901+ runtime. upload_queue_snapshot( ) . await . len( ) ,
902+ 3 ,
903+ "the disconnected waiter must keep its queue entry"
904+ ) ;
905+ // Wait-start intact: the disconnected older waiter still outranks the
906+ // connected younger one (rank derives from the waiting-time score).
907+ assert_eq ! (
908+ runtime
909+ . poll_upload_session_at(
910+ & younger_handle,
911+ false ,
912+ t0 + std:: time:: Duration :: from_secs( 10 )
913+ )
914+ . await ,
915+ Ed2kUploadSessionStatus :: Waiting { rank: 2 } ,
916+ "a disconnected waiter with more accumulated wait must keep rank 1"
917+ ) ;
918+ }
919+
920+ #[ tokio:: test]
921+ async fn upload_queue_reask_reattaches_disconnected_waiter_without_wait_reset ( ) {
922+ let root = unique_test_dir ( "ed2k-upload-queue-reask-reattach" ) ;
923+ let runtime = Ed2kTransferRuntime :: load_or_create ( & root) . unwrap ( ) ;
924+ runtime. configure_upload_queue ( one_slot_config ( ) ) . await ;
925+ let file_hash = Ed2kHash :: from_bytes ( [ 0x7D ; 16 ] ) ;
926+ let t0 = std:: time:: Instant :: now ( ) - std:: time:: Duration :: from_secs ( 60 ) ;
927+
928+ let ( _active_handle, active_status) = runtime
929+ . begin_upload_session_at ( upload_peer ( 1 , 0x11 , 0x0A00_0001 ) , & file_hash, t0)
930+ . await ;
931+ assert_eq ! ( active_status, Ed2kUploadSessionStatus :: Granted ) ;
932+ let ( older_handle, older_status) = runtime
933+ . begin_upload_session_at ( upload_peer ( 2 , 0x22 , 0x0A00_0002 ) , & file_hash, t0)
934+ . await ;
935+ assert_eq ! ( older_status, Ed2kUploadSessionStatus :: Waiting { rank: 1 } ) ;
936+ let ( _younger_handle, younger_status) = runtime
937+ . begin_upload_session_at (
938+ upload_peer ( 3 , 0x33 , 0x0A00_0003 ) ,
939+ & file_hash,
940+ t0 + std:: time:: Duration :: from_secs ( 10 ) ,
941+ )
942+ . await ;
943+ assert_eq ! ( younger_status, Ed2kUploadSessionStatus :: Waiting { rank: 2 } ) ;
944+
945+ // The older waiter disconnects, then re-asks on a NEW connection with a new
946+ // server-assigned client id (same user hash) — the oracle resolves the same
947+ // client by user hash (CUpDownClient::Compare) and the re-ask lands on the
948+ // persisted entry WITHOUT resetting its wait time (UploadQueue.cpp:1865-1869).
949+ runtime. release_upload_session ( & older_handle) . await ;
950+ let mut returning = upload_peer ( 2 , 0x22 , 0x0A00_0002 ) ;
951+ returning. client_id = Some ( 0x0B00_0099 ) ;
952+ let ( reattached_handle, reattached_status) = runtime
953+ . begin_upload_session_at (
954+ returning,
955+ & file_hash,
956+ t0 + std:: time:: Duration :: from_secs ( 30 ) ,
957+ )
958+ . await ;
959+ assert_eq ! (
960+ reattached_status,
961+ Ed2kUploadSessionStatus :: Waiting { rank: 1 } ,
962+ "the re-ask must re-attach with the original wait time, not restart at the tail"
963+ ) ;
964+ // The stale pre-disconnect handle no longer owns the session.
965+ assert_eq ! (
966+ runtime
967+ . poll_upload_session_at(
968+ & older_handle,
969+ false ,
970+ t0 + std:: time:: Duration :: from_secs( 31 )
971+ )
972+ . await ,
973+ Ed2kUploadSessionStatus :: Stale
974+ ) ;
975+ assert_eq ! (
976+ runtime
977+ . poll_upload_session_at(
978+ & reattached_handle,
979+ false ,
980+ t0 + std:: time:: Duration :: from_secs( 31 )
981+ )
982+ . await ,
983+ Ed2kUploadSessionStatus :: Waiting { rank: 1 }
984+ ) ;
985+ }
986+
987+ #[ tokio:: test]
988+ async fn upload_queue_slot_grant_to_disconnected_waiter_queues_outbound_promotion ( ) {
989+ let root = unique_test_dir ( "ed2k-upload-queue-disconnected-promotion" ) ;
990+ let runtime = Ed2kTransferRuntime :: load_or_create ( & root) . unwrap ( ) ;
991+ runtime. configure_upload_queue ( one_slot_config ( ) ) . await ;
992+ let file_hash = Ed2kHash :: from_bytes ( [ 0x7E ; 16 ] ) ;
993+ let t0 = std:: time:: Instant :: now ( ) - std:: time:: Duration :: from_secs ( 20 ) ;
994+
995+ let ( active_handle, active_status) = runtime
996+ . begin_upload_session_at ( upload_peer ( 1 , 0x11 , 0x0A00_0001 ) , & file_hash, t0)
997+ . await ;
998+ assert_eq ! ( active_status, Ed2kUploadSessionStatus :: Granted ) ;
999+ let ( waiting_handle, waiting_status) = runtime
1000+ . begin_upload_session_at ( upload_peer ( 2 , 0x22 , 0x0A00_0002 ) , & file_hash, t0)
1001+ . await ;
1002+ assert_eq ! ( waiting_status, Ed2kUploadSessionStatus :: Waiting { rank: 1 } ) ;
1003+
1004+ // The waiter disconnects, then the active slot frees: the disconnected
1005+ // waiter is promoted and handed to the outbound promote-connect path
1006+ // (master AddUpNextClient US_CONNECTING connect-out, UploadQueue.cpp:327-361).
1007+ runtime. release_upload_session ( & waiting_handle) . await ;
1008+ runtime. release_upload_session ( & active_handle) . await ;
1009+
1010+ let grants = runtime. take_pending_upload_promotions ( ) . await ;
1011+ assert_eq ! (
1012+ grants. len( ) ,
1013+ 1 ,
1014+ "one outbound promote-connect grant expected"
1015+ ) ;
1016+ let grant = & grants[ 0 ] ;
1017+ assert_eq ! ( grant. peer. user_hash, Some ( [ 0x22 ; 16 ] ) ) ;
1018+ assert_eq ! ( grant. file_hash, file_hash. to_string( ) ) ;
1019+ assert_eq ! (
1020+ runtime. poll_upload_session( & grant. handle, false ) . await ,
1021+ Ed2kUploadSessionStatus :: Granted ,
1022+ "the grant handle must own the promoted session"
1023+ ) ;
1024+ // Draining is one-shot until another disconnected promotion happens.
1025+ assert ! ( runtime. take_pending_upload_promotions( ) . await . is_empty( ) ) ;
1026+
1027+ // A failed outbound connect drops the grant entirely (master deletes the
1028+ // client on a failed TryToConnect), freeing the slot for the next waiter.
1029+ runtime. release_upload_session ( & grant. handle ) . await ;
1030+ assert ! (
1031+ runtime. upload_queue_snapshot( ) . await . is_empty( ) ,
1032+ "a dropped grant must not linger in the queue"
1033+ ) ;
1034+ }
1035+
1036+ #[ tokio:: test]
1037+ async fn upload_queue_connected_waiter_promotion_needs_no_outbound_connect ( ) {
1038+ let root = unique_test_dir ( "ed2k-upload-queue-connected-promotion" ) ;
1039+ let runtime = Ed2kTransferRuntime :: load_or_create ( & root) . unwrap ( ) ;
1040+ runtime. configure_upload_queue ( one_slot_config ( ) ) . await ;
1041+ let file_hash = Ed2kHash :: from_bytes ( [ 0x7F ; 16 ] ) ;
1042+
1043+ let ( active_handle, active_status) = runtime
1044+ . begin_upload_session ( upload_peer ( 1 , 0x11 , 0x0A00_0001 ) , & file_hash)
1045+ . await ;
1046+ assert_eq ! ( active_status, Ed2kUploadSessionStatus :: Granted ) ;
1047+ let ( waiting_handle, waiting_status) = runtime
1048+ . begin_upload_session ( upload_peer ( 2 , 0x22 , 0x0A00_0002 ) , & file_hash)
1049+ . await ;
1050+ assert_eq ! ( waiting_status, Ed2kUploadSessionStatus :: Waiting { rank: 1 } ) ;
1051+
1052+ // The waiter still has its live connection when the slot frees: its own
1053+ // session loop observes the grant and sends OP_ACCEPTUPLOADREQ inline
1054+ // (master AddUpNextClient connected branch, UploadQueue.cpp:355-361), so
1055+ // no outbound promote-connect is queued.
1056+ runtime. release_upload_session ( & active_handle) . await ;
1057+ assert_eq ! (
1058+ runtime. poll_upload_session( & waiting_handle, true ) . await ,
1059+ Ed2kUploadSessionStatus :: Granted
1060+ ) ;
1061+ assert ! ( runtime. take_pending_upload_promotions( ) . await . is_empty( ) ) ;
1062+ }
0 commit comments