@@ -960,6 +960,65 @@ fn stdin_eof_exits_while_output_is_not_drained() {
960960 reader. join ( ) . unwrap ( ) ;
961961}
962962
963+ fn wait_for_descendant_lease (
964+ mut try_lock : impl FnMut ( ) -> Result < ( ) , fs:: TryLockError > ,
965+ timeout : Duration ,
966+ ) -> std:: io:: Result < ( ) > {
967+ let started = Instant :: now ( ) ;
968+ loop {
969+ match try_lock ( ) {
970+ Ok ( ( ) ) => return Ok ( ( ) ) ,
971+ Err ( fs:: TryLockError :: Error ( error) ) => return Err ( error) ,
972+ Err ( fs:: TryLockError :: WouldBlock ) => { }
973+ }
974+ let remaining = timeout. saturating_sub ( started. elapsed ( ) ) ;
975+ if remaining. is_zero ( ) {
976+ return Err ( std:: io:: Error :: new (
977+ std:: io:: ErrorKind :: TimedOut ,
978+ "descendant still holds its lease at the shutdown deadline" ,
979+ ) ) ;
980+ }
981+ thread:: sleep ( Duration :: from_millis ( 10 ) . min ( remaining) ) ;
982+ }
983+ }
984+
985+ #[ test]
986+ fn descendant_lease_wait_is_bounded_and_requires_release ( ) {
987+ let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
988+ let path = directory. path ( ) . join ( "lease" ) ;
989+ let holder = fs:: File :: create ( & path) . unwrap ( ) ;
990+ holder. try_lock ( ) . unwrap ( ) ;
991+ let lease = fs:: OpenOptions :: new ( )
992+ . read ( true )
993+ . write ( true )
994+ . open ( path)
995+ . unwrap ( ) ;
996+ let started = Instant :: now ( ) ;
997+ assert_eq ! (
998+ wait_for_descendant_lease( || lease. try_lock( ) , Duration :: from_millis( 20 ) )
999+ . unwrap_err( )
1000+ . kind( ) ,
1001+ std:: io:: ErrorKind :: TimedOut
1002+ ) ;
1003+ assert ! ( started. elapsed( ) < Duration :: from_secs( 1 ) ) ;
1004+ let mut holder = Some ( holder) ;
1005+ let mut attempts = 0 ;
1006+ wait_for_descendant_lease (
1007+ || {
1008+ attempts += 1 ;
1009+ let result = lease. try_lock ( ) ;
1010+ if attempts == 1 {
1011+ assert ! ( matches!( result, Err ( fs:: TryLockError :: WouldBlock ) ) ) ;
1012+ drop ( holder. take ( ) ) ;
1013+ }
1014+ result
1015+ } ,
1016+ Duration :: from_secs ( 1 ) ,
1017+ )
1018+ . expect ( "lease polling must observe release after initial contention" ) ;
1019+ assert_eq ! ( attempts, 2 ) ;
1020+ }
1021+
9631022#[ cfg( feature = "ci" ) ]
9641023#[ test]
9651024fn stdin_eof_cancels_an_active_interpreter_and_its_descendant ( ) {
@@ -1025,10 +1084,13 @@ fn stdin_eof_cancels_an_active_interpreter_and_its_descendant() {
10251084 "active-probe shutdown failed: {status}; stderr: {}" ,
10261085 client. stderr_output( )
10271086 ) ;
1087+ // Observe OS lease release within the same budget as server shutdown.
1088+ wait_for_descendant_lease (
1089+ || lease. try_lock ( ) ,
1090+ Duration :: from_secs ( 4 ) . saturating_sub ( started. elapsed ( ) ) ,
1091+ )
1092+ . expect ( "shutdown must release the actual descendant's lease" ) ;
10281093 assert ! ( started. elapsed( ) < Duration :: from_secs( 4 ) ) ;
1029- lease
1030- . try_lock ( )
1031- . expect ( "shutdown must release the actual descendant's lease" ) ;
10321094 assert ! (
10331095 request. join( ) . unwrap( ) . is_err( ) ,
10341096 "an active request must be cancelled, not reported as successful"
0 commit comments