"""Regression test for issue #1937 — endless-scroll prefetch vs Start-jump race. When both ``session_jump_buttons`` and ``session_endless_scroll`` opt-ins are enabled, ``_loadOlderMessages`` (the endless-scroll prefetch) can be in flight when the user clicks the Start jump pill, which calls ``_ensureAllMessagesLoaded``. If the prefetch resolves AFTER the ensure-all wholesale-replaces ``S.messages``, it would prepend a duplicate page. The fix uses two coordinated guards: 1. A ``_messagesGeneration`` token that gets bumped any time ``S.messages`` is wholesale-replaced. ``_loadOlderMessages`` snapshots the token before its ``await`` and re-checks afterwards; if it changed, the prepend is aborted. 2. ``_ensureAllMessagesLoaded`` claims the existing ``_loadingOlder`` mutex around its body so no NEW prefetch can start mid-replace, and so concurrent ensure-all invocations (e.g. rapid double-click on Start) serialize cleanly. It also yields until any in-flight prefetch's ``finally`` clears the flag before claiming the mutex itself. The old fix shape suggested in the issue (spin-wait on ``_loadingOlder`` before running ensure-all) does not actually solve the race the report describes: by the time the prefetch passes its entry-gate check, it is already past the only point where ``_loadingOlder`` is read, so a same- flag check inside its post-await body would be a no-op. The generation token is the canonical pattern for invalidating async continuations and is what this regression suite locks in. """ from pathlib import Path REPO = Path(__file__).resolve().parents[1] SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") def _function_body(src: str, name: str) -> str: """Slice the body of ``async function `` (or ``function ``).""" needle_async = f"async function {name}" needle_sync = f"function {name}" if needle_async in src: start = src.index(needle_async) else: start = src.index(needle_sync) brace = src.index("{", start) depth = 0 for i in range(brace, len(src)): if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 if depth == 0: return src[start : i + 1] raise AssertionError(f"function {name!r} body not found") # --------------------------------------------------------------------------- # Generation token: declared at module scope, bumped via the helper. # --------------------------------------------------------------------------- def test_generation_token_declared_at_module_scope(): """``_messagesGeneration`` exists as a module-scoped mutable counter.""" assert "let _messagesGeneration = 0;" in SESSIONS_JS, ( "static/sessions.js must declare `let _messagesGeneration = 0;` so " "_loadOlderMessages can snapshot/re-check it across its `await`. " "See #1937." ) def test_generation_bump_helper_exists(): """A single helper bumps the generation; both consumers route through it.""" assert "function _bumpMessagesGeneration()" in SESSIONS_JS, ( "static/sessions.js must define `_bumpMessagesGeneration()` so " "wholesale-replace sites have a single, named pivot to call. See #1937." ) body = _function_body(SESSIONS_JS, "_bumpMessagesGeneration") assert "_messagesGeneration" in body, ( "_bumpMessagesGeneration must mutate _messagesGeneration" ) # --------------------------------------------------------------------------- # _loadOlderMessages: snapshot before await, re-check after. # --------------------------------------------------------------------------- def test_load_older_snapshots_generation_before_await(): """Snapshot must be captured BEFORE the `await api(...)` call.""" body = _function_body(SESSIONS_JS, "_loadOlderMessages") snapshot_idx = body.index("const startGeneration = _messagesGeneration;") await_idx = body.index("await api(") assert snapshot_idx < await_idx, ( "_loadOlderMessages must snapshot _messagesGeneration before its " "`await`. Capturing it after the await defeats the race guard. " "See #1937." ) def test_load_older_aborts_when_generation_changed(): """Post-await guard must compare against the snapshot and abort.""" body = _function_body(SESSIONS_JS, "_loadOlderMessages") assert "if (_messagesGeneration !== startGeneration) return;" in body, ( "_loadOlderMessages must bail out (without prepending) when the " "generation token changed during its await — that is the signal " "that S.messages was wholesale-replaced under it. See #1937." ) def test_load_older_generation_check_runs_before_replace(): """Generation check must come BEFORE the `S.messages = nextMessages` mutation.""" body = _function_body(SESSIONS_JS, "_loadOlderMessages") guard_idx = body.index("if (_messagesGeneration !== startGeneration) return;") replace_idx = body.index("S.messages = nextMessages;") assert guard_idx < replace_idx, ( "Generation guard must short-circuit BEFORE the message-array mutation. " "Otherwise duplicate messages can still slip through. See #1937." ) # --------------------------------------------------------------------------- # _ensureAllMessagesLoaded: claims the mutex, bumps the generation, yields. # --------------------------------------------------------------------------- def test_ensure_all_bumps_generation_before_replace(): """Bump must happen BEFORE the wholesale `S.messages =` replace so racing prefetch sees it.""" import re body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") bump_idx = body.rindex("_bumpMessagesGeneration()") # Match the wholesale replace by its LHS, not the RHS variable name — #3306 # added an ephemeral-field carry-forward so the RHS is now `_msgsToAssign` # rather than the literal `msgs`. The invariant we protect is bump-before-replace. m = re.search(r"S\.messages\s*=\s*\w+;", body) assert m is not None, "expected a wholesale `S.messages = ;` replace in _ensureAllMessagesLoaded" replace_idx = m.start() assert bump_idx < replace_idx, ( "_ensureAllMessagesLoaded must bump the generation token BEFORE the " "wholesale replace, otherwise an in-flight prefetch's post-await " "check could read the old value and prepend duplicates. See #1937." ) def test_ensure_all_claims_loading_older_mutex(): """The body must hold `_loadingOlder = true` so no NEW prefetch starts mid-replace.""" body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") assert "_loadingOlder = true;" in body, ( "_ensureAllMessagesLoaded must claim the _loadingOlder mutex so " "the entry-gate in _loadOlderMessages short-circuits new prefetches " "while ensure-all is mid-replace. See #1937." ) assert "_loadingOlder = false;" in body, ( "_ensureAllMessagesLoaded must release the _loadingOlder mutex in " "its finally-block. Otherwise endless-scroll silently breaks after " "every Start-jump." ) def test_ensure_all_releases_mutex_in_finally(): """Mutex release must live inside a `finally` so errors don't leak the lock.""" body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") finally_idx = body.index("} finally {") release_idx = body.index("_loadingOlder = false;", finally_idx) assert release_idx > finally_idx, ( "_loadingOlder release must be inside the finally-block to survive " "thrown errors during the wholesale replace. See #1937." ) def test_ensure_all_yields_when_prefetch_in_flight(): """When a prefetch holds the mutex, ensure-all must wait, not wholesale-replace alongside it.""" body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") # Look for the yield-loop on _loadingOlder before the mutex claim. yield_idx = body.index("while (_loadingOlder)") claim_idx = body.index("_loadingOlder = true;") assert yield_idx < claim_idx, ( "_ensureAllMessagesLoaded must yield (poll _loadingOlder) BEFORE " "claiming the mutex itself, so an in-flight prefetch's finally-" "block fires and the generation guard inside that prefetch resolves " "the race cleanly. See #1937." ) def test_ensure_all_bumps_generation_during_wait_phase(): """Bumping during the wait poisons any in-flight prefetch immediately, even before ensure-all gets the mutex.""" body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") # Find the _loadingOlder branch that runs when a prefetch is in flight, # and verify it bumps the generation before the wait loop. branch_idx = body.index("if (_loadingOlder) {") wait_idx = body.index("while (_loadingOlder)", branch_idx) bump_in_branch = body.index("_bumpMessagesGeneration()", branch_idx) assert branch_idx < bump_in_branch < wait_idx, ( "When a prefetch is in flight at entry, _ensureAllMessagesLoaded " "must bump the generation BEFORE the wait loop so the in-flight " "prefetch's post-await check fires the moment its api() resolves, " "not just for future calls. See #1937." ) def test_ensure_all_resets_oldest_idx(): """After wholesale-replacing with the full history, _oldestIdx must reset to 0.""" body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") assert "_oldestIdx = 0;" in body, ( "_ensureAllMessagesLoaded must reset _oldestIdx to 0 — without it, " "a subsequent prefetch could send `msg_before=` and " "request older messages that are already in the now-full transcript." ) def test_ensure_all_guards_against_session_switch_mid_await(): """Same-session check must run after await — old version skipped this.""" import re body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") await_idx = body.index("await api(") sid_check_idx = body.index("S.session.session_id !== sid", await_idx) # #3306 renamed the replace RHS from `msgs` to `_msgsToAssign` (carry-forward); # match by LHS so the ordering invariant survives the rename. m = re.search(r"S\.messages\s*=\s*\w+;", body[await_idx:]) assert m is not None, "expected a wholesale `S.messages = ;` replace after the await" replace_idx = await_idx + m.start() assert await_idx < sid_check_idx < replace_idx, ( "_ensureAllMessagesLoaded must guard against session-switch races " "(re-check S.session.session_id after await) BEFORE wholesale-" "replacing S.messages. The pre-fix version had no such guard." )