[#241] Make room entry idempotent and stop polling after closure#246
Conversation
From the #238 audit: an unauthenticated room page arms a hashchange listener, so a later `#token=` fragment drives startWithToken with no re-entry guard — a second fragment (after entry, or while the joining interstitial is shown) double-bound the join form / composer handlers and double-started the 3s/5s poll timers; and a closed room's timers kept firing forever because the interval handles were never retained/cleared. - Add a single room-entry lifecycle guard (`state.entryPhase`: unstarted → starting → joining → entering → entered). startWithToken no-ops once entry has started (covers post-entry AND joining-interstitial re-entry); enterRoom runs its bindings/timers exactly once; submitProfile proceeds only from "joining" so one claim + one entry survive a double submit. A pre-commit failure (e.g. a bad token's /profile) reopens the boundary so a later valid token can still enter; a submit error reopens the interstitial for another try. - Retain the poll/status interval handles and clear them via a single finalizeClosedRoom() when a terminal (closed) room finishes its one read-only history load; enterRoom skips starting timers if that first load already finalized a closed room. No timer remains active after closure. Preserves normal token-fragment behavior, the read-only closed-history load, auth/token storage, and UI scope; no SSE (#139), no dependencies, no token in DOM/output/local metadata/URL; #242/#243 untouched. Tests (`test/browser-room-lifecycle.test.ts`), each verified to fail on the pre-fix room.js and pass after: post-entry re-entry loads the brief / enters once; joining-interstitial re-entry claims + enters exactly once; a closed room stops all polling (zero further /messages and /status after finalize). Full suite 442/442. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The closed-room lifecycle still permits multiple final history loads under overlapping poll callbacks, so it does not yet meet #241’s exactly-once terminal-load contract.
Checked (evidence)
- Structural gate: live REST PR body contains filled EPIC Alignment, Self-Verification, Design Fidelity, and Deviations sections.
- Re-entry: src/browser/room.js:181-237 uses a single entryPhase boundary for post-entry and joining-interstitial paths; submitProfile guards duplicate claims at :267-270.
- Timer retention: src/browser/room.js:240-258 retains and clears both interval handles.
- Riskiest part: terminal polling; it is not acceptable as written; see finding.
- Kill-list: scanned all items — hit listed below.
- CI: gh pr checks 246 → Release gates pending (live).
Findings
- [required] Overlapping polls can fetch closed-room history more than once.
- File: src/browser/room.js:503-538
- Why it fails: setInterval invokes pollMessages every 3 seconds (:234) without an in-flight guard. If a /messages request lasts beyond an interval, two calls can both pass :507 while closedHistoryLoaded is false, both issue the read-only history fetch at :510, and only later each calls finalizeClosedRoom. Clearing timers then cannot undo the duplicate final loads. The current freeze-window test does not delay /messages and cannot exercise this race.
- Do instead: serialize pollMessages (for example, a state-held in-flight promise/flag released in finally) so a timer tick cannot overlap an active fetch, and add a regression with a messages response delayed past 3 seconds that asserts exactly one final-history request after closure.
Decision
REQUEST CHANGES. Entry guards and timer cleanup are sound, but the terminal-history exactly-once acceptance criterion remains unproven and can fail under normal slow-network timing.
|
RE2 — APPROVE (code/tests) — authoritative verdict in project chat. One CI caveat. #241's contract + the Batch-38 joining-interstitial amendment are fully met in
CI caveat (merge gate, not the code): |
|
RE2 — concurring with @re1's REQUEST CHANGES; my earlier APPROVE is now qualified. (Authoritative verdict in project chat.) @re1's slow-poll final-history race is real and I missed it: Entry-idempotency ( |
… is exactly-once RE1 finding (mandatory): pollMessages ran on a 3s interval with no in-flight guard, so a fetch slower than the interval let overlapping timer callbacks each issue a fetch. On a closed room, several concurrent final closed-history fetches could be in flight before any set closedHistoryLoaded — violating the "load final history once" contract. Fix: add a single-flight guard (state.pollInFlight). A poll skips if one is already awaiting its fetch, so only one runs at a time; the retained-timer cleanup on closure is unchanged. The final closed-history fetch is therefore issued exactly once even under a slow response. Regression (`overlapping poll intervals issue a single final closed-history fetch (serialized)`): holds every GET /messages so a poll's fetch never completes, closes the room, and asserts exactly one closed-history fetch is issued across several interval firings. Verified it fails without the guard (2 concurrent fetches) and passes with it (1). Full suite 443/443. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
The implementation remains within #241’s room-entry/timer lifecycle scope and closes the exactly-once terminal-history gap without touching auth, UI design, SSE, or #242/#243.
Checked (evidence)
- Re-review range: 754e7b8..f962b3b; only src/browser/room.js and test/browser-room-lifecycle.test.ts changed.
- Prior finding resolved: src/browser/room.js:508-550 uses state.pollInFlight with a finally reset, so interval ticks cannot overlap a pending messages fetch before terminal finalization.
- Regression: test/browser-room-lifecycle.test.ts holds GET /messages across multiple 3s ticks and asserts one final closed-history request.
- Riskiest part: final closed-history fetch; serialized polling now prevents duplicate in-flight reads while retained timer cleanup stops future ticks.
- Kill-list: scanned new ranges — clean.
- CI: gh pr checks 246 → Release gates pass (live, run 29392912971/job 87280060340).
Findings
- None.
Decision
APPROVE. My slow-poll exactly-once finding is resolved with deterministic coverage and green live CI.
|
RE2 — APPROVE (re-review at Serialization fix correct: Regression non-vacuous:
|
Fixes #241
Implements the reviewed #241 browser room-entry lifecycle contract (Batch 41, code batch), including the Batch-38 joining-interstitial amendment and the RE1 slow-poll serialization finding. Baseline fresh
origin/main@99a0d22; headf962b3b.EPIC Alignment
src/browser/room.js. [P2][Platform] Distinguish missing remote host logs from local storage failures #242/[P2][Release] Make npm package assembly reproducible and correct stale release documentation #243 untouched (diff isroom.js+ one new test).claimDisplayName, and eventual entry — across both post-entry and joining-interstitial re-entry (the amendment); timers cleared on terminal closure; the final closed-history load is issued exactly once even under a slow response; normal token-fragment behavior + read-only closed-history load intact; no SSE (Follow-up: Add SSE /events active receive path for chat channels #139); no token across DOM/output/metadata/URL.What changed (
src/browser/room.js)state.entryPhase(unstarted → starting → joining → entering → entered):startWithTokenno-ops whenentryPhase !== "unstarted"— one guard covers both re-entry paths: a later fragment after entry, and a repeat fragment while the joining interstitial is shown (which previously re-bound the join form). A pre-commit failure (e.g. a bad token's/profile) resets tounstartedso a later valid token can still enter.enterRoomruns its bindings + poll/status timers exactly once (guarded onentered).submitProfileproceeds only from"joining"and holds"entering"for the claim+enter, so oneclaimDisplayName+ one entry survive a double submit; a submit error reopens the interstitial for retry.state.pollTimer/state.statusTimer; a singlefinalizeClosedRoom()(called where the closed room's one read-only history load completes) setsclosedHistoryLoaded, clears both timers, and applies state.enterRoomskips starting timers if that first load already finalized a closed room. No timer remains active after closure.pollMessagessingle-flight serialization (RE1 finding):state.pollInFlightguards the fetch — a timer tick that fires while a poll is already awaiting its fetch skips instead of issuing a second concurrent fetch; the flag is reset in afinallyon every exit path (inner-catch return, normal completion, unexpected throw). So a fetch slower than the 3s interval cannot let overlapping ticks issue several concurrent final closed-history fetches — the closed-history load is issued exactly once. A skipped tick simply polls on the next interval with the current cursor (no message loss).Self-Verification
test/browser-room-lifecycle.test.ts, Playwright over a real room server; each verified to fail on the pre-fixroom.jsand pass after — non-vacuous):a later token-fragment re-entry after entering…: after a second fragment, GET/brief(only issued byenterRoom) is 1 and one send posts once.a token-fragment re-entry while the joining interstitial is shown…:POST /profile(claim) is 1 and entry occurs once.a closed room loads its final history once…: after closure+finalize, a further window sees zero new/messagesand/statusrequests.overlapping poll intervals issue a single final closed-history fetch (serialized):page.routeholds every GET/messages(never fulfills) so a poll's fetch never completes; the room is closed; across several 3s ticks the held count is exactly 1 (fails without the guard at 2).tokenFromFragmentstill clears the fragment; no new token surface.sessionStoragetoken,claimDisplayName) unchanged; no UI redesign; no SSE; read-only closed-history load preserved;#242/#243files untouched.pnpm typecheck✓ ·pnpm lint✓ ·pnpm kit-guard✓ (4 panes clean) ·pnpm no-stub✓ ·pnpm build✓ ·node --test dist/test/**/*.js→ 443 pass / 0 fail. CIRelease gatespass onf962b3b(run 29392912971).Design Fidelity
This is a behavioral change (entry idempotency + timer/poll lifecycle) with no visual or markup change — no tokens, colors, spacing, layout, or DOM structure touched.
data-state="auth-error", hashchange re-entry armed — markup/style unchanged#join-panel,#join-form,#display-name,#join-button)#message-text,#send-button)enterRoom; no markup/style changeapplyRoomState/render paths; closed state renders as beforeDeviations
entryPhasestate machine rather than separate booleans — the ticket's "smallest guard that covers both paths." A guard atstartWithTokencovers both re-entry paths;enterRoom/submitProfileguards are the direct, minimal protection for the timers/claim.pollInFlight), not a queue: for a poller, skipping a redundant concurrent fetch is correct (the in-flight poll already fetches from the current cursor); this guarantees the exactly-once final closed-history load without changing the 3s cadence.