Skip to content

fix(control): drain admitted operations and result writes before exit - #1074

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/control-drain-before-exit
Sep 20, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/control-drain-before-exit

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Refs #943.

The gap

createControlHost.dispose() retired waits, window registrations and IPC handlers synchronously and returned. None of that is evidence that anything stopped: an admitted operation was still running, and its durable result was still queued behind FileControlHistory's append tail. So applicationShutdown's control stage completed instantly and the exit — and the state-process lock — were released while the effect was in flight.

That is the one state a restart cannot answer for: the mutation may or may not have happened, and the journal that exists to say which is missing the record.

What dispose does now

The order is a contract:

  1. Close admission. No new effect is admitted. Reads still answer — they change nothing, and refusing them would blind the tooling used to diagnose a stuck quit. An unknown capability id counts as effectful, so a call nothing knows anything about is refused rather than waved through.
  2. Retire the renderer-facing surface.
  3. Await every admitted operation, then the history tail. That order is real: an operation finishing appends its own result, so draining the file first would leave the very last one behind — and the tail covers what the executor cannot, since recordTransport appends straight to the history outside any call.

IPC handlers come off last. operations.start / operations.finish travel the private main port rather than IPC, so the main process records its own receipts throughout, and leaving control:invoke registered until the end means a renderer gets a structured refusal instead of a dead channel.

Two details the issue called out

In-flight is counted from the top of invoke, not from active. The active map is populated only after the serialized received-intent append succeeds, so draining it alone would miss every operation still inside admission — which is where the serialized, slow part is.

Nested calls are never refused, and say so explicitly. Batch members, a wait's inner read and the main port's own operation receipts all reach the executor after their parent was admitted; refusing them half-finishes work already underway. inFlight > 0 cannot tell a batch member from a fresh external request arriving at the same moment, so the three internal call sites pass { nested: true } and every external entry point (control:invoke, forCaller(...).invoke) does not.

disposeControlHost is typed () => Promise<void> deliberately: a () => void still compiles when assigned an async function, and the shutdown stage would then resolve immediately and release the lock mid-drain.

Tests

Two composed regressions, driving the real executor, the real registry and the real FileControlHistory against a real directory. Electron is stubbed only where ipcMain is touched; the dispatch target is a stand-in because a controllable effect is the point.

  • controlDrain.system.test.ts — an intent still inside admission at commit, a dispatched effect awaiting completion, a refused mutation carrying not_started, reads still answering, nested calls admitted, and a timed-out transport retry joining the in-flight call instead of dispatching twice.
  • controlHostDispose.system.test.ts — the real dispose(): it does not resolve while the effect is dispatched, it refuses a new mutation the moment shutdown begins, it retires IPC only after the drain, and it flushes a queued recordTransport append.
mutation result
settled() resolves immediately (the bug) 2 failed
inFlight counted from the active map 3 failed
admission never closes 1 failed
mutations admitted after close 1 failed
reads refused too 1 failed
unknown capability treated as a read 1 failed
nested calls refused 1 failed
refusal reports an unknown outcome 1 failed
history drain is a no-op 1 failed
dispose skips the executor drain 2 failed
dispose skips the history drain 1 failed
IPC retired before the drain 1 failed
restored 40 passed

