@@ -727,10 +727,17 @@ fn install_fake_pgrep_no_match(dir: &TempDir) -> std::path::PathBuf {
727727 install_executable_script ( dir, "pgrep" , "#!/bin/sh\n exit 1\n " )
728728}
729729
730- async fn wait_for_file ( path : & std :: path :: Path , timeout : Duration ) -> bool {
730+ async fn wait_for_process_exit ( pid : u32 , timeout : Duration ) -> bool {
731731 let deadline = Instant :: now ( ) + timeout;
732732 loop {
733- if path. exists ( ) {
733+ let alive = std:: process:: Command :: new ( "ps" )
734+ . arg ( "-p" )
735+ . arg ( pid. to_string ( ) )
736+ . stdout ( std:: process:: Stdio :: null ( ) )
737+ . stderr ( std:: process:: Stdio :: null ( ) )
738+ . status ( )
739+ . is_ok_and ( |status| status. success ( ) ) ;
740+ if !alive {
734741 return true ;
735742 }
736743 if Instant :: now ( ) >= deadline {
@@ -844,39 +851,87 @@ exit 1
844851 ssh_path
845852}
846853
847- fn install_fake_unreachable_forwarding_ssh ( dir : & TempDir ) -> std:: path:: PathBuf {
854+ struct FakeUnreachableForward {
855+ log_path : std:: path:: PathBuf ,
856+ pid_path : std:: path:: PathBuf ,
857+ }
858+
859+ fn install_fake_unreachable_forwarding_ssh ( dir : & TempDir ) -> FakeUnreachableForward {
860+ let log_path = dir. path ( ) . join ( "fake-forward.log" ) ;
848861 let pid_path = dir. path ( ) . join ( "fake-forward.pid" ) ;
849- let terminated_path = dir. path ( ) . join ( "fake-forward.terminated " ) ;
862+ let ready_path = dir. path ( ) . join ( "fake-forward.ready " ) ;
850863 install_executable_script (
851864 dir,
852865 "ssh" ,
853866 r#"#!/bin/sh
854867set -eu
855868
856- nohup python3 -c '
869+ forward=""
870+ sandbox_id=""
871+ previous=""
872+
873+ for arg in "$@"; do
874+ if [ "$previous" = "-L" ]; then
875+ forward="$arg"
876+ previous=""
877+ continue
878+ fi
879+
880+ if [ "$previous" = "-o" ]; then
881+ case "$arg" in
882+ ProxyCommand=*)
883+ sandbox_id="$(printf '%s\n' "$arg" | sed -n 's/.*--sandbox-id \([^ ]*\).*/\1/p')"
884+ ;;
885+ esac
886+ previous=""
887+ continue
888+ fi
889+
890+ case "$arg" in
891+ -L|-o)
892+ previous="$arg"
893+ ;;
894+ esac
895+ done
896+
897+ if [ -z "$forward" ] || [ -z "$sandbox_id" ]; then
898+ exit 1
899+ fi
900+
901+ trap '' HUP
902+ python3 -c '
857903import pathlib
858904import signal
859905import sys
860906import time
861907
862- terminated_path = pathlib.Path(sys.argv[1])
908+ ready_path = pathlib.Path(sys.argv[1])
863909
864- def stop(_signum, _frame):
865- terminated_path.write_text("terminated")
866- raise SystemExit(0)
867-
868- signal.signal(signal.SIGTERM, stop)
869- signal.signal(signal.SIGINT, stop)
870910signal.signal(signal.SIGHUP, signal.SIG_IGN)
871911
912+ ready_path.write_text("ready")
913+
872914while True:
873915 time.sleep(1)
874- ' '@TERMINATED_PATH@' >/dev/null 2>&1 &
875- echo $! > '@PID_PATH@'
916+ ' '@READY_PATH@' ssh ssh-proxy --sandbox-id "$sandbox_id" -L "$forward" >'@LOG_PATH@' 2>&1 &
917+ pid="$!"
918+ i=0
919+ while [ "$i" -lt 100 ]; do
920+ if [ -e '@READY_PATH@' ]; then
921+ break
922+ fi
923+ i=$((i + 1))
924+ sleep 0.05
925+ done
926+ if [ ! -e '@READY_PATH@' ]; then
927+ exit 1
928+ fi
929+ echo "$pid" > '@PID_PATH@'
876930
877931exit 0
878932"#
879- . replace ( "@TERMINATED_PATH@" , & terminated_path. display ( ) . to_string ( ) )
933+ . replace ( "@LOG_PATH@" , & log_path. display ( ) . to_string ( ) )
934+ . replace ( "@READY_PATH@" , & ready_path. display ( ) . to_string ( ) )
880935 . replace ( "@PID_PATH@" , & pid_path. display ( ) . to_string ( ) ) ,
881936 ) ;
882937
@@ -896,7 +951,7 @@ exit 1
896951 ) ,
897952 ) ;
898953
899- terminated_path
954+ FakeUnreachableForward { log_path , pid_path }
900955}
901956
902957fn test_env ( fake_ssh_dir : & TempDir , xdg_dir : & TempDir ) -> EnvVarGuard {
@@ -1553,14 +1608,37 @@ async fn sandbox_forward_background_fails_when_pid_is_not_discoverable() {
15531608 ) ;
15541609}
15551610
1611+ #[ tokio:: test]
1612+ async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens ( ) {
1613+ let server = run_server ( ) . await ;
1614+ let fake_ssh_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1615+ let xdg_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1616+ let _env = test_env ( & fake_ssh_dir, & xdg_dir) ;
1617+ let tls = test_tls ( & server) ;
1618+ install_fake_ssh ( & fake_ssh_dir) ;
1619+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
1620+ let forward_port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
1621+ drop ( listener) ;
1622+
1623+ let spec = openshell_core:: forward:: ForwardSpec :: new ( forward_port) ;
1624+ let err = run:: sandbox_forward ( & server. endpoint , "foreground-forward" , & spec, false , & tls)
1625+ . await
1626+ . expect_err ( "foreground forward should fail when ssh exits before listener readiness" ) ;
1627+ let msg = format ! ( "{err}" ) ;
1628+ assert ! (
1629+ msg. contains( "ssh exited before local forward listener opened" ) ,
1630+ "error should explain that ssh exited before listener readiness, got: {msg}" ,
1631+ ) ;
1632+ }
1633+
15561634#[ tokio:: test]
15571635async fn sandbox_forward_background_terminates_discovered_pid_when_listener_never_opens ( ) {
15581636 let server = run_server ( ) . await ;
15591637 let fake_ssh_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
15601638 let xdg_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
15611639 let _env = test_env ( & fake_ssh_dir, & xdg_dir) ;
15621640 let tls = test_tls ( & server) ;
1563- let terminated_path = install_fake_unreachable_forwarding_ssh ( & fake_ssh_dir) ;
1641+ let fake_forward = install_fake_unreachable_forwarding_ssh ( & fake_ssh_dir) ;
15641642 let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
15651643 let forward_port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
15661644 drop ( listener) ;
@@ -1578,10 +1656,30 @@ async fn sandbox_forward_background_terminates_discovered_pid_when_listener_neve
15781656 openshell_core:: forward:: read_forward_pid( "unreachable-forward" , forward_port) . is_none( ) ,
15791657 "unreachable background forwards must not write a PID file" ,
15801658 ) ;
1581- assert ! (
1582- wait_for_file( & terminated_path, Duration :: from_secs( 2 ) ) . await ,
1583- "discovered background SSH process should be terminated after listener failure" ,
1584- ) ;
1659+ let pid = fs:: read_to_string ( & fake_forward. pid_path )
1660+ . expect ( "fake forward should record a PID" )
1661+ . trim ( )
1662+ . parse :: < u32 > ( )
1663+ . expect ( "fake forward PID should be numeric" ) ;
1664+ if !wait_for_process_exit ( pid, Duration :: from_secs ( 2 ) ) . await {
1665+ let log = fs:: read_to_string ( & fake_forward. log_path ) . unwrap_or_default ( ) ;
1666+ let command = std:: process:: Command :: new ( "ps" )
1667+ . arg ( "-ww" )
1668+ . arg ( "-o" )
1669+ . arg ( "command=" )
1670+ . arg ( "-p" )
1671+ . arg ( pid. to_string ( ) )
1672+ . output ( )
1673+ . ok ( )
1674+ . map ( |output| String :: from_utf8_lossy ( & output. stdout ) . to_string ( ) )
1675+ . unwrap_or_default ( ) ;
1676+ panic ! (
1677+ "discovered background SSH process should exit after listener failure cleanup; pid={}, command={}, log={}" ,
1678+ pid,
1679+ command. trim( ) ,
1680+ log. trim( ) ,
1681+ ) ;
1682+ }
15851683}
15861684
15871685#[ tokio:: test]
0 commit comments