Repository navigation
fix(destroy): destroy --force ends a session whose program group it cannot read - #327
Conversation
… it ended Codex on #302: under a PID 1 that never reaps, a fork chain leaves its zombies in view while its live member escapes both scans, so gc read the group ended and removed program.json while the chain ran. judgeFrozenGroup now sends SIGSTOP, as stopProgramGroup does, judges the stopped group, and sends SIGCONT in a finally. The integration test runs the chain under the subreaper that never reaps.
The model gets the guard GcFreezesUnseenGroup and the environment constant ZombiesInView. With zombies in view the probe alone proves nothing, and gc sees the chain only through the freeze. mutant-GcFreezesUnseenGroup-unseen-zombies must violate R20. With every guard, ZombiesInView changes nothing, so current-unseen covers the pass run. ProvesGroupOwnership lets judgeFrozenGroup send SIGSTOP and SIGCONT, and the raw kill stays in stopProgramGroup. USAGE limits 3 and 5 name the two signals.
check.sh --update-coverage at 1c1bd1c6 gave 79/79 runs as expected in 1,121 s. current has 29,309,460 distinct states and current-no-token 29,186,546.
…annot read A read of program.json, host.json or /proc that fails with a system error, such as EACCES or EIO, failed every later check, so gc kept the session forever and destroy failed on the same read. destroy --force of a session whose host is gone now stops what it can without the group and records destroyed. A group that reads alive and a host that does not end (HOST_TIMEOUT) still fail it. gc's skip reasons name destroy --force for such a read.
…nder refused reads CheckFails: while program.json names the program, every read of it by destroy, list and gc fails. gc and its reconcile skip the session, plain destroy fails, and with ForceSkipsUnreadableGroup destroy --force records destroyed after it stopped the host alone. The ghost dblind marks such a record, which R11, R16 and R20 leave out, and WitnessUncheckedForceDestroys shows it. Refused: /proc of the CLI refuses the program's autogroup, so the group reads alive while it runs, gets no kill, and gc reads it not proven. R20 leaves Refused out, and WitnessRefusedGcRemovesLeftover shows that removal. facts.mjs reads the new guard and the stopUnlessUnreadable steps of destroySession.
…sed-read runs USAGE says when gc names destroy --force and that a process the program left can outlive it. SKILL.md gets the ForceSkipsUnreadableGroup section, the five new runs, R11, R16 and R20 with their exclusions, and the Refused scope. facts.mjs hashes isUnreadableGroupError.
Codex on #326: SIGSTOP does not nest, so another gc or a destroy can send SIGCONT before this gc judges the frozen group, and a fork chain then read gone and lost program.json. judgeFrozenGroup now reads a frozen group that still reads gone as inconclusive, so provenProgramGroupRuns freezes it again, and only GC_JUDGEMENTS such judgements read it ended. The model has one gc and does not change, so the record comes from facts.mjs at this commit.
…cile repairs to destroyed
… fix/destroy-force-unreadable-group
… a readable group check.sh --update-coverage at 4e9707e gave 83/84 runs as expected: only current-checkfails broke R16, through the destroy marker. 878e73b fixes that, and current-checkfails, its witness and its mutant passed alone on the fixed model (12,444,472 distinct states for current-checkfails). The other runs never assign the blind kind, and every fact value is the same, so this record comes from facts.mjs at this commit. CI runs every run again.
|
@codex review |
|
@codex security review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
…ded in one pass Advisor on #326: a zombie-only ended session under a PID 1 that never reaps took ten passes of six /proc scans and ten SIGSTOP/SIGCONT pairs: about 2.5 s with 2,264 processes in /proc. judgeFrozenGroup now freezes and judges at most twice (FROZEN_JUDGEMENTS). The second freeze covers a SIGCONT from another gc or a destroy. When that judgement reads gone too, nothing live was in view to freeze, and the group reads ended: about 300 ms there, and 50 ms with 258 processes. Also from the advisor: unit tests for a frozen group whose live member is in another autogroup or session (ended, and resumed) and for a /proc/self of another pid (no SIGSTOP), and USAGE limit 5 says that gc keeps the session only when the frozen check proves the group.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
…eadable-group # Conflicts: # .agents/skills/verify-session-lifecycle/SKILL.md # .agents/skills/verify-session-lifecycle/coverage.json # .agents/skills/verify-session-lifecycle/scripts/check.sh # .agents/skills/verify-session-lifecycle/scripts/facts.mjs # docs/USAGE.md # src/host/programGroup.ts # test/unit/host/programGroup.test.ts
destroy --force fell back to stopping the host alone for any system error inside stopSessionProcesses, so a group that had already read alive and then hit an errno on the witness re-read or in the wait got no SIGKILL and was recorded destroyed. The fallback now covers only the read of the group and its first judgement. A failure after that verdict fails the destroy. isUnreadableGroupError now accepts a listed set of errnos that a retry does not change (EACCES, EPERM, EIO, EISDIR, ENOTDIR, ELOOP, ENAMETOOLONG), also as the cause of a STORAGE_READ_ERROR. EMFILE, ENFILE, ENOMEM, EAGAIN and EINTR no longer skip the group. Also: a unit test that reaches the /proc/self guard inside judgeFrozenGroup, a USAGE limit for a SIGCONT from another gc or destroy before both frozen judgements, the forced fallback in src/host/README.md, and the SKILL and facts.mjs updates. The model is unchanged and every guard constant matches the last full check.
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cf79ceeabb
ℹ️ 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".
…oup read fails Codex round 3 on #327: a reconcile also writes the manifest and removes the socket, so an EACCES from the socket unlink got the hint "destroy --force removes it" although destroy --force fails the same way. gc now asks forceSkipsGroup, the read and first judgement that destroy --force runs, and names destroy --force only when that read fails with an error that isUnreadableGroupError accepts. Also from Codex: isUnreadableGroupError no longer claims host.json reads. readHostRecord reads a failed read as missing, so the group reads unshared, destroy --force fails with HOST_TIMEOUT and gc removes the session. USAGE, the host README and SKILL say so. Only the gcSessions and defaultDependencies fingerprints changed, and every guard constant still matches the last full check.
|
@codex review |
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Codex round 3 on #327 (P1) and the maintainer: reading a group ended after two frozen judgements that read gone was a regression from #326, which retried up to GC_JUDGEMENTS times. A SIGCONT from another gc or a destroy before both judgements let gc remove the folder of a live fork chain. judgeFrozenGroup now returns null (inconclusive) after both frozen judgements read gone, so provenProgramGroupRuns judges the group again. It keeps the two freezes per judgement, so a gc sends at most 10 x 2 SIGSTOP/SIGCONT pairs. An ended zombie-only group with about 2,300 processes in /proc now takes about 3.5 s at a load of 90, against about 2.5 s for #326's ten single freezes. New unit test: a live member that escapes both frozen judgements of one pass keeps the session (fails with the old return). facts.mjs expects the null return, and a false return turns GcFreezesUnseenGroup FALSE. USAGE limit 5 and SKILL state the cost and the remaining race (all 20 frozen judgements).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c1ef1d504d
ℹ️ 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".
…stroy --force skips it Codex round 4 on #327 (P2): EIO, as on a network file system or from a one-off /proc read, need not repeat. destroy --force then skipped the group of a terminal session, recorded it destroyed, and the next gc deleted program.json while a leftover could still run. stopUnlessUnreadable now reads and judges the group again after DESTROY_POLL_INTERVAL_MS (100 ms, abortable) when the first read fails with an error that isUnreadableGroupError accepts. It skips the group only when that read fails the same way. A second read that passes stops the group as usual. The model is unchanged: CheckFails fails every read. New unit tests: EIO on the first read and a good second read gets the group its SIGKILL (fails on the previous head), and an abort between the two reads stops the destroy after one read (fails when the delay ignores the signal). facts.mjs reads the new statements, and a catch that skips the second read or the delay turns ForceSkipsUnreadableGroup FALSE. Also (P3): USAGE limit 5 says "up to twice in each of up to ten checks", because a frozen unknown judgement sends one pair.
…eadable-group # Conflicts: # .agents/skills/verify-session-lifecycle/SKILL.md # .agents/skills/verify-session-lifecycle/coverage.json # .agents/skills/verify-session-lifecycle/model/SessionLifecycle.tla # .agents/skills/verify-session-lifecycle/scripts/check.sh
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Why
The rule: gc never keeps a session that agent-tty gives no way to remove. Two cases broke it after #302.
program.jsonthat refuses the read (EACCES), or a/procscan that fails (EIO). Then every later check fails too. gc kept such a session forever, anddestroyanddestroy --forcefailed on the same read. That also covers a session whose host died: gc's reconcile reads the group, so gc reported "failed to reconcile session" forever.unknown). The code has done that since fix(gc): keep a session whose program group still runs, and model-check the lifecycle in CI #302.Follow-up of #326. This PR now targets
main, and it also holds the advisor follow-ups of #326:FROZEN_JUDGEMENTS). The second freeze covers a SIGCONT from another gc or adestroy. When both frozen judgements readgone, the judgement stays inconclusive, andprovenProgramGroupRunsjudges again, up to ten times, as on main. So a gc sends at most 10 x 2 SIGSTOP/SIGCONT pairs, and a live chain reads ended only when a SIGCONT from elsewhere comes before all 20 frozen judgements. An earlier version of this PR read the group ended after the first two, for speed. Codex and the maintainer rejected that as a regression from fix(gc): freeze a fork chain behind its zombies before gc reads it ended #326. Under a subreaper that never reaps, with one zombie of the group and about 2,300 processes in/proc, an ended group now takes about 3.5 s at a load of 90, against about 2.5 s on main./proc/selfof another pid gets no SIGSTOP.gcordestroybefore all 20 frozen checks.What changes for users
destroy --forceof a session whose host is gone records it asdestroyedalso when the read of its program group, or the first check of the group, fails twice, 100 ms apart, with EACCES, EPERM, EIO, EISDIR, ENOTDIR, ELOOP or ENAMETOOLONG. A second read that passes stops the group as usual. The session can be terminal, or running with a host that is proven dead. In that casedestroy --forcestops the host alone, waits until the host has ended, and recordsdestroyed. A process that the program left can outlive it. USAGE says so.HOST_TIMEOUT, to a group that reads alive, or to a host that does not end. A plaindestroystill fails on the read.host.jsonthat cannot be read counts as missing, so the group reads out of view:destroy --forcefails withHOST_TIMEOUT, and gc removes the session, as in another pid namespace. This was already so before this PR.destroy --forceonly when the read and first check thatdestroy --forceruns (forceSkipsGroup) fail too, so an EACCES from the socket unlink gets no hint.No JSON shape changes:
skippedSessions[].reasonis free text.What changes for maintainers
isUnreadableGroupError(programGroup.ts) accepts an errno inUNREADABLE_ERRNOS(EACCES, EPERM, EIO, EISDIR, ENOTDIR, ELOOP, ENAMETOOLONG): on a non-CliError, or as the cause of aSTORAGE_READ_ERRORofprogram.json. It accepts neitherHOST_TIMEOUT, an abort, an assertion, aSTORAGE_READ_ERRORwithout a cause, nor EMFILE, ENFILE, ENOMEM, EAGAIN or EINTR.forceSkipsGroup(programGroup.ts) runsreadProgramGroupand oneisProgramGroupAlive, and returns whether that throws an error thatisUnreadableGroupErroraccepts. gc uses it for the reconcile hint.destroySessionruns the group read of a terminal session, and both rounds of--force, through its localstopUnlessUnreadable. That function reads the group and judges it once inside atry, then stops it and waits after thetry. With--force, an unreadable read or first judgement (readAndJudge) runs again afterDESTROY_POLL_INTERVAL_MS(100 ms, abortable), and only a second unreadable failure makes it stop the host alone instead. An errno after that first judgement (the witness re-read or a scan in the wait) fails the destroy, so a group that read alive never gets recordeddestroyedwithout its SIGKILL. The model has no read that succeeds and then fails, because such a read only fails a destroy.recordDestroyedkeeps its invariant: the host is dead and the group is null.ForceSkipsUnreadableGroup, and the environment constantsCheckFails(every read ofprogram.jsonbydestroy,listandgcfails while the record exists) andRefused(their/procrefuses the program's autogroup).dblindmarks the destroy marker or the destroyed record that a blinddestroy --forcewrote. R11, R16 and R20 leave it out, and R20 also leaves outRefused.current-checkfailsandcurrent-refused(pass),witness-WitnessUncheckedForceDestroys-checkfailsandwitness-WitnessRefusedGcRemovesLeftover-refused(violated), andmutant-ForceSkipsUnreadableGroup-checkfails(pass: without the guard the witness is never reached). That makes 84 runs and thirty-seven guards.facts.mjsreads the new guard fromstopUnlessUnreadable, statement by statement, so a comment does not change a fact. It also reads the restructuredstopLeftoversThenRecordDestroyedand--forcesteps, and it hashesisUnreadableGroupError. The guard-value drift check from fix(gc): keep a session whose program group still runs, and model-check the lifecycle in CI #302 flagged this refactor at once:DestroyRecordsDestroyed,DestroyStopsLeftoversandUnsharedGroupReadsUnknownread FALSE before the facts matched the new code.Validation
chmod 000onprogram.json, skipped under root) fail on the base without the change, and they pass with it. The unit tests forisUnreadableGroupErrorand for gc's reason also fail without the change.ForceSkipsUnreadableGroupFALSE.--force, or a classifier that accepts any string code, also turnsDestroyRecordsDestroyedandDestroyStopsLeftoversFALSE.check.sh --update-coverageat 4e9707e: 83 of 84 runs as expected.current-checkfailsbroke R16: alistreconcile repaired the blind destroy's marker todestroyedbeforedblindwas set. 878e73b setsdblindat the marker write.current-checkfailspasses with 12,444,472 distinct states, its witness is reached, and its mutant passes with 6,452,252.current-refusedhas 29,409,236 distinct states.currentandcurrent-no-tokenstay at 29,309,460 and 29,186,546.facts.mjsat the head, with every fact value unchanged, and CI runs every run again.tsc --noEmit,oxlint,oxfmt --check, the unit tests ofprogramGroup,gcandlifecycle(261 tests), and the gc and unreadable-group integration tests (7 tests) pass after the merge of fix(gc): freeze a fork chain behind its zombies before gc reads it ended #326's head ace0873. After the follow-ups, the non-reaping,programGroupandgcsuites pass (176 tests).facts.mjschecksjudgeFrozenGroupstatement by statement and needs a positive integerFROZEN_JUDGEMENTS. Setting the limit to 0, or removing the SIGCONT, turnsGcFreezesUnseenGroupFALSE. A comment-only edit does not.lifecycle-modelshares took 31.4, 23.1, 20.8 and 11.4 min of TLC, and 22 + 21 + 21 + 20 = 84 runs gave the expected results.try, with the unit test "destroy --force fails, and records nothing, when a read fails after the group read owned" (fails on 286a388);UNREADABLE_ERRNOS, with tests that expect false for EMFILE, ENFILE, ENOMEM, EAGAIN, EINTR and a cause-lessSTORAGE_READ_ERROR; a unit test that reaches the/proc/selfguard insidejudgeFrozenGroup(fails when the guard is removed); USAGE limit 5, the host README and SKILL. The model andcheck.share unchanged, and all 39facts.mjsconstants equal the last full check.forceSkipsGroup, with the unit test "names destroy --force for a failed reconcile only when the read of the group fails too" (fails on cf79cee), and the host.json claim. A real CLI run withchmod 000 host.jsonon an exited session gavegc"removed" anddestroy --forceHOST_TIMEOUT. Only thegcSessionsanddefaultDependenciesfingerprints changed.tsc --noEmit,oxlint,oxfmt --check,check.sh --drift-only,test/unit(2,048 tests), and the integration filesgc,non-reaping-init,socket-directory(22 tests) and thelifecyclegc and unreadable tests pass. The wholelifecycleintegration file passed 74 of 75 at load 48: "destroy ends a fork chain that no scan of /proc shows" failed once and passed 4 of 4 alone. That test uses a plain destroy of a running session, which this PR does not change.check.shGUARDS and the model hold both sides (dblindnext to fix: on Windows, stop a late host after a failed create in more cases, and name the session in text mode #324'slostRepair,diedBeforeRepairandpolled). That makes 90 runs and thirty-nine guards. 77 of the 90 runs finished locally as expected before the host rebooted. CI on 53c0fe1 ran all 90 in 8 shares (13 to 44 min), and every run gave its expected result.currenthas 64,052,288 distinct states andcurrent-no-token63,127,914, as on main.test/unit(2,064 tests), tsc, lint, format andcheck.sh --drift-onlypass.FROZEN_JUDGEMENTSregression in bb4132c. Round 4 (c1ef1d5): security clean, two code findings, both fixed in 24e275f (a one-off EIO, and the wording of USAGE limit 5). Round 5 (53c0fe1): clean in both lanes.ForceSkipsUnreadableGroup,DestroyRecordsDestroyedandDestroyStopsLeftoversFALSE.test/unit(2,051 tests), the integration filesgc,non-reaping-init,socket-directory(22 tests), thelifecyclegc and unreadable tests (7), tsc, lint, format andcheck.sh --drift-onlypass. Only thedestroySessionandfacts.mjsfingerprints changed, and all 39 constants equal the last full check.judgeFrozenGroupreturnsnullafter both frozen judgements readgone. The new unit test "keeps the session when a live member escapes both frozen judgements of one pass" and the updated "reads a group that shows only zombies, frozen twice in each of ten judgements, ended" fail with the oldreturn false, andfacts.mjsturnsGcFreezesUnseenGroupFALSE for it.test/unit(2,049 tests), thenon-reaping-init,gcandlifecyclegc integration tests, tsc, lint, format andcheck.sh --drift-onlypass. Only thejudgeFrozenGroupandfacts.mjsfingerprints changed. Every guard constant still equals the last full check.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:xhigh