npx tsc -b clean. Wider src/main/ run: 1662 passed, with one failure — src/main/workflows/control.system.test.ts — that I verified fails identically on clean origin/main (stashed this branch's changes and re-ran). Not caused here.

Why Refs and not Fixes

The issue's B02 acceptance is broader than this. What is not here:

  • The "required failures hold the exit/lock boundary" assertion is inherited from applicationShutdown's existing join/onShutdownError path rather than newly proven for the control stage specifically.
  • No application crash or OS-exit reproduction has been run, which bug(control): drain admitted operations and result receipts before exit #943 already notes as outstanding.
  • The revision-bound editor preparation and B05 mutation-uncertainty coordination the issue mentions are untouched.

🤖 Generated with Claude Code

`createControlHost.dispose()` retired waits, window registrations and IPC
handlers synchronously and returned. None of that is evidence that anything
STOPPED. An admitted operation was still running, and its durable result was
still queued behind `FileControlHistory`'s append tail — so
`applicationShutdown`'s `control` stage completed instantly and the exit and
the state-process lock were released while the effect was in flight.

That is the one state a restart cannot answer for: the mutation may or may not
have happened, and the journal that exists to say which is missing the record.

WHAT DISPOSE DOES NOW, in an order that is a contract:

 1. Close admission. No new EFFECT is admitted; reads still answer, because
    they change nothing and refusing them would blind the tooling used to
    diagnose a stuck quit. An unknown capability id counts as effectful, so a
    call nothing knows anything about is refused rather than waved through.
 2. Retire the renderer-facing surface.
 3. Await every admitted operation, THEN the history tail. That order is real:
    an operation finishing appends its own result, so draining the file first
    would leave the very last one behind — and the tail covers what the
    executor cannot, since `recordTransport` appends straight to the history
    outside any call.

IPC handlers come off last. `operations.start`/`operations.finish` travel the
private main port rather than IPC, so the main process records its own receipts
throughout, and leaving `control:invoke` registered until the end means a
renderer gets a structured refusal instead of a dead channel.

IN-FLIGHT IS COUNTED FROM THE TOP OF `invoke`, NOT FROM `active`. The `active`
map is populated only AFTER the serialized received-intent append succeeds, so
draining it alone would miss every operation still inside admission — which is
where the serialized, slow part is. #943 calls this out and it is the whole
reason a counter exists instead of reusing the map.

NESTED CALLS ARE NEVER REFUSED, and say so explicitly rather than being
inferred. Batch members, a wait's inner read and the main port's own operation
receipts all reach the executor after their parent was admitted; refusing them
half-finishes work already underway. `inFlight > 0` cannot tell a batch member
from a fresh external request arriving at the same moment, so the three
internal call sites pass `{ nested: true }` and every external entry point
(`control:invoke`, `forCaller(...).invoke`) does not.

`disposeControlHost` is typed `() => Promise<void>` deliberately. A `() => void`
still COMPILES when assigned an async function, and the shutdown stage would
then resolve immediately and release the lock mid-drain — the type is the only
thing standing between that and a silent regression.

Mutation-checked, 40 tests:

  settled() resolves immediately (the bug)   2 failed
  inFlight counted from the active map       3 failed
  admission never closes                     1 failed
  mutations admitted after close             1 failed
  reads refused too                          1 failed
  unknown capability treated as a read       1 failed
  nested calls refused                       1 failed
  refusal reports an unknown outcome         1 failed
  history drain is a no-op                   1 failed
  dispose skips the executor drain           2 failed
  dispose skips the history drain            1 failed
  IPC retired before the drain               1 failed
  restored                                  40 passed

Refs #943

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
Four PRs merged (#1068, #1069, #1071, #1072), closing #1025, #952 and #930.
#1073 and #1074 are open with their review findings fixed. #993 stays open
deliberately: the mechanism does not reproduce the reported snapshot.

Records the two reviewer instructions that produced every blocking finding —
mutate the implementation and report the survivors, and count the real corpus
rather than reasoning about it — because three of the four blockers were
regressions this loop had just introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on #1074. The mechanism was right; the composition was not.

FINDING 1 (high): `dispose()` DESTROYED THE REGISTRY BEFORE IT DRAINED.
`unregisterMain()` and the window retirements empty the catalog, and the
admission gate resolves a capability's declared effect against that catalog,
treating an unknown id as effectful. So during the drain every id was unknown
and everything was refused — including every declared read, and including
`operations.start`/`operations.finish`, whose own owner had just been
unregistered ("No owner for operations.start").

Three claims in that comment were false as written, and review proved each
with a probe. Retirement now happens AFTER the drain, which is the only
ordering under which the comment is true. Waits are still cancelled first:
that is not a registry change, and a wait would otherwise sit in the drain for
its full deadline waiting for something being torn down.

FINDING 2 (high): ACCEPTED OPERATIONS WERE NOT DRAINED AT ALL.
`startControlTask` journals `operations.start`, returns `{accepted:true}`
immediately and runs the real work in a floating async body, so `inFlight`
dropped to zero and the drain reported settled with the mutation still
running — every `agents.prompt`, `agents.close`, `agents.rewind`,
`commands.run`, `workflows.start` and the rest of that class. Review drove it:
the journal ended with `task.started` and no `task.finished`, which is
verbatim the state this PR claims to prevent.

The executor already computes `status: 'pending'` for exactly these, and the
task's completion travels back through it as `operations.finish` carrying the
same callId, so it can account for them without knowing what a task DOES.

FINDING 3 (high): THE NESTED INVENTORY WAS INCOMPLETE. The renderer reports
its own lifecycle through `control:invoke`, which carries no flag, so closing
admission refused the receipt for an effect that had ALREADY happened — with
`outcome: 'not_started'`, the opposite of the truth. `operations.start` and
`operations.finish` do not cause effects, they record them, so they are now
exempt outright. That also matters for finding 2: refusing them would make the
drain wait for a completion that can never arrive.

FINDING 4 (medium): THE DRAIN WAS UNBOUNDED while holding the exit and the
state-process lock. One never-resolving operation — a stalled `fsync` in the
history append is the realistic one, and it blocks admission too — made the
application impossible to quit, and the user's answer to that is a force quit,
which loses the record this protects AND strands the lock. The codebase
already settled the same trade-off three stages later, where `stopPerformance`
races a 2 s timer so "diagnostics must never make the application impossible
to quit". There is a 10 s deadline now, and what was still outstanding is
journalled as `control.drain_incomplete` — giving up loudly beats hanging
silently, and a restart that finds an operation with no result needs to know.

FINDING 5 (medium): the type-safety claim in index.ts was FALSE. Types are
erased, and `applicationShutdown.run` does `Promise.resolve(action()).then(…)`,
which adopts the returned promise whatever its declared type. The annotation
is documentation, and catches a future call site that drops the promise — it
is not what stands between the app and the regression. The comment says that
now.

FINDING 6 (medium): the composition layer was unpinned — eleven mutations
survived. Ten are caught now, including retiring the registry before the
drain, skipping `closeAdmission`, admitting `ui` after close, not tracking
accepted tasks, refusing receipts, dropping the default deadline, and exiting
silently on an incomplete drain.

Three are EQUIVALENT MUTANTS today and are documented as such rather than
covered with a test that would prove nothing: removing `{nested:true}` from
any of the three internal call sites is unobservable, because the main task
port carries only receipt capabilities, `agents.batchRead` and its members are
reads, `agents.batchPrompt` is refused at the gate before any member runs, and
a wait's inner request is hardcoded to reads. The flags are the standing
answer for the first non-read batch member or non-receipt main-port
capability.

Mutation-checked, 49 tests in `src/main/control/` + `src/control-sdk/`; wider
`src/main/` run 1682 passed with the one pre-existing
`workflows/control.system.test.ts` failure that reproduces on clean main.

Refs #943

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round addressed. Finding 1 is the one that stings: the composition falsified three claims in my own comment, and the probe output is unarguable.

1 — retirement now happens AFTER the drain. unregisterMain() and the window retirements empty the catalog, the gate resolves declared effects against it, and an unknown id is treated as effectful — so during the drain everything was refused, reads included, and operations.* failed with "No owner" because their owner had just been unregistered. Waits are still cancelled first: that is not a registry change, and a wait would otherwise sit in the drain for its full deadline waiting for something being torn down.

2 — accepted tasks are drained now, and your probe is what made it undeniable: task.started with no task.finished is verbatim the state the commit message claims to prevent. The executor already computes status: 'pending' for exactly these and sees the matching operations.finish carrying the same callId, so it accounts for them without knowing what a task does.

3 — receipts are exempt outright. operations.start/operations.finish do not cause effects, they record them, and telling a caller not_started about an effect that already happened is worse than the dead channel it replaced. This also turned out to be load-bearing for 2: refusing them would make the drain wait for a completion that can never arrive.

4 — 10 s deadline, and an incident. You are right that hanging buys nothing: the user force-quits, which loses the record and strands the lock. stopPerformance's 2 s race three stages later is the precedent, and I have cited it. An incomplete drain now journals control.drain_incomplete with the outstanding counts, so a restart that finds an operation with no result has somewhere to look.

5 — the type claim was false and is corrected. Promise.resolve(action()) adopts the promise regardless of the declared type, and types are erased. The annotation is documentation that would catch a call site dropping the promise; it is not a safety mechanism, and the comment says so.

6 — ten of your eleven caught. The remaining three are equivalent mutants today, and I have written that into the code rather than inventing coverage: removing {nested:true} from any internal call site is unobservable, because the main task port carries only receipt capabilities, agents.batchRead and its members are reads, agents.batchPrompt is refused at the gate before any member runs, and a wait's inner request is hardcoded to reads. Your own smuggling analysis is what establishes that, and it is quoted in the comment. The flags stay as the standing answer for the first non-read batch member.

49 tests in src/main/control/ + src/control-sdk/; npx tsc -b clean; wider src/main/ 1682 passed with the one pre-existing workflows/control.system.test.ts failure you independently confirmed.

CI's quality-gate type-checks `src/control-sdk` on its own, and that package
is platform-neutral: it has no timer in its lib, so `setTimeout` inside
`executor.settled()` failed there while `npx tsc -b` passed locally. `task.ts`
already says the neutral SDK cannot assume a browser or Node; the timeout
race did exactly that.

The executor now exposes `outstanding()` — a synchronous fact — and an
unbounded `settled()`. `createControlHost.dispose()` owns the race, which is
also the right split: how long to wait is a host policy, not a property of
the executor.

Refs #943

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
#1073 and #1076 merged; #928 deliberately reopened because only its
atomic/no-clobber half shipped. #1074, #1077 and #1078 are in review.

Records the two reviews that caught near-catastrophes — #1077's retry guard
keyed on a shape with no prompt field, which would have blocked fan-out
forever with no way to clear it, and #1074's dispose destroying the registry
before draining — and the two build traps (lockfile resync after a
source-only submodule bump, and the neutral control-sdk type-check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 8e4b9a0 into main Sep 20, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/control-drain-before-exit branch September 20, 2026 13:14
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
#1074 and #1077 merged. #926 and #928 reopened, with the mechanism recorded:
a Fixes trailer in any commit closes the issue even when the PR was narrowed
by review and says Refs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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