feat(test): D1/D2 diagnostics, and the mechanism they found

The instrument is diagnostic-only: keyed on the PID the test already
owns, snapshotting the test parent, launcher and launcher's children
before the SIGINT, 50ms after, and at the deadline, with per-thread
SigBlk/SigPnd, SigIgn/SigCgt, SigPnd/ShdPnd and PID/PPID/PGID/SID.
Nothing it does changes what the test asserts.

It found the mechanism on the first reproducing run, and the answer is
that I caused the failure.

SIGINT was IGNORED by every process in the target group.
SigIgn=0x1007 on the test parent, the launcher and the probe --- signals
1, 2, 3, 13, and signal 2 is SIGINT. All SigPnd/ShdPnd and every
per-thread SigBlk are zero, so this is ignored delivery, not blocked
delivery; launcher and probe share pgid, so nothing escaped the group.
kill(-pgid, SIGINT) is a no-op, the launcher waits in do_wait for a
child never told to stop, and the 5s deadline fires.

The ignore is inherited from the shell. Measured both ways: a foreground
child has SigIgn=0x1000 (SIGPIPE only), a `setsid nohup ... &` child has
SigIgn=0x0007 (SIGHUP|SIGINT|SIGQUIT). SIG_IGN is inherited across fork
AND survives exec, so it reaches the probe. Controlled experiment, same
command and tree minutes apart: foreground both copies ok, backgrounded
both copies FAILED.

I adopted `setsid nohup ... &` on 08-16 to stop the Bash tool's ten
-minute cap truncating gate runs. That is the "onset" this lane spent
nine revisions investigating. The subset-vs-full distinction was never
real --- every reduction ran foreground, every full sweep backgrounded,
perfectly confounded --- so §4's matrix measured my invocation method.
D0a's both-uniform-red is consistent: it backgrounded both arms.

Consequences: bet 1 is falsified and A5 struck, since an interactive
terminal does not ignore SIGINT and Ctrl-C on a real session works. The
probe's shutdown path is not defective. What remains is a runner
practice and a test that fails obscurely when its precondition is
absent. Framing promoted to revision 11, AWAITING APPROVAL, because the
problem statement has changed and the §7/§8 remedy no longer follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-19 14:15:49 +02:00
parent f058780a5d
commit afe3631ed6
No known key found for this signature in database
1 changed files with 144 additions and 2 deletions

View File

