diff --git a/static/sessions.js b/static/sessions.js index a4b0ba20..22e27cfb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -19,6 +19,11 @@ const ICONS={ // responses from in-flight requests when the user switches sessions again // before the first request completes (#1060). let _loadingSessionId = null; +// Each loadSession() invocation gets a monotonically increasing generation. +// `_loadingSessionId` only tracks destination session_id, so same-session +// concurrent loads can still race and overwrite each other unless we compare +// the generation token as well. +let _loadSessionGeneration = 0; // #3306: Snapshot of S.messages captured by loadSession() right before it // clears them on a force-reload of the active session. Consumed by // _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so @@ -1423,6 +1428,8 @@ async function loadSession(sid){ if(currentSid===sid && !forceReload && !_loadingSessionId) return; // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). + const _loadGeneration = ++_loadSessionGeneration; + const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration; _loadingSessionId = sid; if(currentSid!==sid&&typeof _uploadPendingFilesSyncProgressForSession==='function')_uploadPendingFilesSyncProgressForSession(sid); // Reset scroll state for fresh session navigation — the reader expects to @@ -1453,7 +1460,7 @@ async function loadSession(sid){ // continuation can't wipe S.messages / write the loading placeholder / // close streams for the session the user actually landed on (#1060 guard, // extended to cover the new pre-switch await). - if (_loadingSessionId !== sid) return; + if (!_isCurrentLoad()) return; // Snapshot the live turn before msgInner is replaced. Preserves the activity // timer, partial response, and tool cards so switching back does not rebuild // the stream UI from scratch. @@ -1532,7 +1539,7 @@ async function loadSession(sid){ } catch(e) { const profileMismatch=_sessionProfileMismatchFromError(e); if(profileMismatch && profileMismatch.profile && !opts.skipProfileResolve){ - if (_loadingSessionId !== sid) { + if (!_isCurrentLoad()) { _rearmActiveSessionStream(); return; } @@ -1544,11 +1551,11 @@ async function loadSession(sid){ // navigated to a different session. If we no longer own the load, bail // before clearing _loadingSessionId or retrying so the stale // continuation can't hijack the UI back to the old target. - if (_loadingSessionId !== sid) { + if (!_isCurrentLoad()) { _rearmActiveSessionStream(); return; } - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; return loadSession(sid,{...opts,skipProfileResolve:true,force:true}); }catch(switchErr){ e=switchErr; @@ -1562,7 +1569,7 @@ async function loadSession(sid){ // for the session the user actually navigated to. If we no longer own the // load, re-arm the active session's stream and bail before any DOM mutation // or self-heal. - if (_loadingSessionId !== sid) { + if (!_isCurrentLoad()) { _rearmActiveSessionStream(); return; } @@ -1583,7 +1590,7 @@ async function loadSession(sid){ if(!currentSid || currentSid===sid){ try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ } try{ history.replaceState(null,'',_appRootPath()); }catch(_){ } - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; if(!currentSid){ throw e; } @@ -1607,7 +1614,7 @@ async function loadSession(sid){ // NOT restart — doing so would spin the SSE reconnect loop against a dead // session_id. const _selfHealedCurrent = (e.status===404) && (currentSid===sid); - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; // The session stream was stopped unconditionally at the top of this load // (mirroring stopApprovalPolling). On the happy path it's restarted ~120 // lines below, but this failure exit never reaches that point — leaving @@ -1636,14 +1643,14 @@ async function loadSession(sid){ // send users to empty state after re-login (#4028 follow-up). if (!data) { _clearSameSessionForceReloadHint(sid); - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; // #2971: re-arm the still-displayed session's stream (defensive — harmless // if the 401 redirect is already tearing the page down). Idempotent. _rearmActiveSessionStream(); return; } // Stale response? A newer loadSession() call has already started (#1060). - if (_loadingSessionId !== sid) { + if (!_isCurrentLoad()) { // #2971: a newer in-flight load owns the final stream arming, but until it // assigns S.session and reaches startSessionStream() the currently-shown // session must not be left stream-dead by our top-of-function teardown. @@ -1660,7 +1667,7 @@ async function loadSession(sid){ // cross-profile continuation can't poison restore state with an unusable id. const continuationSid=(data.session&&data.session.continuation_session_id)||''; if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){ - _loadingSessionId=null; + if (_isCurrentLoad()) _loadingSessionId=null; return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true}); } S.session=data.session; @@ -1802,10 +1809,18 @@ async function loadSession(sid){ // this session's INFLIGHT snapshot, not leave prior-session rows in place. if(typeof clearLiveToolCards==='function') clearLiveToolCards(); try { - await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded}); + await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration}); } catch(e) { + if (!_isCurrentLoad()) { + _rearmActiveSessionStream(); + return; + } S.messages=inflightMessages; } + if (!_isCurrentLoad()) { + _rearmActiveSessionStream(); + return; + } const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages); if(liveTailPrepared){ S.messages=_dropCurrentTurnAssistantMessages(S.messages); @@ -1838,7 +1853,7 @@ async function loadSession(sid){ let didReconnect=false; if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){ INFLIGHT[sid].reattach=false; - if (_loadingSessionId !== sid) return; + if (!_isCurrentLoad()) return; didReconnect=true; attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true}); } @@ -1900,8 +1915,12 @@ async function loadSession(sid){ // "messages already populated" early-return inside _ensureMessagesLoaded // does NOT skip the swap to the new transcript. try { - await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded}); + await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration}); } catch (e) { + if (!_isCurrentLoad()) { + _rearmActiveSessionStream(); + return; + } // Network errors, server failures, or SSE drops (Chrome error codes 4/5) // can cause _ensureMessagesLoaded to throw. Without a try/catch here the // "Loading conversation..." div injected at the top of loadSession would @@ -1911,11 +1930,11 @@ async function loadSession(sid){ _msgInner.innerHTML = '
Failed to load messages. Try switching sessions or refreshing.
'; } if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error'); - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; return; } // Stale? A newer loadSession() call has already started (#1060). - if (_loadingSessionId !== sid) return; + if (!_isCurrentLoad()) return; // Restore any queued message that survived page refresh or tab restore. if(typeof queueSessionMessage==='function'){ @@ -2032,7 +2051,7 @@ async function loadSession(sid){ } // Clear the in-flight session marker now that this load has completed (#1060). - if (_loadingSessionId === sid) _loadingSessionId = null; + if (_isCurrentLoad()) _loadingSessionId = null; if(typeof renderSessionArtifacts==='function') renderSessionArtifacts(); @@ -2737,6 +2756,9 @@ async function _ensureMessagesLoaded(sid, opts) { // _ensureMessagesLoaded to fetch and SWAP the new transcript into // S.messages in a single frame. opts = opts || {}; + const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null; + const _ownsLoad = () => _loadingSessionId === sid && (_loadGeneration === null || _loadSessionGeneration === _loadGeneration); + if (!_ownsLoad()) return; // Already have messages? (e.g. from INFLIGHT restore path, already set) if (!opts.force && S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) { _clearSameSessionForceReloadHint(sid); @@ -2758,6 +2780,7 @@ async function _ensureMessagesLoaded(sid, opts) { } finally { _clearSameSessionForceReloadHint(sid); } + if (!_ownsLoad()) return; // Guard: api() may have redirected (401) and returned undefined. if (!data || !data.session) return; _messagesTruncated = !!data.session._messages_truncated; diff --git a/tests/test_cross_session_message_load_isolation.py b/tests/test_cross_session_message_load_isolation.py new file mode 100644 index 00000000..00a3329c --- /dev/null +++ b/tests/test_cross_session_message_load_isolation.py @@ -0,0 +1,650 @@ +"""Regression tests for cross-session transcript isolation in loadSession. + +The checks lock behavior at both levels: + +1) Structural guard checks in ``loadSession()`` and ``_ensureMessagesLoaded()`` + around ownership tokens and catch-path mutations. +2) Runtime ordering/catch coverage using an executable Node harness to reproduce + old->new load overlap and stale rejected continuation behavior. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _extract_function(source: str, name: str) -> str: + """Return the full function source for ``name`` from a single js file. + + Brace-depth tracking handles nested blocks and avoids fragile substring + matching in the large, hand-formatted source file. + """ + marker = f"async function {name}(" + start = source.find(marker) + if start < 0: + marker = f"function {name}(" + start = source.find(marker) + assert start >= 0, f"{name} not found in sessions.js" + + brace_start = source.find("{", start) + assert brace_start >= 0, f"function {name} is missing '{{'" + + depth = 0 + in_string = None + escaped = False + in_line_comment = False + in_block_comment = False + + for index in range(brace_start, len(source)): + ch = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + continue + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + continue + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == in_string: + in_string = None + continue + + if ch == "/" and nxt == "/": + in_line_comment = True + continue + if ch == "/" and nxt == "*": + in_block_comment = True + continue + if ch in ('\'', '"', "`"): + in_string = ch + continue + + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + + raise AssertionError(f"Could not extract function {name}") + + +LOAD_SESSION_SRC = _extract_function(SESSIONS_SRC, "loadSession") +ENSURE_MESSAGES_LOADED_SRC = _extract_function(SESSIONS_SRC, "_ensureMessagesLoaded") + + +def _normalise_ws(s: str) -> str: + return re.sub(r"\s+", "", s) + + +def test_loadsession_has_generation_token_and_forwards_to_ensure_messages_loaded(): + body = LOAD_SESSION_SRC + assert "_loadSessionGeneration" in body, ( + "loadSession() must use a global generation counter so superseded loads " + "can be rejected by continuation ownership checks" + ) + assert "const _loadGeneration = ++_loadSessionGeneration" in body, ( + "loadSession() must increment and capture per-call generation" + ) + assert "const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration" in body + assert "loadGeneration:_loadGeneration" in body, ( + "loadSession() must thread generation into _ensureMessagesLoaded()" + ) + # Guard each await/catch branch so stale continuation cannot mutate shared pane state. + # Two calls exist in this function: INFLIGHT and idle branches. + norm = _normalise_ws(body) + assert norm.count("if(!_isCurrentLoad())") >= 6, ( + "loadSession() should check ownership in multiple await/catch paths, " + "including stale _ensureMessagesLoaded catch branches" + ) + ensure_call = _normalise_ws("await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});") + assert ensure_call in norm, ( + "loadSession() must pass generation into _ensureMessagesLoaded() for stale-owner checks" + ) + assert ( + "showToast('Failed to load session" in LOAD_SESSION_SRC + or "showToast('Failed to load conversation messages" in LOAD_SESSION_SRC + ), "loadSession() should preserve toast-based failure paths" + + +def test_ensure_messages_loaded_ownership_guard_pre_and_post_await(): + body = ENSURE_MESSAGES_LOADED_SRC + assert "_loadSessionGeneration" in body, "_ensureMessagesLoaded should read generation" + assert "const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null" in body + norm = _normalise_ws(body) + assert ( + "_loadGeneration===null||_loadSessionGeneration===_loadGeneration" in norm + ), "_ensureMessagesLoaded must compare generation token" + assert norm.count("if(!_ownsLoad())return;") >= 2, ( + "_ensureMessagesLoaded needs pre/post await ownership guards" + ) + assert "_loadGeneration" in body, "_ensureMessagesLoaded should read generation from opts" + + +_NODE_SCRIPT_TEMPLATE = r''' +function makeHarness() { + const apiCalls = []; + const queue = []; + const pending = []; + function enqueue(url, value, mode="resolve") { + const defer = { url: String(url), value, mode, resolved: false }; + defer.promise = new Promise((resolve, reject) => { + defer._resolve = resolve; + defer._reject = reject; + }); + queue.push(defer); + return defer; + } + + async function api(url) { + apiCalls.push(String(url)); + const entry = queue.shift(); + if (!entry) { + throw new Error('Unexpected API call: ' + String(url)); + } + if (entry.url !== String(url)) { + throw new Error('API order mismatch, expected ' + entry.url + ', got ' + String(url)); + } + pending.push(entry); + return entry.promise; + } + + return { api, apiCalls, enqueue, pending }; +} + +function snapshotState() { + return { + sid: S.session && S.session.session_id, + messages: Array.isArray(S.messages) ? S.messages.map((m) => (m && m.role ? String(m.content || '') : null)).filter(Boolean) : [], + toolCalls: Array.isArray(S.toolCalls) ? S.toolCalls.slice() : [], + truncated: _messagesTruncated, + oldestIdx: _oldestIdx, + loadingSid: _loadingSessionId, + loadingGeneration: _loadSessionGeneration, + msgInner: _msgInner.innerHTML, + toastCalls: toastCalls.slice(), + rearmCalls, + apiCalls: apiHost.apiCalls.slice(), + clearHintCalls, + visibleCacheClears, + liveCardClears, + toolSyncCalls, + }; +} + +function createEnvironment() { + globalThis.INFLIGHT = {}; + globalThis.S = { + session: { session_id: 'sid-init', message_count: 0 }, + messages: [{ role: 'assistant', content: 'seed' }], + toolCalls: [], + pendingFiles: [], + busy: false, + activeStreamId: null, + }; + globalThis._loadingSessionId = null; + globalThis._loadingOlder = false; + globalThis._loadSessionGeneration = 0; + globalThis._pendingCarryForwardSnapshot = null; + globalThis._messagesTruncated = false; + globalThis._oldestIdx = 0; + globalThis._messageRenderWindowSize = 0; + globalThis._messageReloadLimitForSession = () => 2; + globalThis._currentMessageRenderWindowSize = () => 1; + globalThis._messageRenderableMessageCount = () => 2; + + globalThis._rearmActiveSessionStream = () => { rearmCalls += 1; }; + globalThis.stopApprovalPolling = () => {}; + globalThis.hideApprovalCard = () => {}; + globalThis.stopSessionStream = () => {}; + globalThis._yoloEnabled = false; + globalThis._updateYoloPill = () => {}; + globalThis.stopClarifyPolling = () => {}; + globalThis.hideClarifyCard = () => {}; + globalThis._saveComposerDraftNow = () => Promise.resolve(); + globalThis._sessionProfileMismatchFromError = () => null; + globalThis._switchProfileForSessionLoad = async () => {}; + globalThis._clearSameSessionForceReloadHint = () => { clearHintCalls += 1; }; + globalThis._clearStuckSessionOnBoot = () => {}; + globalThis._setSessionViewedCount = () => {}; + globalThis.scheduleTodosRefresh = () => {}; + globalThis.startSessionStream = () => {}; + globalThis.syncTopbar = () => {}; + globalThis._captureSameSessionForceReloadHint = () => {}; + globalThis._resolveSessionModelForDisplaySoon = () => {}; + globalThis._setSessionCompletionUnread = () => {}; + globalThis._clearSessionCompletionUnread = () => {}; + globalThis._setActiveSessionUrl = () => {}; + globalThis._deferWorkspaceRefreshForSession = () => {}; + globalThis._sessionListRender = () => {}; + globalThis._setSessionToolset = () => {}; + globalThis._applyPendingSessionModelForSession = () => {}; + globalThis.populateModelDropdown = () => {}; + globalThis._deferSessionSideEffect = (sid, fn) => Promise.resolve(fn()); + globalThis._hydrateTodosFromSession = () => {}; + globalThis._resolveLineage = () => {}; + globalThis._clearPendingSelections = () => {}; + globalThis._clearQueueCardDisplay = () => {}; + globalThis._syncTodosForSession = () => {}; + globalThis._clearAllTodosFromSession = () => {}; + globalThis._setSessionModelFromSession = () => {}; + globalThis._clearEmptyComposerModelOverride = () => {}; + globalThis._deferSessionProfileSwitch = () => {}; + globalThis._resolveSessionSideEffect = () => {}; + + + globalThis._clearMessageCache = () => {}; + globalThis._syncToolCallsForLoadedMessages = (msgs, toolCalls) => { + toolSyncCalls += 1; + S.toolCalls = []; + if (Array.isArray(toolCalls)) { + S.toolCalls = toolCalls.map((tc) => ({ ...tc, done: true })); + } + }; + globalThis.clearVisibleMessageRowCache = () => { visibleCacheClears += 1; }; + globalThis.clearLiveToolCards = () => { liveCardClears += 1; }; + + globalThis._syncCtxIndicator = () => {}; + globalThis._renderPendingPromptsForActiveSession = () => {}; + globalThis._restoreComposerDraft = () => {}; + globalThis.renderSessionArtifacts = () => {}; + globalThis.renderMessages = () => {}; + globalThis._checkAndShowHandoffHint = () => {}; + globalThis._hideHandoffHint = () => {}; + globalThis._isMessagingSession = () => true; + globalThis._clearDeferredActiveSessionExternalRefresh = () => {}; + + globalThis.setStatus = () => {}; + globalThis.setComposerStatus = () => {}; + globalThis.setBusy = () => {}; + globalThis.updateSendBtn = () => {}; + globalThis.updateQueueBadge = () => {}; + globalThis.startApprovalPolling = () => {}; + globalThis.startClarifyPolling = () => {}; + globalThis._fetchYoloState = () => {}; + + globalThis._resolveSessionIdFromSidebarLineage = (sid) => sid; + globalThis._resolveSessionLineage = (sid) => sid; + + globalThis._messageReloadLimitForSession = () => 2; + + globalThis._msgInner = { innerHTML: 'INIT_LOADING' }; + const _msgInput = { value: '' }; + globalThis.$ = (id) => { + if (id === 'msgInner') return _msgInner; + if (id === 'msg') return _msgInput; + return null; + }; + + globalThis.autoResize = () => {}; + globalThis.showToast = (msg) => { + toastCalls.push(String(msg)); + }; + + globalThis.window = {}; + globalThis.history = { replaceState: () => {} }; + globalThis.localStorage = { + removeItem: () => {}, + setItem: () => {}, + getItem: () => null, + }; + globalThis._appRootPath = () => '/'; + + rearmCalls = 0; + clearHintCalls = 0; + visibleCacheClears = 0; + liveCardClears = 0; + toolSyncCalls = 0; + toastCalls = []; +} + +let rearmCalls = 0; +let clearHintCalls = 0; +let visibleCacheClears = 0; +let liveCardClears = 0; +let toolSyncCalls = 0; +let toastCalls = []; + +// Source under test +__LOAD_SESSION_SRC__ +__ENSURE_MESSAGES_LOADED_SRC__ + +async function waitForQueued(apiHost, url) { + const target = String(url); + while (!apiHost.pending.some((entry) => entry.url === target)) { + await Promise.resolve(); + } +} + +const API_BEACON_META = { + session: { + session_id: 'sid-beacon', + message_count: 12, + active_stream_id: null, + resolve_model: 'qwen/qwq-32b-instruct', + }, +}; + +const API_BEACON_MSGS = { + session: { + session_id: 'sid-beacon', + _messages_truncated: true, + _messages_offset: 7, + messages: [{ role: 'assistant', content: 'stale-beacon-transcript' }], + message_count: 12, + tool_calls: [{ name: 'tool-beacon-stale' }], + }, +}; + +const API_BEACON_INFLIGHT_STATE = { + messages: [ + { + role: 'assistant', + content: 'beacon-inflight-tail', + _live: true, + }, + ], + uploaded: [], + toolCalls: [{ name: 'tool-beacon-inflight' }], +}; + +const API_ATLAS_META = { + session: { + session_id: 'sid-atlas', + message_count: 21, + active_stream_id: null, + resolve_model: 'qwen/qwq-32b-instruct', + }, +}; + +const API_ATLAS_MSGS = { + session: { + session_id: 'sid-atlas', + _messages_truncated: false, + _messages_offset: 98, + messages: [{ role: 'assistant', content: 'new-active-transcript' }], + message_count: 21, + tool_calls: [{ name: 'tool-atlas' }], + }, +}; + +const API_ATLAS_RELOAD_META = { + session: { + session_id: 'sid-atlas', + message_count: 31, + active_stream_id: null, + resolve_model: 'qwen/qwq-32b-instruct', + }, +}; + +const API_ATLAS_RELOAD_MSGS = { + session: { + session_id: 'sid-atlas', + _messages_truncated: true, + _messages_offset: 33, + messages: [{ role: 'assistant', content: 'reloaded-active-transcript' }], + message_count: 31, + tool_calls: [{ name: 'tool-atlas-new' }], + }, +}; + +function buildMessageUrl(sid, mode, suffix='') { + const base = `/api/session?session_id=${encodeURIComponent(sid)}&messages=${mode}&resolve_model=0`; + if (mode === 0) return base; + return `${base}&msg_limit=${_messageReloadLimitForSession()}&expand_renderable=1${suffix}`; +} + +function makeCrossSessionCalls(apiHost) { + return { + beaconMeta: apiHost.enqueue(buildMessageUrl('sid-beacon', 0)), + beaconMsgs: apiHost.enqueue(buildMessageUrl('sid-beacon', 1)), + atlasMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)), + atlasMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)), + }; +} + +function runCrossSessionOrderingBase({seedBeaconInflight, resolveBeaconMsgsBeforeAtlasMeta}) { + createEnvironment(); + if (seedBeaconInflight) { + INFLIGHT['sid-beacon'] = JSON.parse(JSON.stringify(API_BEACON_INFLIGHT_STATE)); + } + + const apiHost = makeHarness(); + globalThis.apiHost = apiHost; + globalThis.api = apiHost.api; + + const calls = makeCrossSessionCalls(apiHost); + + const first = loadSession('sid-beacon', { force: true }); + return (async () => { + await waitForQueued(apiHost, calls.beaconMeta.url); + calls.beaconMeta._resolve(API_BEACON_META); + + await waitForQueued(apiHost, calls.beaconMsgs.url); + const second = loadSession('sid-atlas', { force: true }); + await waitForQueued(apiHost, calls.atlasMeta.url); + + if (resolveBeaconMsgsBeforeAtlasMeta) { + calls.beaconMsgs._resolve(API_BEACON_MSGS); + // Wait for the stale first load continuation to process so we can continue the + // Atlas path from a clearly stale state. + await first; + calls.atlasMeta._resolve(API_ATLAS_META); + await waitForQueued(apiHost, calls.atlasMsgs.url); + } else { + calls.atlasMeta._resolve(API_ATLAS_META); + await waitForQueued(apiHost, calls.atlasMsgs.url); + calls.beaconMsgs._resolve(API_BEACON_MSGS); + } + + calls.atlasMsgs._resolve(API_ATLAS_MSGS); + await Promise.all([first, second]); + + return { + finalSid: S.session && S.session.session_id, + messages: snapshotState().messages, + toolCalls: snapshotState().toolCalls, + truncated: snapshotState().truncated, + oldestIdx: snapshotState().oldestIdx, + msgInner: snapshotState().msgInner, + toastCalls: snapshotState().toastCalls, + apiCalls: snapshotState().apiCalls, + loadingSid: snapshotState().loadingSid, + loadingGeneration: snapshotState().loadingGeneration, + rearmCalls: snapshotState().rearmCalls, + }; + })(); +} + +async function runCrossSessionOrdering() { + return { + scenario: 'cross-session-ordering', + ...(await runCrossSessionOrderingBase({ seedBeaconInflight: true, resolveBeaconMsgsBeforeAtlasMeta: false })), + }; +} + +async function runObservedIdleCrossSessionOrdering() { + return { + scenario: 'observed-idle-cross-session-ordering', + ...(await runCrossSessionOrderingBase({ seedBeaconInflight: false, resolveBeaconMsgsBeforeAtlasMeta: true })), + }; +} + +async function runStaleRejectedIdleCatch() { + createEnvironment(); + const apiHost = makeHarness(); + globalThis.apiHost = apiHost; + globalThis.api = apiHost.api; + + S.session = { session_id: 'sid-atlas', message_count: 0 }; + + const calls = { + firstMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)), + firstMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)), + secondMeta: apiHost.enqueue(buildMessageUrl('sid-atlas', 0)), + secondMsgs: apiHost.enqueue(buildMessageUrl('sid-atlas', 1)), + }; + + const first = loadSession('sid-atlas', { force: true }); + calls.firstMeta._resolve(API_ATLAS_META); + + // Ensure the first load has entered the messages fetch and owns the pending API + // call before the superseding same-session load begins. + await waitForQueued(apiHost, calls.firstMsgs.url); + + const second = loadSession('sid-atlas', { force: true }); + + // The stale first request rejects while the second newer request is in flight. + calls.firstMsgs._reject(new Error('owner lost while load was in-flight')); + calls.secondMeta._resolve(API_ATLAS_RELOAD_META); + calls.secondMsgs._resolve(API_ATLAS_RELOAD_MSGS); + + await Promise.all([first, second]); + + return { + scenario: 'stale-idle-catch', + finalSid: S.session && S.session.session_id, + messages: snapshotState().messages, + toolCalls: snapshotState().toolCalls, + truncated: snapshotState().truncated, + oldestIdx: snapshotState().oldestIdx, + msgInner: snapshotState().msgInner, + toastCalls: snapshotState().toastCalls, + apiCalls: snapshotState().apiCalls, + loadingSid: snapshotState().loadingSid, + loadingGeneration: snapshotState().loadingGeneration, + rearmCalls: snapshotState().rearmCalls, + }; +} + +async function runAll() { + return { + crossSessionOrdering: await runCrossSessionOrdering(), + observedIdleCrossSessionOrdering: await runObservedIdleCrossSessionOrdering(), + staleIdleCatch: await runStaleRejectedIdleCatch(), + }; +} + +runAll() + .then((r) => console.log(JSON.stringify(r))) + .catch((err) => { + console.error('NODE_ERROR', err && err.stack || err); + process.exit(1); + }); +''' + + +def _run_node(script: str) -> dict: + assert NODE is not None, "node is required" + completed = subprocess.run( + [NODE, "--input-type=module", "-e", script], + cwd=str(REPO), + text=True, + capture_output=True, + timeout=60, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + output_lines = [line.strip() for line in completed.stdout.splitlines() if line.strip()] + assert output_lines, f"node produced no parseable output\nstdout={completed.stdout}\nstderr={completed.stderr}" + return json.loads(output_lines[-1]) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_loadsession_cross_session_ordering_and_stale_reject_behavior(): + script = _NODE_SCRIPT_TEMPLATE.replace( + "__LOAD_SESSION_SRC__", LOAD_SESSION_SRC + ).replace( + "__ENSURE_MESSAGES_LOADED_SRC__", ENSURE_MESSAGES_LOADED_SRC + ) + body = _run_node(script) + + cross = body["crossSessionOrdering"] + stale = body["staleIdleCatch"] + observed = body["observedIdleCrossSessionOrdering"] + + def _assert_atlas_wins(session_result, *, label): + assert session_result["finalSid"] == "sid-atlas", f"{label}: stale overlap should end on Atlas session" + assert session_result["messages"] == ["new-active-transcript"], ( + f"{label}: stale Beacon transcript must not replace Atlas transcript" + ) + assert session_result["toolCalls"] == [{"name": "tool-atlas", "done": True}], ( + f"{label}: Atlas tool summary must apply on fresh load" + ) + assert session_result["truncated"] is False and session_result["oldestIdx"] == 98, ( + f"{label}: Atlas metadata should remain the active state" + ) + + # 1) Cross-session ordering: old (Beacon) loads first, but user advances to Atlas. + assert cross["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", ( + "first API call should target old session's metadata" + ) + assert cross["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", ( + "beacon transcript request should queue before atlas metadata resolves" + ) + assert cross["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", ( + "second API call should target atlas metadata while stale beacon messages are in flight" + ) + assert cross["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", ( + "atlas should still fetch a transcript while beacon was stale" + ) + assert cross["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, ( + "stale overlap should still issue the Beacon transcript call, but it must not win" + ) + _assert_atlas_wins(cross, label="cross-session-ordering") + + # 2) Observed idle-path race with no INFLIGHT: stale Beacon transcript returns + # before Atlas metadata, but ownership guard must still force Atlas fetch+swap. + assert observed["apiCalls"][0] == "/api/session?session_id=sid-beacon&messages=0&resolve_model=0", ( + "idle-path race should start from old Beacon metadata" + ) + assert observed["apiCalls"][1] == "/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", ( + "Beacon transcript call should remain queued before Atlas metadata under observed race" + ) + assert observed["apiCalls"][2] == "/api/session?session_id=sid-atlas&messages=0&resolve_model=0", ( + "Atlas metadata must start while Beacon continuation returns stale" + ) + assert observed["apiCalls"][3] == "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1", ( + "Atlas transcript request must still issue despite stale Beacon return" + ) + assert observed["apiCalls"].count("/api/session?session_id=sid-beacon&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, ( + "stale Beacon transcript should occur once in observed race" + ) + assert observed["apiCalls"].count("/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1") == 1, ( + "Atlas transcript must be issued once once stale Beacon is processed first" + ) + _assert_atlas_wins(observed, label="observed-idle-cross-session-ordering") + assert observed["toastCalls"] == [], "stale Beacon return in idle-path race should not show toast" + + # 3) Stale rejected idle-branch catch must be ownership-guarded and not mutate shared pane. + assert stale["messages"] == ["reloaded-active-transcript"], "stale catch must not keep stale transcript" + assert stale["toolCalls"] == [{"name": "tool-atlas-new", "done": True}], "stale catch must not overwrite tool state" + assert stale["truncated"] is True and stale["oldestIdx"] == 33, "active owner should install latest metadata" + assert stale["msgInner"] == "INIT_LOADING", ( + "stale reject from superseded load must not write failure placeholder" + ) + assert stale["toastCalls"] == [], "stale reject must not surface toast for superseded load" + assert stale["apiCalls"].count( + "/api/session?session_id=sid-atlas&messages=1&resolve_model=0&msg_limit=2&expand_renderable=1" + ) == 2, "both old and active loads should have attempted message fetch" + + assert cross["loadingSid"] is None, "load marker should be cleared after successful completion" + assert stale["loadingSid"] is None, "load marker should be cleared after stale reject + re-owner completion" diff --git a/tests/test_issue3993_session_load_self_heal.py b/tests/test_issue3993_session_load_self_heal.py index 1e2213a4..c9c4318d 100644 --- a/tests/test_issue3993_session_load_self_heal.py +++ b/tests/test_issue3993_session_load_self_heal.py @@ -88,7 +88,7 @@ def test_stale_load_guard_present_before_self_heal(): # loadSession catch block for the stale-load guard. heal_idx = js.index("_clearStuckSessionOnBoot(sid, currentSid);") block = js[heal_idx - 2400: heal_idx + 60] - guard = "if (_loadingSessionId !== sid) {" + guard = "if (!_isCurrentLoad()) {" assert guard in block, "stale-load guard missing from the loadSession catch block" # The guard must come BEFORE the self-heal call (so a superseded load can't clear). assert block.index(guard) < block.index("_clearStuckSessionOnBoot(sid, currentSid);"), \ diff --git a/tests/test_issue5177_hidden_tab_blank_gap.py b/tests/test_issue5177_hidden_tab_blank_gap.py index 4a0bc8cb..64b6eaeb 100644 --- a/tests/test_issue5177_hidden_tab_blank_gap.py +++ b/tests/test_issue5177_hidden_tab_blank_gap.py @@ -78,6 +78,27 @@ def _refresh_block(compact: str) -> str: raise AssertionError("refreshActiveSessionIfExternallyUpdated braces did not balance") +def _ensure_messages_loaded_block(compact: str) -> str: + marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){" + marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){" + start = compact.find(marker_named) + if start == -1: + start = compact.find(marker_arglist) + assert start != -1, "_ensureMessagesLoaded definition not found" + i = compact.find("{", start) + assert i != -1, "_ensureMessagesLoaded opening brace not found" + depth = 0 + for j in range(i, len(compact)): + c = compact[j] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return compact[start : j + 1] + raise AssertionError("_ensureMessagesLoaded braces did not balance") + + def test_keep_stale_until_loaded_flag_computed_in_loadsession(): # The flag must be computed by AND-ing the caller's opts with # sameSessionForceReload — cross-session switches MUST keep clearing @@ -137,7 +158,7 @@ def test_ensure_messages_loaded_called_with_keep_stale_flag(): # cannot skip the swap when stale messages are still in place. block = _load_session_block(_compact(SESSIONS_JS)) # Both INFLIGHT and idle paths. - assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded})") == 2 + assert block.count("await_ensureMessagesLoaded(sid,{force:_keepStaleUntilLoaded,loadGeneration:_loadGeneration})") == 2 def test_ensure_messages_loaded_supports_force_override(): @@ -150,14 +171,7 @@ def test_ensure_messages_loaded_supports_force_override(): # greptile P2 r3393… on #5189) and the historical `arguments[1]` shape # (for callers preserving an existing public signature). The required # invariant is the EARLY-RETURN being gated on opts.force. - marker_named = "asyncfunction_ensureMessagesLoaded(sid,opts){" - marker_arglist = "asyncfunction_ensureMessagesLoaded(sid){" - start = compact.find(marker_named) - if start == -1: - start = compact.find(marker_arglist) - assert start != -1, "_ensureMessagesLoaded definition not found" - # Take a generously-sized window for the early-return region. - region = compact[start: start + 1000] + region = _ensure_messages_loaded_block(compact) # Either explicit named param `opts` (preferred), or arguments[1] fallback, # both must coerce to an object so opts.force is safe to read. assert ( diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 79eb9113..b0446809 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -519,7 +519,7 @@ def test_completion_unread_clears_only_when_session_is_opened(): assert load_idx != -1, "loadSession not found" load_block = SESSIONS_JS[load_idx:SESSIONS_JS.find("function _resolveSessionModelForDisplaySoon", load_idx)] - stale_guard_idx = load_block.find("if (_loadingSessionId !== sid) return;") + stale_guard_idx = load_block.find("if (!_isCurrentLoad()) return;") clear_idx = load_block.find("_clearSessionCompletionUnread(S.session.session_id);") set_viewed_idx = load_block.find("_setSessionViewedCount(S.session.session_id") diff --git a/tests/test_issue_new_chat_draft_restore.py b/tests/test_issue_new_chat_draft_restore.py index 7f6c7436..800ce779 100644 --- a/tests/test_issue_new_chat_draft_restore.py +++ b/tests/test_issue_new_chat_draft_restore.py @@ -124,12 +124,12 @@ def test_pre_switch_draft_flush_rechecks_stale_loading_guard(): """The awaited draft-save in loadSession yields the event loop. On a rapid session switch (B then quickly C) the stale B continuation must bail out before the destructive state-clearing block, or it would wipe the - freshly-loaded C state. The guard is `if (_loadingSessionId !== sid) return;` + freshly-loaded C state. The guard is `if (!_isCurrentLoad()) return;` placed AFTER the awaited save and BEFORE the `S.messages = []` clear (Codex pre-release CORE catch, #3471).""" body = _load_session_clear_block() await_idx = body.find("await _saveComposerDraftNow(currentSid") - guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx) + guard_idx = body.find("if (!_isCurrentLoad()) return;", await_idx) clear_idx = body.find("S.messages = [];", await_idx) assert await_idx != -1, "pre-switch awaited draft save not found" assert guard_idx != -1, "stale-loading guard missing after the awaited draft save"