Skip to content

fix(destroy): probe a group that no scan shows before the wait reads it ended - #334

Merged
ThomasK33 merged 10 commits into
mainfrom
fix/destroy-wait-probes-unseen-group
Nov 5, 2026
Merged

ThomasK33 merged 10 commits into
mainfrom
fix/destroy-wait-probes-unseen-group

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

#333 makes stopProgramGroup freeze a fork chain whose member ends between two reads. A freeze can still miss a chain: when another process sends SIGCONT before each of its 5 judgements, or when a SIGSTOP does not take effect within them on a loaded machine. After a miss, the wait read the running chain as ended, and destroy recorded destroyed while the chain ran. The cause was in isProcessGroupAlive: when its probe kill(-pgid, 0) found the group but no scan showed a member, live or zombie, it took the processes for ones reaped before the scans and read the group ended (?? false).

What changes for users

  • When a freeze misses a fork chain, destroy now fails with HOST_TIMEOUT and leaves the session as it was. It no longer reports destroyed while the chain runs.
  • Under a PID 1 that never reaps, a group whose scans show only zombies still reads ended, because the zombies of a killed group stay there. So under such a PID 1, a missed chain can still outlive a destroy that reports success. USAGE item 3 says so.
  • A group that shows a process that job control or a tracer stopped (kill -STOP, or Ctrl-Z in a shell) gets no SIGSTOP from destroy before its first freeze. So the SIGCONT after the judgements cannot resume that process beside a running one. Before, a group with one such stopped member and one running member without an autogroup got SIGSTOP and then SIGCONT, which resumed the stopped member (advisor follow-up on fix(destroy): freeze a group whose member ends between two reads before destroy gives up on it #333).

No CLI JSON or exit codes change.

What changes for maintainers

  • isProcessGroupAlive: return groupHasLiveProcess(pgid) ?? probeFindsGroup(pgid);. A second probe after the scans decides. Processes reaped meanwhile are gone (ESRCH), and the group reads ended. A fork chain still holds the id, and the group reads alive.
  • stopProgramGroup: if (!frozen && showsStoppedMember(group.pid)) { return; } before the first SIGSTOP. showsStoppedMember scans the group for a member in state T or t.
  • judgeFrozenGroup (gc) waits STOP_SETTLE_MS (1 ms) after its SIGSTOP, inside the try, as stopProgramGroup does since fix(destroy): freeze a group whose member ends between two reads before destroy gives up on it #333. An ended group costs 20 more ms on top of its ten passes.
  • README: src/host/README.md names the freeze of a member that ends between two reads, the stopped-member rule, and the wait after each SIGSTOP. The long comment line in stopProgramGroup is rewrapped.
  • Model:
    • The guard WaitProbesUnseenGroup, and the environment constant FreezeMisses: with Unseen, every freeze misses the chain.
    • ProbeFindsUnseen is the second probe: WaitProbesUnseenGroup without ZombiesInView. GroupSeen reads a group with a token alive under Unseen through it, and ProbeSees (a group without a token) now reads the program's processes under Unseen only through it, as the code does.
    • GroupJudged is GroupSeen without that probe, and gc reads it with its own probe (GcSeesGroup).
    • ProvenKill gets no kill under Unseen without the freeze or with FreezeMisses.
    • Runs: current-unseen-misses (pass, 68,556,464 distinct states) and mutant-WaitProbesUnseenGroup-unseen-misses (violates R11). mutant-FreezesUnseenGroup-unseen becomes mutant-UnseenBothGuards-unseen (both guards off, violates R11): either guard alone keeps R11, like mutant-GcBothChecks. That makes 92 runs and forty guards. Fourteen runs explore more than 20 million distinct states.
    • Without Unseen, the changed operators equal the old ones (ProbeSees, GroupSeen = GroupJudged, ProvenKill), so every run without Unseen explores the same states as before.
  • facts.mjs: waitProbeFacts reads WaitProbesUnseenGroup from isProcessGroupAlive and probeFindsGroup. freezeFacts reads the stopped-member check, and frozenJudgesTwice reads gc's wait.

Validation

  • Red first (each fails on fix(destroy): freeze a group whose member ends between two reads before destroy gives up on it #333's head):
    • "is alive while no scan shows a member and the probe finds the id"
    • ['ok', [], true] in "on linux, when the probe ends ... and the group shows ..., is alive"
    • "sends no signal to a group with a stopped member and a running member that shows no autogroup"
    • Also new: "on linux, reads a group ended when no scan shows a member and the second probe finds none" (the reaped case).
  • Model, locally with the generated configurations:
    • current-unseen (64,052,288 states) and current-unseen-misses pass.
    • mutant-UnseenBothGuards-unseen and mutant-WaitProbesUnseenGroup-unseen-misses violate DestroyedProgramStopped.
    • mutant-GcProbesUnseenGroup-unseen and mutant-GcFreezesUnseenGroup-unseen-zombies still violate RemovedSessionStopped.
    • CI runs all 92 runs.
  • Real CLI, the fork-chain scenario of fix(destroy): freeze a group whose member ends between two reads before destroy gives up on it #333 with a debug copy that is not committed:
  • tsc, oxlint, oxfmt, check.sh --drift-only and test/unit (2,073 tests) pass.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

…re destroy gives up on it

stopProgramGroup sent SIGSTOP only after a judgement of gone. A member of a
fork chain that a scan shows can end before its autogroup read, so the
judgement reads unknown, and stopProgramGroup returned with no freeze and no
kill. A witness that ended before its read again made the loop judge again
without a freeze. The wait after it then read the unseen chain ended, and
destroy recorded destroyed while the chain ran. CI of #331 hit this in "gc
keeps an exited session while a fork chain that the program left runs".

Now every judgement that gets no kill and is not unproven sends SIGSTOP and
judges the frozen group again: gone as before, unknown, and owned with a
witness that ended. A stopped member cannot end, so the next judgement reads
its autogroup and the witness, and the group gets the kill. The loop keeps
its 5 attempts, and a frozen group that gets no kill still gets SIGCONT in
the finally block.

New unit tests: a member that ends before its autogroup read, and a witness
that ends before its read again, both get SIGSTOP and then SIGKILL (both fail
on main). A member whose autogroup /proc refuses now gets 5 SIGSTOP and a
SIGCONT, and still no kill. facts.mjs reads the new loop. The model is
unchanged: it already assumes that the freeze brings the witness into view.
…group

Codex round 1 on #333: a group that job control stopped, whose autogroup or
boot id /proc refuses, reads unknown at every judgement. The new freeze sent
it SIGSTOP and then SIGCONT, which resumed a program that its user stopped.

The unknown verdict now says whether a member without an autogroup still
runs. A stopped member cannot end between two reads, so /proc refused its
autogroup, and stopProgramGroup sends no signal then. A refused boot id is
unknown without a running member too, because no later judgement can prove
the group without it. A running member without an autogroup still gets the
freeze, once: its stopped member reads the same way, and the group gets
SIGCONT.

New unit test: a stopped group whose member shows no autogroup gets no
signal (fails on the previous head).
…readable-member

# Conflicts:
#	.agents/skills/verify-session-lifecycle/coverage.json
…obe finds its id

When the probe kill(-pgid, 0) found the group but no scan showed a member,
live or zombie, isProcessGroupAlive read the group ended: it took the
processes for ones reaped before the scans. A fork chain that no scan shows
reads the same way, so a freeze that missed the chain let the wait after it
read the running chain ended, and destroy recorded destroyed. Now a second
probe after the scans decides: reaped processes are gone, and a chain still
holds the id, so the wait times out with HOST_TIMEOUT instead. Scans that
show only zombies still read the group ended, because under a PID 1 that
never reaps the zombies of a killed group stay.

Model: the guard WaitProbesUnseenGroup and the environment constant
FreezeMisses (every freeze of an Unseen group misses, as when a SIGCONT from
another process comes before each judgement). ProbeSees now reads the
program's processes under Unseen only through that probe, as the code does.
GroupJudged keeps gc on its own probe. New runs: current-unseen-misses (pass,
68,556,464 distinct states) and mutant-WaitProbesUnseenGroup-unseen-misses
(violates R11). mutant-FreezesUnseenGroup-unseen becomes
mutant-UnseenBothGuards-unseen: either guard alone keeps R11. That makes 92
runs and forty guards.

Also from the advisor on #333:
- stopProgramGroup sends no SIGSTOP before its first freeze to a group that
  shows a member that job control or a tracer stopped, so the SIGCONT after
  the judgements cannot resume that member beside a running one.
- src/host/README.md names the freeze of a member that ends between two
  reads, and the comment of stopProgramGroup is rewrapped.
…readable-member

# Conflicts:
#	.agents/skills/verify-session-lifecycle/coverage.json
…the group

The merge-group CI of #333 failed "gets no kill and reads alive when the
autogroup of its member names no autogroup": the real group got two
SIGSTOPs before its SIGCONT. A SIGSTOP takes effect only when the kernel
next runs the member. A read of /proc/<pid>/stat right after kill() showed
the member not yet stopped in 1,984 of 2,000 tries here, and the stop took
about 30 us (max 405 us) on an idle machine. A full /proc scan usually gives
it that time, but on a loaded runner the next judgement read the member
running and froze the group again. The code is safe either way: a member
that still runs gets another SIGSTOP, within the 5 judgements, and the
group still gets its SIGCONT.

stopProgramGroup now waits STOP_SETTLE_MS (1 ms) after each SIGSTOP, so the
next judgement mostly reads the frozen members stopped and fewer of the 5
judgements go to a stop that has not taken effect. On one CPU shared with
four busy loops, the test saw a second SIGSTOP in 6 of 24 runs without the
wait and in 1 of 24 with it. The test now accepts 1 to 5 SIGSTOPs before
the SIGCONT. facts.mjs reads the wait as part of the freeze.
…wait-probes-unseen-group

# Conflicts:
#	.agents/skills/verify-session-lifecycle/SKILL.md
#	.agents/skills/verify-session-lifecycle/coverage.json
#	src/host/README.md
… scan

The same asynchronous stop as in stopProgramGroup: judgeFrozenGroup judged
the group right after its SIGSTOP, so on a loaded machine the scan could
read the chain still forking, and the frozen judgement read gone. It now
waits STOP_SETTLE_MS (1 ms) inside the try, before the judgement, so the
SIGCONT in the finally block still follows every SIGSTOP. An ended group
costs 20 such waits, 20 ms, on top of its ten passes.
…es-unseen-group

# Conflicts:
#	.agents/skills/verify-session-lifecycle/SKILL.md
#	.agents/skills/verify-session-lifecycle/coverage.json
#	.agents/skills/verify-session-lifecycle/scripts/facts.mjs
#	docs/USAGE.md
#	src/host/programGroup.ts
#	test/unit/host/programGroup.test.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T01:54:42.271735Z a2f5225 Manual request
🔒 Security Review ✅ Completed 2026-11-05T01:55:59.188026Z a2f5225 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: a2f5225563

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: a2f5225563

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 4afadba Nov 5, 2026
25 checks passed
@ThomasK33
ThomasK33 deleted the fix/destroy-wait-probes-unseen-group branch November 5, 2026 03:04
@github-actions github-actions Bot mentioned this pull request Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant