## docs/adr-0050-silent-skip...origin/main [ahead 1] M crates/spt/tests/worker_lifecycle_e2e.rs M docs/KNOWN-HAZARDS.md M docs/adr/0050-golden-ci-merge-integration.md M traceable-reqs.toml warning: in the working copy of 'crates/spt/tests/worker_lifecycle_e2e.rs', LF will be replaced by CRLF the next time Git touches it diff --git a/crates/spt/tests/worker_lifecycle_e2e.rs b/crates/spt/tests/worker_lifecycle_e2e.rs index a9610ed..0c7bb96 100644 --- a/crates/spt/tests/worker_lifecycle_e2e.rs +++ b/crates/spt/tests/worker_lifecycle_e2e.rs @@ -45,26 +45,64 @@ fn output_bounded(mut cmd: Command, deadline: Duration) -> std::process::Output .expect("run spt") } -/// Best-effort cross-platform kill of a pid — reaps the daemon `api worker-start` -/// auto-starts (REQ-DAEMON-3), so a failed assertion can't leak it on the shared -/// gate box. -fn kill_pid(pid: u32) { +/// Render every child outcome before a destructive cleanup boundary and in every +/// assertion failure. A hard-killed harness cannot unwind and print its panic. +fn output_diagnostic(label: &str, out: &std::process::Output) -> String { + format!( + "{label}: status={} stdout={:?} stderr={:?}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// Reap only the daemon binary built under this test job's own target directory. +/// A pid breadcrumb is not identity: Windows can recycle it onto a foreign tree. +fn reap_daemon(home: &std::path::Path, owned_target: &std::path::Path) { + let Ok(txt) = std::fs::read_to_string(home.join("daemon.pid")) else { + eprintln!("WORKER_E2E_REAP: no daemon.pid"); + return; + }; + let pid = txt + .trim() + .parse::() + .expect("WORKER_E2E_REAP: daemon.pid must contain a numeric pid"); + let self_pid = std::process::id(); + let ancestors = spt_store::proc::process_ancestry(); + assert_ne!( + pid, self_pid, + "WORKER_E2E_REAP_REFUSED: target pid is the test harness" + ); + assert!( + !ancestors.contains(&pid), + "WORKER_E2E_REAP_REFUSED: target pid {pid} is a test-harness ancestor: {ancestors:?}" + ); + let exe = spt_store::proc::exe_path(pid).unwrap_or_else(|| { + panic!("WORKER_E2E_REAP_REFUSED: cannot resolve executable for pid {pid}") + }); + assert!( + exe.starts_with(owned_target), + "WORKER_E2E_REAP_REFUSED: pid {pid} resolves outside owned target: exe={} target={}", + exe.display(), + owned_target.display() + ); + eprintln!( + "WORKER_E2E_REAP: pid={pid} exe={} target={}", + exe.display(), + owned_target.display() + ); #[cfg(windows)] - let _ = Command::new("taskkill") + let reaped = Command::new("taskkill") .no_window() .args(["/PID", &pid.to_string(), "/F", "/T"]) - .output(); + .output() + .expect("WORKER_E2E_REAP: run taskkill"); #[cfg(unix)] - let _ = Command::new("kill").args(["-9", &pid.to_string()]).output(); -} - -/// Reap the auto-started daemon via its pid breadcrumb (`daemon.pid`), if any. -fn reap_daemon(home: &std::path::Path) { - if let Ok(txt) = std::fs::read_to_string(home.join("daemon.pid")) { - if let Ok(pid) = txt.trim().parse::() { - kill_pid(pid); - } - } + let reaped = Command::new("kill") + .args(["-9", &pid.to_string()]) + .output() + .expect("WORKER_E2E_REAP: run kill"); + eprintln!("{}", output_diagnostic("WORKER_E2E_REAP_RESULT", &reaped)); } /// Seed a bound PARENT perch pinned to `sid` — the record worker-start @@ -75,13 +113,23 @@ fn seed_parent(home: &std::path::Path, id: &str, sid: &str) { std::fs::create_dir_all(&path).unwrap(); info::write_info( &path, - &InfoJson::new(id, "2026-07-06T00:00:00Z", std::process::id(), sid, "live_agent"), + &InfoJson::new( + id, + "2026-07-06T00:00:00Z", + std::process::id(), + sid, + "live_agent", + ), ) .unwrap(); } /// `spt api ` against the test SPT_HOME, deadline-bounded. -fn spt_api(spt_bin: &std::path::Path, home: &std::path::Path, args: &[&str]) -> std::process::Output { +fn spt_api( + spt_bin: &std::path::Path, + home: &std::path::Path, + args: &[&str], +) -> std::process::Output { let mut cmd = Command::new(spt_bin); cmd.no_window() .arg("api") @@ -101,10 +149,18 @@ fn worker_start_mints_on_stdout_and_stop_authenticates_by_parent_sid() { let psid = "psid-1"; seed_parent(home.path(), parent, psid); - // Run every real-binary call FIRST, then reap the auto-started daemon, THEN - // assert — so a failed assertion can never leak the daemon on the gate box. - let start1 = spt_api(&spt_bin, home.path(), &["worker-start", parent, "--session-id", psid]); - let start2 = spt_api(&spt_bin, home.path(), &["worker-start", parent, "--session-id", psid]); + // Run every real-binary call FIRST, then print their complete outcomes BEFORE + // the destructive reaper. Even a hard-killed harness leaves a diagnosable log. + let start1 = spt_api( + &spt_bin, + home.path(), + &["worker-start", parent, "--session-id", psid], + ); + let start2 = spt_api( + &spt_bin, + home.path(), + &["worker-start", parent, "--session-id", psid], + ); let stop = spt_api( &spt_bin, home.path(), @@ -115,57 +171,73 @@ fn worker_start_mints_on_stdout_and_stop_authenticates_by_parent_sid() { home.path(), &["worker-start", parent, "--session-id", "wrong-sid"], ); - reap_daemon(home.path()); + let start1_diag = output_diagnostic("worker-start w1", &start1); + let start2_diag = output_diagnostic("worker-start w2", &start2); + let stop_diag = output_diagnostic("worker-stop w1", &stop); + let refused_diag = output_diagnostic("worker-start wrong sid", &refused); + for diagnostic in [&start1_diag, &start2_diag, &stop_diag, &refused_diag] { + eprintln!("{diagnostic}"); + } + let owned_target = spt_bin + .parent() + .expect("spt test binary has a target directory"); + reap_daemon(home.path(), owned_target); // ── (1) worker-start mints {parent}-w1; the BARE id is the whole of stdout. ── let s1_out = String::from_utf8_lossy(&start1.stdout); let s1_err = String::from_utf8_lossy(&start1.stderr); - assert!(start1.status.success(), "worker-start must succeed with the parent's sid: {s1_err}"); + assert!( + start1.status.success(), + "worker-start must succeed with the parent's sid: {start1_diag}" + ); assert_eq!( s1_out.trim(), "hostmaster-w1", - "stdout is the bare minted id, nothing else: {s1_out:?}" + "stdout is the bare minted id, nothing else: {start1_diag}" ); assert!( !s1_out.contains("WORKER_STARTED"), - "the human WORKER_STARTED line must NOT be on stdout: {s1_out:?}" + "the human WORKER_STARTED line must NOT be on stdout: {start1_diag}" ); assert!( s1_err.contains("WORKER_STARTED:hostmaster-w1 under hostmaster"), - "the human status line rides stderr: {s1_err:?}" + "the human status line rides stderr: {start1_diag}" ); // ── (2) monotonic: a second start under the same parent mints w2. ── - assert!(start2.status.success()); + assert!( + start2.status.success(), + "second worker-start must succeed: {start2_diag}" + ); assert_eq!( String::from_utf8_lossy(&start2.stdout).trim(), "hostmaster-w2", - "the per-parent counter is monotonic" + "the per-parent counter is monotonic: {start2_diag}" ); // ── (3) W-2: worker-stop authenticates by the PARENT's sid (the natural adapter // emission). The OLD empty-sid + token-only record refused this 100%. ── assert!( stop.status.success(), - "worker-stop --session-id must authenticate (W-2 sid-symmetric): {}", - String::from_utf8_lossy(&stop.stderr) + "worker-stop --session-id must authenticate (W-2 sid-symmetric): \ + {stop_diag}" ); assert!( String::from_utf8_lossy(&stop.stderr).contains("WORKER_STOPPED:hostmaster-w1"), - "the soft-stop status line: {}", - String::from_utf8_lossy(&stop.stderr) + "the soft-stop status line: {stop_diag}" ); // ── (4) a refused start is loud + stdout stays EMPTY (no id leaks). ── - assert!(!refused.status.success(), "a wrong parent sid must refuse"); + assert!( + !refused.status.success(), + "a wrong parent sid must refuse: {refused_diag}" + ); assert!( String::from_utf8_lossy(&refused.stdout).trim().is_empty(), - "stdout must be EMPTY on a refused start (no minted id leaks): {:?}", - String::from_utf8_lossy(&refused.stdout) + "stdout must be EMPTY on a refused start (no minted id leaks): {refused_diag}" ); assert!( String::from_utf8_lossy(&refused.stderr).contains("AUTH_REFUSED"), - "the refusal names the auth gate: {}", - String::from_utf8_lossy(&refused.stderr) + "the refusal names the auth gate: {refused_diag}" ); }