fix: isolate L3 dispatch per composed server (main CI failure root cause) - #18
Merged
Merged
Conversation
…use)
The 2026-08-13 main CI failure ('a valid fromClient publish re-enters
onInbound and fires the reactive L2 scenario') was not a timing flake:
dispatch.test.ts's loadHandlers fixtures registered hook-less handlers
on command/{deviceId}/set into defaultDispatch — the process-wide
singleton every composed engine shares — and nothing ever unregisters.
With bun's readdir-dependent test-file order (fresh checkout = fresh
order), the leaked handler could run ahead of the control-plane file
and silently L3-shadow its channel: inbound swallowed, no scenario, no
violation, empty state. Deterministic per checkout, coin-flip per CI
run.
Fix, inside the G11 contract: dispatch.ts routes handler-file
registrations to the registry whose loadHandlers is importing them (an
active-loader slot behind the free register()), and compose gives each
server its own registry — which also closes the latent product bug of
two in-process servers sharing L3 handlers. Direct register() with no
load in flight still targets the singleton (pinned).
Tests (red first): loadHandlers isolation unit test; an incident-shaped
integration pin (server A's handlersDir must not shadow server B's
scenarios); the singleton-contract pin doubling as mutant-kill for the
new lines. The culprit fixture test migrates to an isolated registry.
Verified: full gates green (573/0), and the hostile-readdir-order
worktree that reproduced the CI failure 12/12 runs green with the fix
under the same order.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
This PR repairs the cause of the main-branch CI failure of 2026-08-13 (run 31668911331). It changes two source files and two test files. Each composed server now has its own L3 dispatch registry.
The failure
The test
POST /v1/publish: a valid fromClient publish re-enters onInbound and fires the reactive L2 scenariofailed on main. A re-run passed. The test was not the problem.The cause
The investigation found this chain:
dispatch.test.tsloads two handler files. The handlers have no hooks. Their pattern iscommand/{deviceId}/set. The registrations go intodefaultDispatch.defaultDispatchis one object for the full process. Each engine uses it. No function removes a registration.dispatch.test.tsruns beforecontrol-plane/index.test.ts, the leaked handler eats the control-plane test's message, and the test fails.The failure is constant for one checkout. It looks random across CI runs because each CI run makes a fresh checkout. A scratch checkout with the bad order failed 12 of 12 runs. The main local checkout has the good order and always passed.
The fix
Two parts. The G11 contract does not change:
dispatch.ts— WhileloadHandlersimports a handler file, the freeregister()sends the registration to the registry that does the import, not to the singleton. A directregister()call, with no import in flight, goes to the singleton as before.compose/index.ts— Each composed server gets its own registry. Two servers in one process can no longer share handlers. This closes a latent product defect, not only the test defect.The polluting test now uses an isolated registry.
The tests
All new tests were red (or order-dependent) before the fix:
loadHandlersputs handler-file registrations into the loading registry, and the singleton stays clean.register()with no load in flight goes to the singleton, also after a load completed. This pin also kills the mutants of the new lines for the mutation gate.Verification
bun scripts/check-docs.ts: exit 0.bun run lint: exit 0.bun run typecheck: exit 0.bun test: 573 pass, 0 fail, exit 0.Note for review
The mutation gate will run on
dispatch.ts. The new tests target the new lines. If a mutant survives, it gets handled in this PR.