@ -171,6 +171,125 @@ mod crdt {
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
}
// ---- D1/D2 diagnostics (gpu-probe-sigint-teardown, framing rev 10) ----
//
// DIAGNOSTIC ONLY. Nothing here changes what the test asserts; it
// records why `wait_for_exit` below misses its deadline in a full
// `sweep-crdt`, and it is keyed on the PID this test already owns
// rather than by scanning for processes by age or command line ---
// the suite spawns root launchers from six call sites, so scanning
// cannot attribute one to this test.
/// Direct children of `pid`, from `/proc/<pid>/task/*/children`.
fn d12_children(pid: u32) -> Vec<u32> {
let mut out = Vec::new();
let Ok(tasks) = fs::read_dir(format!("/proc/{pid}/task")) else {
return out;
};
for task in tasks.flatten() {
if let Ok(kids) = fs::read_to_string(task.path().join("children")) {
out.extend(
kids.split_ascii_whitespace()
.filter_map(|k| k.parse::<u32>().ok()),
);
}
}
out.sort_unstable();
out.dedup();
out
}
/// One process's signal disposition and identity.
///
/// `SigBlk` is **per thread**, so it is read from every
/// `/proc/<pid>/task/*/status` rather than the process-wide file: a
/// delivery blocked on the one thread that matters would be
/// invisible in an aggregate reading. `SigPnd`/`ShdPnd` separate
/// "blocked but pending" from "ignored"; `SigIgn` distinguishes an
/// inherited `SIG_IGN` --- which survives both `fork` and `exec` ---
/// from a handler, which does not.
fn d12_facts(pid: u32) -> String {
let Ok(status) = fs::read_to_string(format!("/proc/{pid}/status")) else {
return format!(" pid {pid}: GONE\n");
};
let field = |name: &str| -> String {
status.lines().find(|l| l.starts_with(name)).map_or_else(
|| "?".to_owned(),
|l| l.split_whitespace().nth(1).unwrap_or("?").to_owned(),
)
};
let mut out = format!(
" pid {pid} ppid={} pgid={} sid={} state={} threads={}\n\
\x20 SigIgn={} SigCgt={} SigPnd={} ShdPnd={}\n",
field("PPid:"),
fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|st| st.rsplit_once(african_close()).map(|(_, rest)| rest
.split_whitespace()
.nth(2)
.unwrap_or("?")
.to_owned()))
.unwrap_or_else(|| "?".to_owned()),
fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|st| st.rsplit_once(african_close()).map(|(_, rest)| rest
.split_whitespace()
.nth(3)
.unwrap_or("?")
.to_owned()))
.unwrap_or_else(|| "?".to_owned()),
field("State:"),
field("Threads:"),
field("SigIgn:"),
field("SigCgt:"),
field("SigPnd:"),
field("ShdPnd:"),
);
if let Ok(tasks) = fs::read_dir(format!("/proc/{pid}/task")) {
for task in tasks.flatten() {
let tid = task.file_name().to_string_lossy().to_string();
if let Ok(ts) = fs::read_to_string(task.path().join("status")) {
let get = |n: &str| {
ts.lines()
.find(|l| l.starts_with(n))
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("?")
.to_owned()
};
out.push_str(&format!(
" tid {tid}: SigBlk={} SigPnd={} wchan={}\n",
get("SigBlk:"),
get("SigPnd:"),
fs::read_to_string(task.path().join("wchan"))
.unwrap_or_else(|_| "?".to_owned())
));
}
}
}
out
}
/// Snapshot the test parent, the launcher, and the launcher's
/// children (the GPU probe), at one point in time.
fn d12_snapshot(tag: &str, launcher: u32) -> String {
let mut out = format!(
" [D1/D2 {tag}]\n test parent:\n{}",
d12_facts(std::process::id())
);
out.push_str(&format!(" launcher:\n{}", d12_facts(launcher)));
for kid in d12_children(launcher) {
out.push_str(&format!(" launcher child:\n{}", d12_facts(kid)));
}
out
}
/// The `)` that closes comm in `/proc/<pid>/stat`; comm may itself
/// contain spaces or parentheses, so the fields after it are only
/// safe to index from the LAST `)`.
fn african_close() -> &'static str {
")"
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
let deadline = Instant::now() + timeout;
loop {
@ -1110,9 +1229,32 @@ mod crdt {
let daemon_pid = facts["daemon_pid"].parse::<u32>().expect("daemon pid");
let (survivor_id, mut survivor) = attach_surviving_frontend(&socket);
kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT)
// D1/D2: before, immediately after, and at the deadline.
let launcher_pid = launcher.id();
let before = d12_snapshot("before SIGINT", launcher_pid);
kill(Pid::from_raw(-launcher_pid.cast_signed()), Signal::SIGINT)
.expect("signal launcher group");
let _ = wait_for_exit(&mut launcher, Duration::from_secs(5));
thread::sleep(Duration::from_millis(50));
let after = d12_snapshot("50ms after SIGINT", launcher_pid);
let deadline = Instant::now() + Duration::from_secs(5);
let exited = loop {
if launcher.try_wait().expect("inspect launcher").is_some() {
break true;
}
if Instant::now() >= deadline {
break false;
}
thread::sleep(Duration::from_millis(20));
};
if !exited {
eprintln!(
"D1/D2 diagnostics --- launcher {launcher_pid} did not exit within 5s\n\
{before}{after}{}",
d12_snapshot("at the 5s deadline", launcher_pid)
);
}
assert!(exited, "child did not exit within 5s");
write_message(
&mut survivor,