Skip to content

🤖 tests: cover #5465 background-process cases in the TLA+ model - #5575

Merged
ThomasK33 merged 3 commits into
mainfrom
formal/5465-bgproc-model-cases
Oct 3, 2026
Merged

ThomasK33 merged 3 commits into
mainfrom
formal/5465-bgproc-model-cases

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

This extends the background-process TLA+ model (formal/background-processes/) so that check.sh reports 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

Case Model change Config(s) and EXPECT Outcome
1. Refused or failed migration, join > 5 s BgCleanup: MRefused now unregisters the foreground entry and aborts; new MMigrateFail (an admitted migration whose record cannot be created takes the same path); new MJoin / MJoinTimeout (constant JoinTimeout) MC_cleanup_refused_join_timeout, and MC_cleanup_failed_join_timeout (constant MigrationFails forces the admitted-then-failed path): NoLiveAfterDelete MigrationOwned violated Defect. Repro added; fix tracked in #5522
2. Exec timeout after a migration BgCleanup: FTimeout (constant ExecTimeout) MC_cleanup_migration_timeout: all hold Benign (reason below)
3. Crash between spawn and writeMeta BgGateEvidence: spawn split into ADir → AChild → AMeta, plus ACrash (constant Crash), an exit marker, and the scan's meta-less rule MC_gate_crash_before_meta, MC_gate_crash_devcontainer: hold. Mutant MC_mut_gate_metaless_trusted: violated Safe on scanned roots (reason below). Liveness gap tracked in #5576

Case 1: defect (#5522)

When a migration is refused (the workspace is sealed) or fails, tools/bash.ts:

  • unregisters the foreground entry (~:1479);
  • aborts the command (~:1569);
  • joins its exit for at most 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:

  1. The removal seals the workspace.
  2. The migration is refused.
  3. MJoinTimeout fires while the command is still alive.
  4. MEnd runs, then cleanup drains, then RDelete deletes 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 close arrives 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, and migrateToBackground fails with ENOSPC). Each checks which path its tool error came from. Both use a LocalRuntime subclass 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) gets Expected: false, Received: true). The target maps to the model's NoLiveAfterDelete, 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_secs is 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.
  • The kill ends the same exec stream whose exitCode the migrated handle observes and writes as exit_code (backgroundProcessExecutor.ts ~:739-743). The exit is therefore recorded, not lost.

In the model, FTimeout is FExit restricted to the migrated phases, so the state count equals MC_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

spawnProcess creates the record directory with output.log and clears exit_code (backgroundProcessExecutor.ts :242-268) before it starts the child (:294). writeMeta comes later (backgroundProcessManager.ts ~:1867). After a crash in between:

  • A's turn lease goes stale.
  • recordRootHoldsOrphan treats a directory that has neither meta.json nor exit_code as a live orphan and fails closed (~:2877-2890). This is already tested in backgroundProcessManager.test.ts ("fails closed on unreadable records without an exit marker").
  • The wrapper's trap writes the marker when the process exits.

I re-checked this against current main (eea905d): backgroundProcessManager.ts, backgroundProcessExecutor.ts and 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_spawn and MC_gate_docker_spawn configs (#4889). The mutant that skips meta-less directories is caught, so the model can see this class of bug.

Model faithfulness for existing configs

  • Every existing config gets the new constants set to FALSE.
  • Every existing EXPECT verdict is unchanged. check.sh exits 0, and FORMAL_FAST=1 also passes.
  • In the gate model, the record evidence with a meta.json is ~marker /\ alive, which matches the old record /\ alive. A's lease still covers the spawn until AEnd, so the step split changes no verdict when Crash = FALSE.

Validation


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $32.91

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 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-10-03T20:11:34.534066Z 6aabf6b New commits
🔒 Security Review ✅ Completed 2026-10-03T20:10:45.515400Z 6aabf6b New commits
ℹ️ 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 chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread formal/background-processes/BgCleanup.tla
Comment thread formal/background-processes/BgCleanup.tla
Comment thread formal/background-processes/BgGateEvidence.tla

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread formal/background-processes/BgCleanup.tla
Comment thread src/node/services/backgroundProcessesFormalRepro.test.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

Readiness record for head 6aabf6b:


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $32.91

@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 12982d9 Oct 3, 2026
33 checks passed
@ThomasK33
ThomasK33 deleted the formal/5465-bgproc-model-cases branch October 3, 2026 20:31
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 4, 2026
…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 -->
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.

🤖 formal: background-process cases the TLA+ model does not cover

1 participant