@@ -236,6 +236,15 @@ pub(crate) fn output_full_stdout_with_timeout(
236236 run_captured ( command, timeout, None , |_| { } , true )
237237}
238238
239+ #[ cfg( test) ]
240+ fn output_full_stdout_with_timeout_observed (
241+ command : & mut Command ,
242+ timeout : Duration ,
243+ on_spawn : impl FnOnce ( i32 ) ,
244+ ) -> anyhow:: Result < Output > {
245+ run_captured ( command, timeout, None , on_spawn, true )
246+ }
247+
239248/// Shared spawn/wait/read-back core. The child is `setsid`, so its pid is also its process group
240249/// id — the group this function signals on every failure path.
241250fn run_captured (
@@ -371,13 +380,17 @@ pub struct PtyCli {
371380 bin : String ,
372381 /// The catalog root — the value of `$CATALOG` during `$`-expansion (spec.md §2 / R11).
373382 catalog_root : PathBuf ,
383+ #[ cfg( test) ]
384+ on_command_spawn : Option < std:: sync:: Arc < dyn Fn ( i32 ) > > ,
374385}
375386
376387impl Default for PtyCli {
377388 fn default ( ) -> Self {
378389 Self {
379390 bin : "pty" . to_string ( ) ,
380391 catalog_root : PathBuf :: from ( "." ) ,
392+ #[ cfg( test) ]
393+ on_command_spawn : None ,
381394 }
382395 }
383396}
@@ -509,6 +522,8 @@ impl PtyCli {
509522 Self {
510523 bin : "pty" . to_string ( ) ,
511524 catalog_root,
525+ #[ cfg( test) ]
526+ on_command_spawn : None ,
512527 }
513528 }
514529
@@ -883,14 +898,25 @@ impl PtyCli {
883898 display_name : presentation. display_name . as_ref ( ) ,
884899 tags : & presentation. tags ,
885900 } ) ?;
886- let out = output_with_input_timeout (
887- Command :: new ( & self . bin )
888- . args ( [ "metadata" , "patch" , "--id" , & presentation. pty_id ] )
889- . env ( "PTY_ROOT" , effective_pty_root ( & self . catalog_root ) ) ,
901+ let mut command = Command :: new ( & self . bin ) ;
902+ command
903+ . args ( [ "metadata" , "patch" , "--id" , & presentation. pty_id ] )
904+ . env ( "PTY_ROOT" , effective_pty_root ( & self . catalog_root ) ) ;
905+ #[ cfg( test) ]
906+ let out = output_with_input_timeout_observed (
907+ & mut command,
890908 PTY_LIST_TIMEOUT ,
891909 Some ( payload) ,
892- )
893- . map_err ( |error| anyhow:: anyhow!( "`pty metadata patch --id` failed: {error}" ) ) ?;
910+ |pid| {
911+ if let Some ( on_spawn) = & self . on_command_spawn {
912+ on_spawn ( pid) ;
913+ }
914+ } ,
915+ ) ;
916+ #[ cfg( not( test) ) ]
917+ let out = output_with_input_timeout ( & mut command, PTY_LIST_TIMEOUT , Some ( payload) ) ;
918+ let out =
919+ out. map_err ( |error| anyhow:: anyhow!( "`pty metadata patch --id` failed: {error}" ) ) ?;
894920 if !out. status . success ( ) {
895921 anyhow:: bail!(
896922 "`pty metadata patch --id {}` failed: {}" ,
@@ -906,13 +932,26 @@ impl PtyCli {
906932 }
907933
908934 fn list_entries_at ( & self , root : & Path ) -> anyhow:: Result < Vec < PtyListEntry > > {
935+ #[ cfg( test) ]
936+ let out = output_full_stdout_with_timeout_observed (
937+ Command :: new ( & self . bin )
938+ . args ( [ "list" , "--json" ] )
939+ . env ( "PTY_ROOT" , root) ,
940+ PTY_LIST_TIMEOUT ,
941+ |pid| {
942+ if let Some ( on_spawn) = & self . on_command_spawn {
943+ on_spawn ( pid) ;
944+ }
945+ } ,
946+ ) ;
947+ #[ cfg( not( test) ) ]
909948 let out = output_full_stdout_with_timeout (
910949 Command :: new ( & self . bin )
911950 . args ( [ "list" , "--json" ] )
912951 . env ( "PTY_ROOT" , root) ,
913952 PTY_LIST_TIMEOUT ,
914- )
915- . map_err ( |error| anyhow:: anyhow!( "`pty list --json` failed: {error}" ) ) ?;
953+ ) ;
954+ let out = out . map_err ( |error| anyhow:: anyhow!( "`pty list --json` failed: {error}" ) ) ?;
916955 if !out. status . success ( ) {
917956 anyhow:: bail!(
918957 "`pty list --json` failed: {}" ,
@@ -3385,6 +3424,27 @@ mod tests {
33853424 }
33863425 }
33873426
3427+ fn fixture_barrier_path ( executable : & Path , suffix : & str ) -> PathBuf {
3428+ PathBuf :: from ( format ! ( "{}.{suffix}" , executable. display( ) ) )
3429+ }
3430+
3431+ fn reset_fixture_barrier ( executable : & Path ) {
3432+ for suffix in [ "ready" , "release" ] {
3433+ let marker = fixture_barrier_path ( executable, suffix) ;
3434+ match std:: fs:: remove_file ( & marker) {
3435+ Ok ( ( ) ) => { }
3436+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => { }
3437+ Err ( error) => panic ! ( "remove stale fixture marker {}: {error}" , marker. display( ) ) ,
3438+ }
3439+ }
3440+ }
3441+
3442+ fn release_ready_fixture ( pid : i32 , executable : & Path , what : & str ) {
3443+ let ready = fixture_barrier_path ( executable, "ready" ) ;
3444+ await_fixture_ready ( pid, & ready, what) ;
3445+ std:: fs:: write ( fixture_barrier_path ( executable, "release" ) , b"go\n " ) . unwrap ( ) ;
3446+ }
3447+
33883448 fn process_can_retain_cleanup_resources ( pid : i32 ) -> bool {
33893449 #[ cfg( target_os = "linux" ) ]
33903450 if linux_process_state ( pid) == Some ( 'Z' ) {
@@ -5758,13 +5818,28 @@ mod tests {
57585818 let executable = temporary. path ( ) . join ( "pty-capture" ) ;
57595819 std:: fs:: write (
57605820 & executable,
5761- "#!/bin/sh\n printf '%s\\ n' \" $@\" > \" $0.args\" \n cat > \" $0.stdin\" \n " ,
5821+ r#"#!/bin/sh
5822+ printf '%s\n' "$@" > "$0.args"
5823+ printf '' > "$0.ready.tmp"
5824+ mv "$0.ready.tmp" "$0.ready"
5825+ while [ ! -e "$0.release" ]; do sleep 0.01; done
5826+ cat > "$0.stdin"
5827+ "# ,
57625828 )
57635829 . unwrap ( ) ;
57645830 std:: fs:: set_permissions ( & executable, std:: fs:: Permissions :: from_mode ( 0o755 ) ) . unwrap ( ) ;
5831+ reset_fixture_barrier ( & executable) ;
5832+ let observed_executable = executable. clone ( ) ;
57655833 let cli = PtyCli {
57665834 bin : executable. display ( ) . to_string ( ) ,
57675835 catalog_root : temporary. path ( ) . to_path_buf ( ) ,
5836+ on_command_spawn : Some ( std:: sync:: Arc :: new ( move |pid| {
5837+ release_ready_fixture (
5838+ pid,
5839+ & observed_executable,
5840+ "fake PTY metadata command was not ready" ,
5841+ ) ;
5842+ } ) ) ,
57685843 } ;
57695844 let presentation = PtyPresentation {
57705845 pty_id : "stable.agent.id" . to_owned ( ) ,
@@ -6680,16 +6755,32 @@ mod tests {
66806755 let fake = tmp. path ( ) . join ( "pty-bin" ) ;
66816756 std:: fs:: write (
66826757 & fake,
6683- "#!/bin/sh\n rmdir \" $PTY_ROOT\" \n mkdir \" $PTY_ROOT\" \n printf '%s\\ n' '[]'\n " ,
6758+ r#"#!/bin/sh
6759+ rmdir "$PTY_ROOT"
6760+ mkdir "$PTY_ROOT"
6761+ printf '%s\n' '[]'
6762+ printf '' > "$0.ready.tmp"
6763+ mv "$0.ready.tmp" "$0.ready"
6764+ while [ ! -e "$0.release" ]; do sleep 0.01; done
6765+ "# ,
66846766 )
66856767 . unwrap ( ) ;
66866768 let mut permissions = std:: fs:: metadata ( & fake) . unwrap ( ) . permissions ( ) ;
66876769 permissions. set_mode ( 0o755 ) ;
66886770 std:: fs:: set_permissions ( & fake, permissions) . unwrap ( ) ;
66896771
6772+ reset_fixture_barrier ( & fake) ;
6773+ let observed_fake = fake. clone ( ) ;
66906774 let batch = PtyCli {
66916775 bin : fake. display ( ) . to_string ( ) ,
66926776 catalog_root : tmp. path ( ) . join ( "catalog" ) ,
6777+ on_command_spawn : Some ( std:: sync:: Arc :: new ( move |pid| {
6778+ release_ready_fixture (
6779+ pid,
6780+ & observed_fake,
6781+ "fake PTY inventory was not published" ,
6782+ ) ;
6783+ } ) ) ,
66936784 }
66946785 . task_observations_at_root ( & HashSet :: from ( [ "h.worker" ] ) , & root) ;
66956786 assert ! ( !batch. complete) ;
@@ -6778,6 +6869,7 @@ esac
67786869 let cli = PtyCli {
67796870 bin : fake. display ( ) . to_string ( ) ,
67806871 catalog_root : tmp. path ( ) . join ( "catalog" ) ,
6872+ on_command_spawn : None ,
67816873 } ;
67826874 let desired_ids = HashSet :: from ( [ "h.a" , "h.b" ] ) ;
67836875 let unavailable = cli. task_observations_at_root ( & desired_ids, & root) ;
@@ -6837,19 +6929,32 @@ esac
68376929 & fake,
68386930 r#"#!/bin/sh
68396931printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-07-31T10:00:00.000Z","displayName":"Build owner","tags":{"agent.presentation.schema":"1","unrelated":"preserved"}},{"name":"h.exit","status":"exited","exitCode":0,"pid":42,"createdAt":"2026-07-31T09:00:00.000Z"},{"name":"h.gone","status":"vanished","pid":43,"createdAt":"2026-07-31T08:00:00.000Z"}]'
6932+ printf '' > "$0.ready.tmp"
6933+ mv "$0.ready.tmp" "$0.ready"
6934+ while [ ! -e "$0.release" ]; do sleep 0.01; done
68406935"# ,
68416936 )
68426937 . unwrap ( ) ;
68436938 let mut permissions = std:: fs:: metadata ( & fake) . unwrap ( ) . permissions ( ) ;
68446939 permissions. set_mode ( 0o755 ) ;
68456940 std:: fs:: set_permissions ( & fake, permissions) . unwrap ( ) ;
68466941
6942+ let observed_fake = fake. clone ( ) ;
68476943 let cli = PtyCli {
68486944 bin : fake. display ( ) . to_string ( ) ,
68496945 catalog_root : catalog,
6946+ on_command_spawn : Some ( std:: sync:: Arc :: new ( move |pid| {
6947+ release_ready_fixture (
6948+ pid,
6949+ & observed_fake,
6950+ "fake PTY inventory was not published" ,
6951+ ) ;
6952+ } ) ) ,
68506953 } ;
68516954 let desired = HashSet :: from ( [ "h.live" , "h.exit" , "h.gone" ] ) ;
6955+ reset_fixture_barrier ( & fake) ;
68526956 let first = cli. task_observations ( & desired) ;
6957+ reset_fixture_barrier ( & fake) ;
68536958 let second = cli. task_observations ( & desired) ;
68546959 assert ! ( first. complete, "{:?}" , first. errors) ;
68556960 assert_eq ! ( first, second, "same PTY evidence changed generation" ) ;
@@ -6862,6 +6967,7 @@ printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-0
68626967 assert_eq ! ( first. observations[ 1 ] . state, ObservedState :: Exited ) ;
68636968 assert_eq ! ( first. observations[ 2 ] . state, ObservedState :: Vanished ) ;
68646969
6970+ reset_fixture_barrier ( & fake) ;
68656971 let sessions = cli. list_sessions ( ) . unwrap ( ) ;
68666972 let presentation = sessions[ 0 ] . presentation . as_ref ( ) . unwrap ( ) ;
68676973 assert_eq ! ( presentation. display_name. as_deref( ) , Some ( "Build owner" ) ) ;
@@ -6898,15 +7004,29 @@ printf '%s\n' '[{"name":"h.live","status":"running","pid":41,"createdAt":"2026-0
68987004 let fake = tmp. path ( ) . join ( "pty-bin" ) ;
68997005 std:: fs:: write (
69007006 & fake,
6901- "#!/bin/sh\n printf '%s\\ n' '[{\" name\" :\" h.live\" ,\" status\" :\" running\" }]'\n " ,
7007+ r#"#!/bin/sh
7008+ printf '%s\n' '[{"name":"h.live","status":"running"}]'
7009+ printf '' > "$0.ready.tmp"
7010+ mv "$0.ready.tmp" "$0.ready"
7011+ while [ ! -e "$0.release" ]; do sleep 0.01; done
7012+ "# ,
69027013 )
69037014 . unwrap ( ) ;
69047015 let mut permissions = std:: fs:: metadata ( & fake) . unwrap ( ) . permissions ( ) ;
69057016 permissions. set_mode ( 0o755 ) ;
69067017 std:: fs:: set_permissions ( & fake, permissions) . unwrap ( ) ;
7018+ reset_fixture_barrier ( & fake) ;
7019+ let observed_fake = fake. clone ( ) ;
69077020 let batch = PtyCli {
69087021 bin : fake. display ( ) . to_string ( ) ,
69097022 catalog_root : catalog,
7023+ on_command_spawn : Some ( std:: sync:: Arc :: new ( move |pid| {
7024+ release_ready_fixture (
7025+ pid,
7026+ & observed_fake,
7027+ "fake PTY inventory was not published" ,
7028+ ) ;
7029+ } ) ) ,
69107030 }
69117031 . task_observations ( & HashSet :: from ( [ "h.live" ] ) ) ;
69127032 assert ! ( !batch. complete) ;
0 commit comments