5.9 KiB
Web M5 fix: simultaneous-tab double-freeze
Plan: plans/260831-2314-review-followups/plan.md Phase 1.
Source: plans/reports/code-reviewer-260831-2213-web-review.md M5.
Change
web/src/lib/active-tab.svelte.js:
- Added module-scoped
myClaimTs(lastDate.now()this tab claimed) and apostClaim()helper that sets it and broadcasts{type:"claim", id, ts}. Called from bothwatchActiveTab()(mount) andclaimActiveTab()(reclaim) — same call sites as the old unconditionalpostMessage. onmessagenow applies newest-claim-wins: ignorepeerTs < myClaimTs; freeze ifpeerTs > myClaimTs; on exact tie, freeze only if peeridis lexicographically greater thanTAB_ID(deterministic, exactly one tab wins). Reclaim's freshmyClaimTsmeans a stale/late peer claim can no longer re-freeze the reclaiming tab.+layout.svelteuntouched —watchActiveTab()/claimActiveTab()keep their signatures, reclaim re-hydration flow unchanged.
web/src/lib/active-tab.test.js:
FakeBC.postMessagenow defers delivery viaqueueMicrotaskand re-reads the peer list at delivery time instead of snapshotting it at call time. This matches realBroadcastChannel's queued-task delivery and — critically — is what makes a true "both tabs mount in the same tick" test possible: with the old synchronous/snapshot delivery, a tab created after another tab'spostMessagecall could never receive that earlier message, making one side of any tie-break untestable (and, per spec, unfaithful to the real API). AddeddeliverBcMessages()helper (await Promise.resolve()) to flush it in tests.beforeEachnow stubsDate.nowwith a strictly-increasing counter by default (removes flakiness/ambiguity in the pre-existing tests, which don't care about specific ts values) and restores mocks inafterEach.- Added three tests: unequal-ts simultaneous mount (older freezes, newer stays active), equal-ts tie-break (both tabs receive each other's claim via the same-tick delivery fix above; exactly one — the lexicographically greater id — stays active), and reclaim-updates-myClaimTs (a stale, older-ts claim from a peer after reclaim doesn't re-freeze).
- Existing 4 tests updated only to
await deliverBcMessages()after actions that need cross-tab delivery; assertions/intent unchanged.
Verification (from web/)
npx vitest run→ 141/141 passed (10 files), incl. 7/7 inactive-tab.test.js.npx eslint .→ 0 problems.npx svelte-check --tsconfig ./jsconfig.json→ 0 errors, 0 warnings (429 files,checkJs: true).
Builds skipped per instruction (unaffected — no import/signature changes).
Unresolved questions
None — decision and algorithm were fully specified; no ambiguity hit during implementation.
Status: DONE
Summary: Implemented newest-claim-wins with id tie-break in active-tab.svelte.js; extended the test file (and made its BroadcastChannel fake deliver messages the way the real API does) to cover unequal-ts, equal-ts tie-break, and reclaim-clears-staleness; all gates green.
Concerns/Blockers: none
M-1 follow-up — losing claimer never told it lost (2026-08-31)
Source: plans/reports/code-reviewer-260831-2314-precommit-review.md M-1.
Change
web/src/lib/active-tab.svelte.js onmessage handler:
peerTsnow falls back toInfinitywhene.data.tsis not a number (legacy pre-fix peer broadcasting{type, id}only) — a no-ts claim always wins, matching the old always-freeze behavior instead of being silently ignored (peerTs > myClaimTs/===were bothfalseagainstundefined).- Added an
elsebranch: when this tab does not lose (!peerWins) and is currently active, it echoes its own unchanged{type:"claim", id: TAB_ID, ts: myClaimTs}back on the channel. The peer that sent the losing claim receives this echo, re-evaluates, strictly loses (samemyClaimTsit already lost against, or a higherts/idunder theInfinitylegacy path), and freezes. A frozen tab only ever hits thepeerWinsbranch, so it never echoes — the exchange terminates after one echo round by construction, no counter/dedup needed.
No public API change — watchActiveTab()/claimActiveTab() signatures
and activeTab shape untouched.
web/src/lib/active-tab.test.js:
- Updated "reclaim updates myClaimTs..." — a stale claim from B now also
asserts
tabB.activeTab.inactive === trueafter an extradeliverBcMessages()flush (previously only checked A, per M-1 finding #1). - Added "a losing claimer is echoed at and freezes instead of leaving both
tabs active" — reproduces the asymmetric-delivery race (tabA's initial
claim is flushed with zero peers before tabB even mounts, so tabB never
receives it, matching real
BroadcastChannelnon-replay), equal ts, id tie-break; asserts exactly one tab ends up active. - Added "the echo does not loop" — spies on
FakeBC.prototype.postMessage, asserts message count settles at exactly 3 (two initial claims + one echo) and stays there across further flushes. - Added "a peer claim with no ts (legacy tab) always wins" — posts a raw
{type:"claim", id}(nots) from a secondBroadcastChannelinstance directly and asserts the tab freezes.
Verification (from web/)
npx vitest run→ 144/144 passed (10 files), incl. 10/10 inactive-tab.test.js.npx eslint .→ 0 problems.npx svelte-check --tsconfig ./jsconfig.json→ 0 errors, 0 warnings (429 files).
Files touched: web/src/lib/active-tab.svelte.js,
web/src/lib/active-tab.test.js only, per assignment. Not committed.
Status: DONE
Summary: Fixed M-1 by echoing a winning claim back at a losing peer (so it freezes instead of silently ignoring the loss) and treating a missing peer ts as Infinity (legacy peer always wins); added echo, no-loop, and legacy-no-ts test coverage; all gates green.
Concerns/Blockers: none