fix(ui): prevent OOM on tab focus for long sessions (#7006)
* fix(ui): prevent OOM on tab focus for long sessions Coordinate visibility-recovery and session-updated refresh paths so they never start concurrent full-transcript loads, window the O(N) pre-render scans to the virtual render window, and bound the render-window expansion. Closes #6999 * fix(ui): full turn context + collision-free cache hash (#7006 review rework) Addresses the maintainer's CHANGES_REQUESTED on PR #7006: - Turn-context maps (_assistantTurnFinalVisibleContentMap / _assistantTurnVisibleContentMap) receive the FULL visWithIdx again. The virtualized render window (head+tail around a gap) cut through assistant runs and merged distinct turns across an omitted user boundary, which could duplicate the final answer in Worklog/Thinking or feed later-turn prose into the reasoning echo-strip. Turn context is input, not output: it must always be complete. - _addBoundedHash now streams the FULL string form through the FNV-1a loop (no length+head+tail clip, no slice copies), and object fields (attachments, tc.args, compression-anchor keys) are walked key-by-key so no integral JSON.stringify() is allocated. Same-length middle-only edits of tool args, attachment metadata, tool snippets, or compression-anchor keys no longer collide in _sessionHtmlCache. Regression tests: - tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn with a virtualized gap proves full-context echo-strip suppresses the exact final-answer echo, and documents the gapped head+tail failure. - tests/test_issue2613_render_cache_signature.py: Node harness executing the real _messageRenderCacheSignature() proves same-length middle-only mutations change the signature for every structured field while the old clip scheme collides on the same data. Closes #6999 * fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash Re-gate round 2 for #6999: - session-updated frames arriving while the external-refresh guard is held are latched per SID (max announced count) and drained by the refresh owner's finally with ONE guarded follow-up, instead of being dropped (production does not guarantee a second event). - _hashObjectInto walks keys in insertion order (matching Object.entries render projection) with an explicit per-key index discriminator, so opposite-insertion-order argument objects no longer share a cache signature; recursion depth is now threaded (children increment, not restart). * test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness #7006 adds a reference to the pre-existing module var _loadingSessionId and a new _drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated. The jump-to-answer scroll-settle tests extract that function into a node harness, which didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs to the harness preambles (no-op drain, since these tests exercise scroll ownership, not the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py). Test-harness-only; production static/ untouched. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
This commit is contained in:
7 files changed
+914
-7
No files matched your search
+16
-1
@@ -8022,6 +8022,20 @@ function startSessionStream(sid) {
|
||||
// the hidden-tab return (loadSession force + keepStaleUntilLoaded): the new
|
||||
// transcript replaces the old in a single render frame — NO clear+refetch,
|
||||
// so the #5177/#5189 blank-gap "jump" is not reintroduced.
|
||||
// #6999: focusing a backgrounded tab also fires the visibility-recovery
|
||||
// probe in sessions.js (refreshActiveSessionIfExternallyUpdated), which
|
||||
// holds the shared _activeSessionExternalRefreshInFlight guard while it
|
||||
// probes + force-reloads this same session. This handler honors that guard
|
||||
// so the two paths never start two concurrent loadSession(force) calls
|
||||
// (double full-transcript fetch + double renderMessages pass = the OOM
|
||||
// pattern on long sessions). The probe side carries its own
|
||||
// _loadingSessionId guard; loadSession() keeps its legitimate
|
||||
// newest-wins supersede semantics untouched.
|
||||
// #6999 re-gate: while the probe owns the guard, frames are COALESCED via
|
||||
// _coalesceSessionUpdatedWhileRefreshHeld — the max announced count is
|
||||
// latched per SID and the owner's finally runs ONE guarded follow-up when
|
||||
// local state is still behind (a bare return dropped the update; production
|
||||
// does not guarantee a second event).
|
||||
es.addEventListener('session-updated', e => {
|
||||
try {
|
||||
const d = JSON.parse(e.data || '{}');
|
||||
@@ -8034,12 +8048,13 @@ function startSessionStream(sid) {
|
||||
: (S.session && S.session.session_id === sid);
|
||||
if (!isCurrent) return;
|
||||
if (S.activeStreamId) return;
|
||||
const serverCount = Number(d.message_count);
|
||||
if (typeof _coalesceSessionUpdatedWhileRefreshHeld === 'function' && _coalesceSessionUpdatedWhileRefreshHeld(sid, serverCount)) return;
|
||||
// Re-check against our CURRENT known count — a concurrent load may have
|
||||
// already caught us up between the server's emit and now.
|
||||
const localCount = (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count)))
|
||||
? Number(S.session.message_count)
|
||||
: (Array.isArray(S.messages) ? S.messages.length : 0);
|
||||
const serverCount = Number(d.message_count);
|
||||
if (!Number.isFinite(serverCount) || serverCount <= localCount) return;
|
||||
if (typeof loadSession === 'function') {
|
||||
void loadSession(sid, {force: true, externalRefreshReason: 'session-updated', keepStaleUntilLoaded: true});
|
||||
|
||||
Reference in new issue
Block a user