Repository navigation
fix(control): drain admitted operations and result writes before exit - #1074
Conversation
`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>
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>
|
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. 2 — accepted tasks are drained now, and your probe is what made it undeniable: 3 — receipts are exempt outright. 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. 5 — the type claim was false and is corrected. 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 49 tests in |
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>
#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>
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 behindFileControlHistory's append tail. SoapplicationShutdown'scontrolstage 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:
recordTransportappends straight to the history outside any call.IPC handlers come off last.
operations.start/operations.finishtravel the private main port rather than IPC, so the main process records its own receipts throughout, and leavingcontrol:invokeregistered 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 fromactive. Theactivemap 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 > 0cannot 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.disposeControlHostis typed() => Promise<void>deliberately: a() => voidstill 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
FileControlHistoryagainst a real directory. Electron is stubbed only whereipcMainis 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 carryingnot_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 realdispose(): 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 queuedrecordTransportappend.settled()resolves immediately (the bug)inFlightcounted from theactivemapunknownoutcomenpx tsc -bclean. Widersrc/main/run: 1662 passed, with one failure —src/main/workflows/control.system.test.ts— that I verified fails identically on cleanorigin/main(stashed this branch's changes and re-ran). Not caused here.Why
Refsand notFixesThe issue's B02 acceptance is broader than this. What is not here:
applicationShutdown's existingjoin/onShutdownErrorpath rather than newly proven for the control stage specifically.🤖 Generated with Claude Code