Repository navigation
WS reconnect loses session state on reload: is_editor/is_cutting and live-show leadership (on_close finalises before REFRESH_CLIENT can reclaim) #1419
Description
Summary
REFRESH_CLIENT (sent by every client on reconnect/page-reload when it already has a persisted internalUUID) does not actually preserve is_editor/is_cutting across a full page reload. This was found by a new Playwright test (client-v3/e2e/tests/15-collaborative-script-editing.spec.ts, "reloading during a draft rejoins it") that is the first test in the suite to reload the page while holding an edit lock — no existing spec does this, so the path has zero coverage. Reproduced 4/4 times locally (initial attempt + 3 retries).
Root cause (traced in code, not yet fixed)
server/controllers/ws_controller.py:
open()(~L99-103) generates a freshinternal_idfor every new WS connection and immediately callsself.update_session(user_id=...), which creates aSessionrow for that fresh id withis_editor=False(the default).on_message's dispatcher (~L340) computesentry = session.get(Session, self.internal_id)once, using the connection's current (fresh) internal_id — before branching on the op.- In the
REFRESH_CLIENTbranch (~L369-390),is_editor = entry.is_editorreads that fresh, just-created row — which is never an editor — not the row fornew_uuid(the old, persisted id the client is asking to resume). So the value copied forward is alwaysFalse. - Separately,
on_close(~L111 onward) unconditionally deletes theSessionrow for the disconnectinginternal_idas soon as the old socket closes (no grace period). On an ordinary page reload the old socket closes at or before the new one opens, so even if step 3's lookup targeted the right id, the row it needs may already be gone by the timeREFRESH_CLIENTruns. on_closealso actively tears down an in-progress collaborative script room if the disconnecting client was its only editor (room.remove_client→close_active_room()whennot room.has_editors), on every disconnect — including a same-tab reload, not just an intentional "Stop Editing".
Net effect: reloading the page while editing the script silently drops is_editor, so the UI shows "Edit" again instead of "Stop Editing" after a reload. No draft data is lost (the room rebuilds from the checkpointed draft on the next join), but the user's "I am editing" state is lost without any explanation, and this appears to affect the legacy single-editor lock and live-session leader election equally, not just the new collaborative editor — those flows have simply never had a reload-mid-session test either.
Evidence
- Zero references to
REFRESH_CLIENTanywhere inserver/test/— this path has never been unit-tested. - Playwright trace/screenshot for the failing E2E attempt (available on request / in CI artifacts) shows a normal "Edit" button after reload where "Stop Editing" was present before.
Scope note
This is orthogonal to the collaborative-editing rebuild (issue #889) — it's shared WS session-lifecycle code used by script editing, cutting, and live-session leader election alike. Please investigate and, if the fix is small and safe, propose it; if it touches broader session semantics, scope a follow-up.
Suggested starting points (not verified as sufficient — the on_close race needs checking too)
- In the
REFRESH_CLIENTbranch, look up the row bynew_uuid(the id being resumed), not by the connection's freshself.internal_id. - Investigate whether
on_closeneeds to leave a resumable trace (e.g. a short grace period, or not deleting a row whoseis_editor/is_cuttinga same-tab reload is likely to reclaim) rather than deleting unconditionally, given the two events race.
Activity
Tim020 commentedon Sep 22, 2026
Investigated — root cause confirmed, but not what the write-up says. No production fix implemented (needs a design decision).
I branched
investigate/refresh-client-session-bugfromdev(local only, not pushed) and added two empirical integration tests toserver/test/controllers/test_ws_controller.py, using real WebSocket connections through the existing Tornado test harness (matches this file's existing_connect_and_authpattern). Committed locally as4069c74(not pushed).What I verified empirically (via the two new tests, not just reading)
I confirmed by removing
@unittest.expectedFailureand running each test in isolation (pytest -k refresh_client -n0) that both fail exactly where intended, not via timeout or an earlier assertion:test_refresh_client_after_stale_close_loses_is_editorfails at:AssertionError: False is not true : is_editor did not survive a real reload -- on_close's unconditional delete raced ahead of REFRESH_CLIENTtest_refresh_client_before_stale_close_loses_is_editorfails at:AssertionError: unexpectedly None : stale ws1's on_close deleted the live, just-resumed uuid1 row instead of leaving it aloneBoth are now left in the branch with
@unittest.expectedFailure(xfail), so they currently "pass" (as documented, known-broken behavior) and will flip to a loud unexpected pass the moment a real fix lands — rather than silently continuing to assert the bug. Full backend suite:934 passed, 2 xfailed, 0 failures(no production code was changed, only tests added, so this is a clean baseline, not a regression check).Two independent mechanisms cause the loss, one per possible ordering between the old socket's
on_closeand the new socket'sREFRESH_CLIENT:on_closeruns first (the scenario the issue describes):on_closesynchronously deletes theSessionrow for the disconnectinginternal_id, with no grace period, before the new connection'sREFRESH_CLIENTever runs. By the timeREFRESH_CLIENTlooks for the row to resume, it's already gone — there is nothing to recover, regardless of which key you look it up by.REFRESH_CLIENTruns first (not previously identified): the new connection (H2) successfully resumes the old row viaREFRESH_CLIENT, reassigning its owninternal_idattribute to the old uuid. But the old, now-stale connection object (H1) never updates its owninternal_idattribute — it's still holding the old uuid. WhenH1.on_closeeventually fires (the old socket takes a moment to actually tear down after navigation), it looks upSessionby its ownself.internal_id— unchanged, and now identical toH2's — and deletes it unconditionally. This destroysH2's freshly-resumed, live session purely because the two handler objects collided on the sameinternal_idstring.I did not determine which ordering a real browser reload produces — neither my tests nor the E2E repro establish this; both orderings were reproduced by explicit sequencing in the test, and both independently cause data loss, so it doesn't actually matter which one wins in practice: either way, the bug reproduces.
Correction to the issue's stated root cause
Item 3 in the trace (
REFRESH_CLIENTreadsis_editorfromentry, which is keyed by the connection's own freshinternal_idfromopen(), rather than from the row keyed bynew_uuid) is real code as described, but I verified it is currently behaviorally inert — it has zero effect on the outcome in either ordering I tested. That's becauseupdate_session()(server/controllers/ws_controller.py:64-89) only setsis_editor/is_cuttingwhen it creates a newSessionrow (theelsebranch); when a row for thatinternal_idalready exists (theif entry:branch), it silently ignores theis_editor/is_cuttingkwargs entirely and only toucheslast_ping/last_pong/user_id. So:REFRESH_CLIENTtime, whateveris_editorwas already on it survives untouched — regardless of what (wrong) valueREFRESH_CLIENT's local variable computed, becauseupdate_sessionnever writes it.update_sessiontakes the create-path and would use whatever theREFRESH_CLIENTbranch's local variable holds — which is currently alwaysFalse(sinceentryis the fresh, just-opened row) — but this only matters if the row doesn't already exist, which per mechanism A/B above is the dominant case anyway.These two bugs are coupled, not independent: fixing only the "wrong row" lookup, or only
update_session's asymmetry, without the other, doesn't help (and fixingupdate_sessionalone would make things worse in the row-still-exists case, overwriting a survivingTruewith the fresh row'sFalse). I actually implemented and tested the "look up bynew_uuid" lookup fix in isolation to confirm this empirically — it did not make either xfail test pass; the real target row is gone (mechanism A) or gets deleted immediately after by the stale handler (mechanism B) regardless of which rowREFRESH_CLIENTreads from. I reverted that change; it's not in the branch.On the "affects leader election equally" claim
Based on reading (not tested): this appears to be only partly right. The live-session leader-election path (
ShowSession.last_client_internal_id/client_internal_id, set inon_closeand reclaimed in theREFRESH_CLIENTbranch whennew_uuidmatches) already has a resumable-trace mechanism that survives theSessionrow's deletion, since it lives on the separateShowSessionrow. That makes leader election resilient to mechanism A specifically. But under mechanism B, the stale handler'son_closestill findsentry.live_sessiontruthy on the now-H2-owned row, deletes it, and runs leader election anyway — which for a single-tab reload would likely electNO_LEADER(no other session for that user). So leader election is not fully immune either; it just has a partial mitigation that editor/cutting flags entirely lack. I have not written a test for this — flagging as inference from reading only.Why I didn't implement a production fix
Per the task scope: this needs a real design decision, not a small/safe patch. Concretely:
on_closeto stop unconditionally deleting aSessionrow that a same-tab reload may be about to reclaim — e.g. a grace period, or a persisted resumable trace analogous toShowSession.last_client_internal_id, but foris_editor/is_cutting. This needs decisions about: how long to wait, where the trace lives, how/when it's cleaned up if never reclaimed, and interaction with RBAC (should a resumed edit lock re-validate write access?) and the room-teardown logic that also currently runs unconditionally on everyon_close.on_closecould check whether anotherWebSocketHandlerinself.application.clientscurrently holds the sameinternal_idbefore deleting theSessionrow / firing notifications, and skip cleanup entirely if so (a live connection now owns that identity). I have not implemented or tested this — it's a candidate, not a verified fix — but it looks narrower than mechanism A's fix and might be worth doing first/independently.Recommended direction
on_closecleanup when another live client already holds the sameinternal_id) — but it should still get its own test before landing, and needs a look at whether the room-teardown/broadcast logic inon_closehas the same issue.is_editor/is_cutting, following the existingShowSession.last_client_internal_idprecedent.expectedFailuretest(s) in my branch (or equivalents) to plain passing assertions — that's exactly what they're for.client-v3/e2e/tests/15-collaborative-script-editing.spec.ts, "reloading during a draft rejoins it") is still pending — I skipped running Playwright entirely per the task's guidance (another session may be using port 8888), so this is unverified at the E2E layer beyond the original report. Note: this spec file doesn't exist ondevyet — it's on an in-progress feature branch.Where things are
investigate/refresh-client-session-bug, local only, not pushed, commit4069c74on top ofdevat549e847.server/test/controllers/test_ws_controller.py(+149 lines, two new tests). No production code changed.934 passed, 2 xfailed, 0 failures.Happy to paste the full test diff here on request, or push the branch for review if that's preferred over leaving it local-only.
[-]REFRESH_CLIENT does not preserve is_editor/is_cutting across a page reload[/-][+]WS reconnect loses session state on reload: is_editor/is_cutting and live-show leadership (on_close finalises before REFRESH_CLIENT can reclaim)[/+]Tim020 commentedon Sep 24, 2026
Consolidating #1424 into this issue: same root cause
#1424 ("Live show leader re-election can race when the same user has two open tabs") is being closed as a duplicate of this issue. Both bugs come from the same WS session-lifecycle flaw in
server/controllers/ws_controller.py:A fix for either bug has to change the same rule: when
on_closefinalises a departed uuid, and howREFRESH_CLIENTreclaims it. Fixing them separately would produce two conflicting reclaim designs in the same functions.Context carried over from #1424 (corrected mechanism, per its final comment)
Two tabs, same user, leader L and follower F, both reloading (e.g.
RELOAD_CLIENTbroadcast by "Jump To Page" / "Reload Clients"):on_closeruns election:select(Session).where(Session.user_id == live_session.user_id).first()(noORDER BY) finds F's still-open row and promotes F:client_internal_id = F,last_client_internal_id = None.on_closeruns election again. It setslast_client_internal_id = F, finds no other same-user session, and emitsNO_LEADER.REFRESH_CLIENTmatcheslast_client_internal_id == Fand reclaims leadership. That reclaim never checks who currently holdsclient_internal_id, andNEW_CLIENTnever clearslast_client_internal_id.REFRESH_CLIENThas nothing to match, so L stays a follower.Evidence: A/B-testing the #1425 E2E fix (reverting the follower to the admin login) failed 3 of 4 full Firefox runs, with the leader's own header showing "Following" after reload.
This lines up with Mechanism B in the investigation above: a stale handler's
on_closeacts on a uuid that a live handler has already resumed. The investigation already noted this affects leader election as well as editor flags.Acceptance criteria for the fix
Each of these needs a backend WS integration test, written before the production change:
is_editor/is_cutting(Mechanism A:on_closeruns first).on_closedoesn't delete or finalise a row that a live handler has resumed viaREFRESH_CLIENT(Mechanism B).NO_LEADER.expectedFailuretests oninvestigate/refresh-client-session-bug(4069c74) become plain passing tests.A fix is being picked up on a
fix/branch targetingdev.Tim020 commentedon Sep 24, 2026
For whoever picks this up:
client-v3/e2e/tests/15-collaborative-script-editing.spec.tshas a test named "reloading loses editor status (known gap, issue #1419) but not the draft" that currently asserts the buggy behaviour (the Edit button reappears after a reload) as a tripwire. Once this is fixed server-side, that test will start failing — at that point, invert the assertion to check thatstopEditing(page)is visible instead ofeditButton(page).CollabScriptEditor.vue's reload-rejoin code path (onMounted'sif (isEditor.value) retryJoin();) needs no client-side change to pick up the fix.Address PR #1421 review: Stop Editing lifecycle bug, stuck-spinner fa…
Hold WS session state through a reconnect grace window (fixes #1419, #…
Tim020 commentedon Oct 19, 2026
Fixed by #1427, squash-merged into
devas d9718b8. Closing manually, because the PR targeteddevrather than the default branch, so GitHub's "Fixes #1419" auto-close didn't trigger.Summary of the fix
Sessionrow, its edit/cut lock, live-show leadership and the collab room close are held for a short window (5s by default). The original deadline is never re-armed.REFRESH_CLIENTrestores identity only.REASSIGN_UUID, handled by both clients).show/sessions/startbinds to the requester.on_messageexceptions killing the read loop;ApiRoutebinding classes toNone;The acceptance checklist in this issue is covered by backend tests in
server/test/controllers/test_ws_controller.py(written test-first and mutation-checked) plus E2E coverage in specs 02, 03 and 10. Full suites were green at merge: pytest 1043, and Playwright 225/225 on Chromium and Firefox.Follow-up: #1430,
LIVE_SHOW_JUMP_TO_PAGEisn't leader-gated. It's still open.Merge remote-tracking branch 'origin/dev' into feature/collab-editing…
Collaborative script editing: read-only lifecycle and browse path (Ph…