Skip to content

fix(host): drain the dispatcher queue to empty when a step throws - #405

Merged
mtskf merged 2 commits into
mainfrom
fix/draining-dispatcher-throw-policy
Sep 9, 2026
Merged

fix(host): drain the dispatcher queue to empty when a step throws#405
mtskf merged 2 commits into
mainfrom
fix/draining-dispatcher-throw-policy

Conversation

@mtskf

@mtskf mtskf commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

createDrainingDispatcher had no failure policy for a throwing step: it reset draining in a finally but neither cleared nor re-drained queue, so an event enqueued re-entrantly before the throw sat there until the NEXT external dispatch — a stale replay, arbitrarily later, against a state it was never computed for. This decides the policy explicitly: continue-draining.

Changes

  • host-session-core.ts — the dispatcher catches per step, drains the queue to EMPTY, and only then lets the failure reach the caller: a lone error rethrown as-is (identity and triage payload intact, falsy values included), several as an AggregateError. draining is still released in a finally, before the rethrow, so a caller that dispatches from its own catch handler starts a fresh drain.
  • The JSDoc records the decision and both rejected alternatives: CLEARING the queue is unsafe by construction here — applyEditSettled is the write lock's only release site (edit-settled-barrier.ts), so dropping one strands the lock and the side channels deferred behind it; DOCUMENTING the "effects never dispatch synchronously" invariant leaves it unenforced by anything. It also states what continuing does NOT do (it is scheduling, not transactional recovery: the failed event's remaining effects stay abandoned, and a throwing applyEditSettled transition still strands the lock — a cost owned by HostSessionStepDeps.commitTransition), and that liveness is unchanged (no effect may re-dispatch unconditionally).
  • effect-executor.ts — the two comments asserting "createDrainingDispatcher has try/finally and NO catch" now say it rethrows once the drain is empty. Their substance is unchanged: the throw still escapes to the caller, so the unhandled-rejection reasoning holds; only its timing moves to the end of the drain.
  • Tests — four new createDrainingDispatcher traces (drain-to-empty + error identity + no residue on the next dispatch, AggregateError for multiple failures, a falsy throw not read as a clean drain, and no throw when every step succeeds). host-session-step.test.ts's residue pin, which explicitly asked to be updated consciously if the policy changed, now pins the new behaviour.

Related

  • TODO: createDrainingDispatcher has no failure policy for a throwing step (out-of-scope finding from the /review-plan rounds on the settle-effects-throw barrier fix).
  • Design reviewed by Codex, which reached the same verdict independently and contributed the liveness note and the "attempt, not success" framing of the contract.

Test Plan

  • pnpm compile — clean
  • pnpm test:unit — 5367 passed
  • Non-vacuity: the three new policy assertions are red on the pre-change dispatcher (seen stops at ["a"]; the multi-failure case throws the first Error instead of an AggregateError)
  • pnpm lint — clean for the touched files
  • pnpm build + pnpm package (vsix audit: 22 entries, no violations) + code --install-extension --force

`createDrainingDispatcher` reset `draining` in a `finally` but neither
cleared nor re-drained `queue`, so an event enqueued re-entrantly before a
`step` throw sat there until the NEXT external dispatch — a stale replay,
arbitrarily later, against a state it was never computed for.

Decide the policy explicitly: continue-draining. Each step is caught, the
drain runs to an empty queue, and only then does the failure reach the
caller — a lone error rethrown as-is (identity and triage payload intact),
several as an `AggregateError`. Clearing the queue was rejected because
`applyEditSettled` is the write lock's only release site, so dropping one
strands the lock and the side channels deferred behind it; documenting the
"effects never dispatch synchronously" invariant was rejected because
nothing mechanically enforces it.

The throw still escapes to the caller, so the unhandled-rejection reasoning
in `effect-executor.ts` is unchanged — only its timing moves to the end of
the drain; both comments asserting "try/finally and NO catch" are updated.
`host-session-step.ts`'s residue baseline test, which asked to be updated
consciously if the policy changed, now pins the new one.
…message

- host-session-step.test.ts: fix stale PR #404 reference to the actual
  PR (#405) that introduced the drain-residue fix
- host-session-core.ts: scope the createDrainingDispatcher JSDoc's
  'applyEditSettled is the write lock's ONLY release site' claim to a
  still-alive panel, and note the disposed arm's teardown-only release;
  correct the citation from edit-settled-barrier.ts (which only
  consumes isWriteLockHeld) to isWriteLockHeld itself and
  effect-executor.ts's header
- host-session-core.test.ts: assert the AggregateError message string
  in the multi-throw drain test, which no existing assertion pinned
@mtskf
mtskf merged commit a2320a9 into main Sep 9, 2026
2 checks passed
@mtskf
mtskf deleted the fix/draining-dispatcher-throw-policy branch September 9, 2026 06:11
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