fix(terminal): don't let a stale upstream handler evict a live resubscription#93
Conversation
…cription The upstream close/error handlers (and the view-revocation path) deleted the subscription by session id alone, so an unsubscribe -> immediate resubscribe of the same id let a stale event from the OLD subscription evict the new, live one — releasing its input state and telling the client it closed / its share was revoked. In particular a canView() from a prior interval tick can resolve after the swap; clearInterval does not cancel it. Re-check subscription identity before acting in all three paths, matching the input loop's subscriptions.get(id) !== subscription guard. Regression tested for the close and error handlers (stale + normal paths).
|
Codex review: needs maintainer review before merge. Reviewed July 17, 2026, 7:16 PM ET / 23:16 UTC. Summary Reproducibility: no. high-confidence current-main reproduction was established because the local read-only inspection sandbox failed before current source could be exercised. The supplied branch includes a focused source-level reproduction and linked real-worker before/after transcript for the reported interleaving. Review metrics: 1 noteworthy metric.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge
Security Review detailsBest possible solution: Retain the identity-based teardown guard if it cleanly rebases onto current Do we have a high-confidence way to reproduce the issue? No high-confidence current-main reproduction was established because the local read-only inspection sandbox failed before current source could be exercised. The supplied branch includes a focused source-level reproduction and linked real-worker before/after transcript for the reported interleaving. Is this the best way to solve the issue? Yes, conditionally: comparing the stored subscription object before destructive teardown is the narrowest way to preserve a newer same-id subscription. It should still be validated against the current-main three-way merge because the branch base is behind. AGENTS.md: unclear because the file could not be read completely. Codex review notes: model internal, reasoning high; reviewed against 2cb0c9ac0892. Label changesLabel justifications:
Evidence reviewedWhat I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
@clawsweeper re-review Added real Worker WebSocket proof: driven end-to-end against the real worker under |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
What Problem This Solves
The terminal hub keys its live subscriptions by session id. When an upstream connection closes or
errors, the
close/errorhandlers removed the subscription withsubscriptions.delete(id)— byid alone. So an unsubscribe → immediate resubscribe of the same session id could let the old
upstream's deferred
close(orerror) event fire after the new subscription was already stored,and evict it: releasing the live subscription's input state and telling the client its session had
closed, while the connection stayed up.
The hub's own input loop already guards against exactly this with an identity re-check
(
subscriptions.get(id) !== subscription); the two upstream lifecycle handlers did not.The change
Re-check subscription identity before acting in the three paths that tear a subscription down — the
closehandler, theerrorhandler, and the view-revocation path (revokeView) — matching theinput loop:
If a newer subscription has taken over the id, the stale handler stops: it no longer evicts the live
subscription, releases its input state, marks the session detached, or sends the client a
closed/error /
share revokedevent. Each handler still cleans up its own subscription (pending inputacknowledgements, its view-check interval, its own upstream).
revokeViewis included because acanView()from a prior interval tick can still be in flight and resolve after the id wasre-subscribed —
clearIntervaldoes not cancel that pending promise.Real Worker WebSocket proof (before → after)
Driven end-to-end against the real worker under
wrangler dev(workerd): a real WebSocket clientconnects to
/api/terminal/ws(realcrabbox_sessioncookie auth), and the subscription's upstreamis a real
ws://server the harness controls. The production sequence — subscribe → unsubscribe →immediate same-id resubscribe — opens two real upstream connections; the old upstream's close then
fires deferred (a real WS close handshake) after the resubscription:
Reproducible transcript + setup: https://gist.github.com/anagnorisis2peripeteia/a401c163be272045571337a9419c37a8
Deterministic reproduction (unit)
The same interleaving through the real
TerminalHub, which additionally makes the double-releaseobservable:
(The extra
releaseInputStateon base is the second, wrong release — of the live subscription B'sinput state.)
Testing
pnpm check(tsc + oxlint + oxfmt),pnpm build,pnpm exec wrangler deploy --dry-run,pnpm testall green.
tests/terminal-hub.test.tsadds four cases covering the close and error handlers on bothpaths: a stale close and a stale error must NOT evict a live resubscription; a normal close and a
normal error still tear down the current subscription and notify the client. Mutation testing of the
close/error guard conditions scores 100%.
revokeViewgets the identical one-line guard; its staletrigger (an in-flight
canView()resolving after the swap) isn't deterministically unit-testableagainst the 5s view-check interval, so it relies on that same proven pattern rather than a flaky
fake-timer test. A concurrency lint of the diff is clean.
Related: FINDING 3 of #88.