Repository navigation
🤖 tests: cover #5465 background-process cases in the TLA+ model - #5575
Conversation
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f475f5e634
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ca6c233b29
ℹ️ 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".
…osed cleanup is safe
|
Readiness record for head 6aabf6b:
Generated with |
…xits (coder#5579) ## Summary When a foreground command's move to the background is refused (a removal or archive has sealed the workspace) or fails (the record cannot be created), the bash tool aborts the command and waits at most 5 s for it to exit. Before this PR, the migration's pending entry ended with that wait, so a command whose kill took effect late ran on untracked, and the removal went on to delete its checkout. Now the pending entry stays until the command's exit actually settles. A removal or archive cleanup keeps waiting for it, and fails closed at its existing 60 s drain deadline instead of deleting the checkout. Fixes coder#5522 Builds on coder#5575 (merged: the coder#5465 model and repros). This PR turns its two case-1 repros into plain tests. ## Implementation - `tools/bash.ts`: the migration handle was a `using` declaration, so it ended with the block. It is now a plain handle, ended by a small `using` guard unless the failed-migration path hands it to `execStream.exitCode`. It ends only when that promise resolves, which confirms the exit. A rejection (for example a remote transport error) does not confirm the stop, so the migration then stays pending for the session, and removal and archive of that workspace keep failing closed. The 5 s join and the tool's reply are unchanged. - No new persisted state. The pending entry is the existing in-memory `pendingAdmissions` entry that `cleanup()` already drains (`backgroundProcessManager.ts`). - Removal and archive call `cleanup(ws, { failClosedAfterDrainTimeout: true })`, which throws after 60 s (coder#5477). An unconfirmed stop therefore blocks the destructive step, as coder#5521 requires. Session disposal (no deadline) waits until the command's exit is confirmed. This only delays the disposal's tail steps, as an admitted spawn stuck in a runtime call already does. ## Model `formal/background-processes/BgCleanup.tla`: `MJoinTimeout` now moves the migration to `"stopping"`, which `MStopped` ends only after the command exits. `MigrationOwned` counts `"stopping"` as owned. `MC_cleanup_refused_join_timeout` and `MC_cleanup_failed_join_timeout` (the forced admitted-then-failed path) now hold (EXPECT `""`). A new mutant constant `JoinUntracked` (the code before this PR) is `FALSE` in every other config. Its config `MC_mut_cleanup_join_untracked` still violates `NoLiveAfterDelete` and `MigrationOwned`, so the model still sees the defect class. ## Validation - Test-first. On main before this PR (coder#5575), both repros ("a refused migration ..." and "a failed migration ...") fail at their target assertion, `expect(early === "finished").toBe(false)`, with `Expected: false, Received: true`: the removal's cleanup has already finished while the command is alive. The first version of the plain test, run without the fix, also failed: ```text Expected: "waiting" Received: "finished" (fail) coder#5465 case 1: ... > a refused migration keeps its command tracked until it exits ``` With the fix, both pass as plain tests. A third test, "a migration whose exit observation fails stays pending", uses a runtime whose `exitCode` rejects after the kill. Without the rejection handling it fails (`Expected: "waiting", Received: "finished"`). Each also releases the held kill and checks that the cleanup then finishes and the PID is gone. - `formal/background-processes/check.sh` (full) and `FORMAL_FAST=1` exit 0. - Bun 1.3.12: `backgroundProcessesFormalRepro`, `bash*`, `backgroundProcessManager`, `workspaceService.archive` and `workspaceService.remove` tests pass (403 tests). `make static-check` passes. ## Risks Low. The change only affects the path where a migration was refused or failed, and only lengthens how long cleanup waits for that command. A command that never exits now makes removal or archive fail after 60 s instead of deleting the checkout under it. That is the intended fail-closed behavior. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$32.91`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=32.91 -->
Summary
This extends the background-process TLA+ model (
formal/background-processes/) so thatcheck.shreports the three cases from #5465. It also adds a deterministic repro for the one that is a real defect. No product code changes.Fixes #5465
BgCleanup:MRefusednow unregisters the foreground entry and aborts; newMMigrateFail(an admitted migration whose record cannot be created takes the same path); newMJoin/MJoinTimeout(constantJoinTimeout)MC_cleanup_refused_join_timeout, andMC_cleanup_failed_join_timeout(constantMigrationFailsforces the admitted-then-failed path):NoLiveAfterDelete MigrationOwnedviolatedBgCleanup:FTimeout(constantExecTimeout)MC_cleanup_migration_timeout: all holdwriteMetaBgGateEvidence: spawn split intoADir→AChild→AMeta, plusACrash(constantCrash), an exit marker, and the scan's meta-less ruleMC_gate_crash_before_meta,MC_gate_crash_devcontainer: hold. MutantMC_mut_gate_metaless_trusted: violatedCase 1: defect (#5522)
When a migration is refused (the workspace is sealed) or fails,
tools/bash.ts:FAILED_MIGRATION_EXIT_JOIN_MS= 5 s (~:1578).If the join does not see the exit, nothing tracks the command any more: there is no manager entry and no record. The removal that waited on the migration then deletes its checkout. TLC's shortest trace for
NoLiveAfterDelete:MJoinTimeoutfires while the command is still alive.MEndruns, then cleanup drains, thenRDeletedeletes the checkout.One backend is enough to reach this. It needs a kill that takes effect late: a command stuck in uninterruptible I/O, or a remote exec whose
closearrives late.The repros are in
backgroundProcessesFormalRepro.test.ts: "a refused migration does not leave its command running untracked" (the seal refuses the migration) and "a failed migration does not leave its command running untracked" (the migration is admitted, andmigrateToBackgroundfails with ENOSPC). Each checks which path its tool error came from. Both use aLocalRuntimesubclass that delivers the abort only after the test releases it. After the tool returns "terminated because it could not be tracked", the command's PID is still alive. Each repro then starts the cleanup a removal runs (cleanup(ws, { failClosedAfterDrainTimeout: true })). The target assertion is that this cleanup has not finished 500 ms later (still waiting, or failed closed), because a removal deletes the checkout once it returns. Today it fails: the cleanup has already finished (expect(early === "finished").toBe(false)getsExpected: false, Received: true). The target maps to the model'sNoLiveAfterDelete, so the #5522 fix turns these repros into plain tests without changing what it checks.The control test, with an effective kill, passes. Both repros fail at the same target before the fix. The fix belongs in a separate PR (#5522), as the brief requires.
Case 2: benign
The foreground exec's timer (
LocalBaseRuntime~:369-377,RemoteRuntime~:266) keeps running after a migration and can kill the command. This is safe:timeout_secsis the documented maximum lifetime of a background command ("For background: max lifetime before auto-termination",toolDefinitions.ts). The migrated command keeps its original call's bound.exitCodethe migrated handle observes and writes asexit_code(backgroundProcessExecutor.ts~:739-743). The exit is therefore recorded, not lost.In the model,
FTimeoutisFExitrestricted to the migrated phases, so the state count equalsMC_cleanup_migration_remove's (106). The config pins the case. Whether a migration should lift the foreground timeout is a UX question, not a safety one.Case 3: safe on scanned roots, with a liveness gap
spawnProcesscreates the record directory withoutput.logand clearsexit_code(backgroundProcessExecutor.ts:242-268) before it starts the child (:294).writeMetacomes later (backgroundProcessManager.ts~:1867). After a crash in between:recordRootHoldsOrphantreats a directory that has neither meta.json nor exit_code as a live orphan and fails closed (~:2877-2890). This is already tested inbackgroundProcessManager.test.ts("fails closed on unreadable records without an exit marker").I re-checked this against current main (eea905d):
backgroundProcessManager.ts,backgroundProcessExecutor.tsand their tests are unchanged since 486f156, so the cited lines are what ships today. (Open PR #5521 would make the process group decide instead of the exit marker. It is paused, and it can rebase onto this model.)A crash before the child starts leaves a markerless directory that no process will ever settle. The structural mutation gate runs the orphan scan for user removal too, so that workspace's removal is refused until someone deletes the directory. That is safe but a liveness gap, which these safety-only configs do not check. It existed before this PR and is tracked in #5576. Remote roots are still unscanned, as in the existing
MC_gate_ssh_spawnandMC_gate_docker_spawnconfigs (#4889). The mutant that skips meta-less directories is caught, so the model can see this class of bug.Model faithfulness for existing configs
FALSE.check.shexits 0, andFORMAL_FAST=1also passes.~marker /\ alive, which matches the oldrecord /\ alive. A's lease still covers the spawn untilAEnd, so the step split changes no verdict whenCrash = FALSE.Validation
formal/background-processes/check.sh(full run) exits 0, and every new config matches its EXPECT entry.src/node/services/backgroundProcessesFormalRepro.test.tspassed 3 times in a row (14 tests each) on Bun 1.3.12.make static-checkpasses.formal/background-processes/and this repro file. It is paused, so this PR goes first and 🤖 fix: only a supervisor inside the group signals a background process group #5521 rebases onto it.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high• Cost:$32.91