From 5a3a79c46518c06e27b3e4e467e2fb90a1f532b4 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Wed, 4 Nov 2026 01:24:00 +0100 Subject: [PATCH] Read a legacy daemon that exited unreaped as gone in explicit controls check_daemon_identity has read a zombie as Gone since #207, but the pre-boot-ID branch of check_control_identity still looked only at the thread group and the start token, and a zombie keeps both. A legacy record over a killed, unreaped daemon was LegacyVerified there, and that is the verifier attach also uses since #209. Nothing acted on it wrongly. Attach, kill and rm read the inventory identity first, which is Gone, and rm's exit wait counts a zombie as exited. But the target resolution that attach and child signals share returned the zombie's PID, so a signal went on to ask a dead daemon for STATUS instead of reporting the session not running. Read the zombie state in the same /proc//status read on Linux, and from the process table row on macOS, as check_daemon_identity does. --- crates/pty-core/src/registry/list.rs | 61 ++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/pty-core/src/registry/list.rs b/crates/pty-core/src/registry/list.rs index 33f69c9e..f155a720 100644 --- a/crates/pty-core/src/registry/list.rs +++ b/crates/pty-core/src/registry/list.rs @@ -449,6 +449,8 @@ pub enum ControlIdentityCheck { /// Keep modern records on the full identity check. Only absent boot evidence /// permits the pre-boot-ID control policy, never present but invalid evidence, /// and that policy needs a recorded start token: a PID alone is not authority. +/// A zombie is Gone under either policy, as in [`check_daemon_identity`]: it +/// still shows the recorded start token, but no daemon is left behind it. pub fn check_control_identity(pid: i32, metadata: &SessionMetadata) -> ControlIdentityCheck { if metadata.daemon_boot_id.is_some() { return match check_daemon_identity(pid, metadata) { @@ -465,9 +467,10 @@ pub fn check_control_identity(pid: i32, metadata: &SessionMetadata) -> ControlId return ControlIdentityCheck::Mismatch; } #[cfg(target_os = "linux")] - match linux_thread_group(pid) { - Ok(Some(tgid)) if tgid != pid => ControlIdentityCheck::Mismatch, - Ok(Some(_)) => { + match linux_leader_status(pid) { + Ok((Some(tgid), _)) if tgid != pid => ControlIdentityCheck::Mismatch, + Ok((Some(_), true)) => ControlIdentityCheck::Gone, + Ok((Some(_), false)) => { check_legacy_control_identity_with(pid, metadata, read_linux_leader_start_token) } Err(error) if process_status_is_gone(&error) => ControlIdentityCheck::Gone, @@ -477,6 +480,7 @@ pub fn check_control_identity(pid: i32, metadata: &SessionMetadata) -> ControlId match crate::proctable::process(pid) { crate::proctable::Answer::NotPresent => ControlIdentityCheck::Gone, crate::proctable::Answer::Unknown(_) => ControlIdentityCheck::Unknown, + crate::proctable::Answer::Known(row) if row.is_zombie() => ControlIdentityCheck::Gone, crate::proctable::Answer::Known(_) => { check_legacy_control_identity_with(pid, metadata, read_process_start_token) } @@ -1684,6 +1688,57 @@ mod control_identity_tests { assert_eq!(check_control_identity(pid, &metadata), ControlIdentityCheck::Mismatch); } + #[test] + fn a_legacy_daemon_that_exited_unreaped_is_gone_not_verified() { + if crate::registry::list::skip_without_start_token() { + return; + } + let root = std::env::temp_dir().join(format!("pty-legacy-zombie-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + let pid = child.id() as i32; + let mut metadata = legacy_record(); + metadata.daemon_pid = Some(pid); + metadata.daemon_start_token = read_process_start_token(pid); + assert_eq!( + check_control_identity(pid, &metadata), + ControlIdentityCheck::LegacyVerified + ); + // The target attach and child signals resolve. + assert_eq!( + read_explicit_control_target_in(&root, "legacy", Some(&metadata), true), + Some(pid) + ); + + // Killed and never waited for: the zombie keeps the PID and the token. + child.kill().unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !matches!( + crate::proctable::has_exited(pid), + crate::proctable::Answer::Known(true) + ) { + assert!( + std::time::Instant::now() < deadline, + "the child never became a zombie" + ); + std::thread::sleep(Duration::from_millis(10)); + } + #[cfg(target_os = "linux")] + assert_eq!(read_process_start_token(pid), metadata.daemon_start_token); + assert_eq!(check_control_identity(pid, &metadata), ControlIdentityCheck::Gone); + assert_eq!( + read_explicit_control_target_in(&root, "legacy", Some(&metadata), true), + None + ); + child.wait().unwrap(); + assert_eq!(check_control_identity(pid, &metadata), ControlIdentityCheck::Gone); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn present_boot_evidence_never_falls_back_to_legacy() { if crate::registry::list::skip_without_start_token() {