diff --git a/static/ui.js b/static/ui.js index fd8bc8a8..a979ff30 100644 --- a/static/ui.js +++ b/static/ui.js @@ -556,7 +556,24 @@ function _messageVirtualDefaultHeightForRole(role){ role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default' ]; } -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2; +// Cycle-aware measurement burst tracking (#6654/#6717): instead of a flat +// render cap, the burst remembers every window cycle key it has already seen. +// An UNSEEN key means the window is still converging forward (A->B->C->settled +// — content reflow, late fonts/images, dynamic height) and may proceed; a key +// that REPEATS means the browser is oscillating (A->B->A->B) and the burst +// terminates. Keys repeat only when geometry flaps, so legitimate convergence +// is never capped while oscillation is always bounded. +// Absolute per-burst ceiling (#6717 re-gate): the seen-key rule alone only +// ends the burst on a REPEATED key, so a browser emitting a monotonically +// changing geometry (A->B->C->D->... never repeating) would still loop without +// limit — the #6654 CPU-runaway class under a different trigger — and the +// seen-key collection would grow without bound (memory). A distinct-key +// convergence realistically settles in a handful of frames, so this +// conservative cap (the historical MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS +// was 2) ends the burst regardless of key novelty AND bounds the seen-key +// memory to the cap. Reset through _resetMessageVirtualMeasurementBurst(). +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=12; +let _messageVirtualMeasurementSeenKeys=[]; let _messageRenderWindowSid=null; let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; let _messageVirtualHeightCache=[]; @@ -567,7 +584,19 @@ let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('defau let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; -let _messageVirtualMeasurementRetryCount=0; +let _messageVirtualMeasurementBurstActive=false; +// Provenance of the QUEUED virtualized render: 'internal' while the pending +// rAF was requested by the internal measurement chain, 'external' for any +// other trigger (user scroll / scroll-settle / message append / session +// load). The origin lives on the QUEUED render itself, NOT in a global +// consumable flag: when an internal and an external request coalesce into one +// rAF, _scheduleMessageVirtualizedRender lets EXTERNAL win, so a render that +// fires after any external trigger is never mis-attributed as internal +// (#6717 re-gate). renderMessages() resets the burst on every UNMARKED +// (externally initiated) render trigger, so an overlapping external render +// always starts a fresh cycle even when an internal measurement callback is +// still pending. +let _messageVirtualRenderQueuedOrigin=null; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -614,7 +643,7 @@ function _clearMessageVirtualHeightCache(){ _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default'); _messageVirtualWindowKey=''; _messageVirtualMeasurementCycleKey=''; - _messageVirtualMeasurementRetryCount=0; + _resetMessageVirtualMeasurementBurst(); _messageVirtualScrollActive=false; clearTimeout(_messageVirtualScrollSettleTimer); _messageVirtualScrollSettleTimer=0; @@ -742,6 +771,13 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){ windowMetrics.tailStart||0, ].join(':'); } +function _resetMessageVirtualMeasurementBurst(){ + _messageVirtualMeasurementSeenKeys=[]; + _messageVirtualMeasurementBurstActive=false; + // Strip any pending internal provenance: an external reset must never let + // an internal marker survive onto a render that fires later (#6717 re-gate). + _messageVirtualRenderQueuedOrigin=null; +} function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualScrollActive){ _messageVirtualDeferredMeasurement=windowMetrics; @@ -750,15 +786,55 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; - _messageVirtualMeasurementRetryCount=0; } - if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; - _messageVirtualMeasurementRetryCount++; - requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); + // Cycle-aware burst tracking (#6717 re-gate): the burst follows ONLY the + // internally measurement-scheduled chain (requestAnimationFrame -> + // _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here). + // Within one burst we remember every cycle key already seen. An UNSEEN key + // (genuine forward convergence A->B->C->settled — content reflow, late + // fonts/images, dynamic height) may proceed, so convergence is never capped + // at a flat render count; a key that REPEATS (WebKit's A->B->A->B window + // oscillation) ends the burst, so the rAF/measure loop can never run forever + // (#6654). The burst starts fresh on every EXTERNALLY initiated render + // (session load, message append, real content change — see renderMessages) + // and on measurement settlement — never on a cycle-key change alone. + if(!_messageVirtualMeasurementBurstActive){ + _messageVirtualMeasurementSeenKeys=[]; + _messageVirtualMeasurementBurstActive=true; + } + // Absolute per-burst cap (#6717 re-gate): even an ALL-DISTINCT monotonic + // key sequence (A->B->C->D->... never repeating) must terminate — a repeated + // key is not the only way the burst can end. This also bounds the seen-key + // collection to the cap (memory). Distinct-key convergence settles in a + // handful of frames, so the cap is far above any legitimate multi-pass + // reflow while still closing the #6654 CPU-runaway class. + if(_messageVirtualMeasurementSeenKeys.length>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){ + _resetMessageVirtualMeasurementBurst(); + return; + } + const lastKey = _messageVirtualMeasurementSeenKeys.length ? _messageVirtualMeasurementSeenKeys[_messageVirtualMeasurementSeenKeys.length - 1] : null; + if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){ + // Non-consecutive repeated key (A->B->A): the window is oscillating, not + // converging. End the burst (no further internal re-render is scheduled), + // so the next externally initiated cycle starts fresh instead of being + // starved of retries. A consecutive same-key pass (A->A) proceeds because + // heights changed while the window bounds did not (e.g. row shrink across + // two passes), still bounded by the absolute per-burst cap (#6717 re-gate). + _resetMessageVirtualMeasurementBurst(); + return; + } + _messageVirtualMeasurementSeenKeys.push(cycleKey); + // The internal-measurement origin travels WITH the scheduled render request + // (threaded through BOTH rAF layers into renderMessages), so coalescing can + // never consume a stale global marker onto an unrelated render. When an + // external request coalesces into the queued rAF, EXTERNAL wins and the + // render that fires degrades to an unmarked (burst-resetting) render + // (#6717 re-gate). + requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true,{origin:'internal'}); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); - _messageVirtualMeasurementRetryCount=0; + _resetMessageVirtualMeasurementBurst(); } function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ return !!( @@ -1474,7 +1550,7 @@ function _rememberRenderedUserRowIntrinsicHeights(){ } } } -function _scheduleMessageVirtualizedRender(force){ +function _scheduleMessageVirtualizedRender(force, request){ const container=$('messages'); const inner=$('msgInner'); if(!container||!inner) return; @@ -1486,9 +1562,28 @@ function _scheduleMessageVirtualizedRender(force){ _messageVirtualWindowKey=nextKey; return; } - if(_messageVirtualScrollRaf) return; + // The request carries its own origin: the internal measurement chain passes + // {origin:'internal'} (see _scheduleMessageVirtualMeasurementRefresh); + // every other caller is external by default. + const requestOrigin=(request&&request.origin==='internal')?'internal':'external'; + if(_messageVirtualScrollRaf){ + // Coalescing into an already-queued rAF: the queued render is shared, so + // merge the provenance. EXTERNAL always wins — the render that actually + // fires must reset the burst, and an internal marker must never survive + // onto an external render (#6717 re-gate). An internal request joining a + // queued render never downgrades an already-external one. + if(requestOrigin==='external') _messageVirtualRenderQueuedOrigin='external'; + return; + } + _messageVirtualRenderQueuedOrigin=requestOrigin; _messageVirtualScrollRaf=requestAnimationFrame(()=>{ _messageVirtualScrollRaf=0; + // The provenance belongs to THIS queued render: it was set when the render + // was scheduled (and possibly flipped to 'external' by a coalesced + // external request). Consume it here — no global consumable flag that an + // unrelated render could steal (#6717 re-gate). + const internalMeasurement=_messageVirtualRenderQueuedOrigin==='internal'; + _messageVirtualRenderQueuedOrigin=null; const liveVisWithIdx=_getVisibleMessagesWithIdx(); const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount()); const liveKey=_messageVirtualWindowKeyFor(liveWindow); @@ -1496,14 +1591,14 @@ function _scheduleMessageVirtualizedRender(force){ if(_scrollbarDragActive){ _programmaticScroll=true; _programmaticScrollSetAt=performance.now(); - _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); }); + _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); }); _deferClearProgrammaticScroll(); _messageVirtualWindowKey=liveKey; return; } _msgNodeRecycleEnabled=true; try{ - _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); }); + _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); }); } finally{ _msgNodeRecycleEnabled=false; } }); @@ -17216,6 +17311,8 @@ function _insertSegmentBlock(seg, html){ } function renderMessages(options){ _lastMessageRenderAt=performance.now(); + // typeof guard: node harnesses extract renderMessages() without its helpers (#6717). + if(!(options&&options._internalMeasurement) && typeof _resetMessageVirtualMeasurementBurst==='function'){ _resetMessageVirtualMeasurementBurst(); } const preserveScroll=!!(options&&options.preserveScroll); const virtualFallback=!!(options&&options._virtualFallback); // Capture the pre-wipe scroll position when preserving OR when the reader has diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index 6e61dd2b..6d29d039 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -679,6 +679,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds function _scrollAfterMessageRender() {{}} function _maybeRecoverVirtualizedBlankViewport() {{ return false; }} function _updateMessageVirtualMeasurements() {{}} + function _resetMessageVirtualMeasurementBurst() {{}} function postProcessRenderedMessages() {{}} function _postProcessWithAnchorSuppression() {{}} function _formatGatewayModelLabel() {{ return ''; }} diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 12d14185..5448a907 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -99,7 +99,7 @@ def test_js_recycle_flag_lifecycle(): """_scheduleMessageVirtualizedRender sets _msgNodeRecycleEnabled=true before the compensate call and clears it in finally.""" fn_match = re.search( - r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )', + r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )', JS, re.DOTALL | re.MULTILINE ) assert fn_match, "_scheduleMessageVirtualizedRender not found" @@ -1101,7 +1101,7 @@ class TestScrollbarDragRenderDuringDrag: """_scheduleMessageVirtualizedRender must have a _scrollbarDragActive branch that runs a full render with scroll compensation.""" fn_match = re.search( - r'function _scheduleMessageVirtualizedRender\(force\)\{(.+?)^(?=function )', + r'function _scheduleMessageVirtualizedRender\(force(?:, request)?\)\{(.+?)^(?=function )', JS, re.DOTALL | re.MULTILINE ) assert fn_match, "_scheduleMessageVirtualizedRender not found" diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 6994c36b..aa130c00 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1,5 +1,6 @@ """Regression coverage for issue #500 transcript virtualization.""" import json +import re import shutil import subprocess import tempfile @@ -1345,7 +1346,9 @@ let _messageVirtualHeightCacheSrc = {}; let _messageVirtualEstimatedRowHeight = 200; let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; -let _messageVirtualMeasurementRetryCount = 3; +let _messageVirtualMeasurementSeenKeys = ['stale-key']; +let _messageVirtualMeasurementBurstActive = true; +let _messageVirtualRenderQueuedOrigin = 'internal'; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; // #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache // resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness @@ -1353,6 +1356,7 @@ const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140}; function clearTimeout(id){ timerCleared = (id === 99); } eval(extractFunc('_messageVirtualDefaultHeightForRole')); +eval(extractFunc('_resetMessageVirtualMeasurementBurst')); eval(extractFunc('_clearMessageVirtualHeightCache')); _clearMessageVirtualHeightCache(); console.log(JSON.stringify({ @@ -1360,6 +1364,9 @@ console.log(JSON.stringify({ settleTimer: _messageVirtualScrollSettleTimer, deferred: _messageVirtualDeferredMeasurement, timerCleared: timerCleared, + seenKeys: _messageVirtualMeasurementSeenKeys, + burstActive: _messageVirtualMeasurementBurstActive, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """ metrics = json.loads(_run_node(source)) @@ -1375,3 +1382,425 @@ console.log(JSON.stringify({ assert metrics["timerCleared"] is True, ( "_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer" ) + assert metrics["seenKeys"] == [], ( + "_clearMessageVirtualHeightCache must clear the measurement-burst seen-key " + f"memory: seenKeys {metrics['seenKeys']}" + ) + assert metrics["burstActive"] is False, ( + "_clearMessageVirtualHeightCache must end any active measurement burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["queuedOrigin"] is None, ( + "_clearMessageVirtualHeightCache must clear any queued internal-measurement " + f"provenance: queuedOrigin {metrics['queuedOrigin']}" + ) + + +def _measurement_burst_harness(scenario_js: str) -> str: + """Shared queued-rAF harness for the measurement-burst chain (#6654/#6717). + + Drives the REAL production chain: the cycle-aware scheduler queues a first + rAF, _scheduleMessageVirtualizedRender queues a second rAF (the + internal-measurement provenance marker is threaded through BOTH layers), + and the renderMessages stub applies the production burst-reset contract for + every unmarked (external) render, then simulates the measure pass from the + caller-provided plan (measurePlan). Unlike the earlier synchronous-rAF + harnesses, external renders can land BETWEEN queued rAF callbacks — exactly + where the overlap/convergence regressions lived. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + cap_match = re.search( + r"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS\s*=\s*(\d+)", js + ) + assert cap_match, ( + "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS must be defined in ui.js " + "(absolute per-burst cap, #6717 re-gate)" + ) + cap = int(cap_match.group(1)) + return _extract_func_script(js) + ( + "// Absolute per-burst ceiling, read from production ui.js (#6717 re-gate).\n" + f"const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = {cap};\n" + ) + """ +// ── queued-rAF harness ──────────────────────────────────────────────────────── +let renderLog = []; // every renderMessages call: {internal, preserveScroll} +let rafQueue = []; +function requestAnimationFrame(cb){ rafQueue.push(cb); return rafQueue.length; } +function flushRaf(){ const q = rafQueue; rafQueue = []; for (const cb of q) cb(); } +let _messageVirtualScrollRaf = 0; +let _messageVirtualWindowKey = ''; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementSeenKeys = []; +let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualRenderQueuedOrigin = null; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +let _scrollbarDragActive = false; +let _msgNodeRecycleEnabled = false; +let _programmaticScroll = false; +let _programmaticScrollSetAt = 0; +function _deferClearProgrammaticScroll(){} +function $(id){ return {}; } +function _getVisibleMessagesWithIdx(){ return []; } +function _messageVirtualKeepTailCount(){ return 0; } +function _currentMessageVirtualWindow(){ + return { virtualized:true, start:0, end:10, topPad:0, bottomPad:0, tailStart:10, total:20 }; +} +function _messageVirtualMeasurementCycleKeyFor(w){ return (w && w.key) || ''; } +function _compensateScrollForMeasurementDelta(fn){ return fn(); } +// Simulated measure pass: after each render, the plan yields the next measured +// window cycle key; undefined means the measurements settled (unchanged). +let measurePlan = []; +let measureIdx = 0; +function renderMessages(options){ + const internal = !!(options && options._internalMeasurement); + renderLog.push({ internal: internal, preserveScroll: !!(options && options.preserveScroll) }); + // Contract of production renderMessages(): every UNMARKED (external) render + // trigger starts a fresh measurement burst, overriding any pending internal + // callback. + if(!internal) _resetMessageVirtualMeasurementBurst(); + const next = measurePlan[measureIdx++]; + if(next === undefined){ + _markMessageVirtualMeasurementsSettled({ key: 'settled' }); + }else{ + _scheduleMessageVirtualMeasurementRefresh({ key: next }); + } +} +// Production functions under test. +eval(extractFunc('_resetMessageVirtualMeasurementBurst')); +eval(extractFunc('_messageVirtualWindowKeyFor')); +eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); +eval(extractFunc('_scheduleMessageVirtualizedRender')); +eval(extractFunc('_markMessageVirtualMeasurementsSettled')); +""" + scenario_js + + +def test_oscillating_cycle_keys_terminate_burst_with_queued_raf(): + """#6654/#6717 re-gate (c): a repeated oscillating window key (A->B->A->B) + must terminate the measurement burst even though every key was unseen on + first sight — the burst tracks SEEN keys, so geometry that flaps between + two states can never renew the chain and keep the rAF/measure loop alive + (~30% WebContent CPU, issue #6654). The queued-rAF harness drives the + oscillation through the real two-rAF-layer chain.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','A','B','A','B','A']; +renderMessages({}); // external trigger: measures B -> burst starts +flushRaf(); flushRaf(); // layer-1 then layer-2 rAF -> internal render (A) +flushRaf(); flushRaf(); // next cycle -> internal render (B repeats) -> burst ends +flushRaf(); flushRaf(); // nothing left queued +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["internal"] == 2, ( + "A->B->A->B oscillation must stay bounded: a repeated key ends the burst. " + f"scheduled {metrics['internal']} internal re-renders (expected 2, then termination)" + ) + assert metrics["renders"] == 3, ( + "oscillation must not keep scheduling renders: " + f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)" + ) + assert metrics["burstActive"] is False, ( + "the burst must end once the repeated key is detected so the next " + f"externally initiated cycle starts fresh: burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + "termination must clear the seen-key memory: " + f"seenKeys {metrics['seenKeys']}" + ) + assert metrics["queuedOrigin"] is None, ( + "no internal render may remain queued after termination: " + f"queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_consecutive_same_key_row_shrink_converges_to_settled(): + """#6717 re-gate: consecutive same-key passes (e.g. A->A->B->settled from + a row shrinking across two measurement passes with unchanged window bounds) + must NOT be cut short as oscillation. Only non-consecutive repeats (A->B->A) + represent cyclic oscillation; consecutive same-key passes continue toward + convergence while remaining bounded by the absolute cap.""" + source = _measurement_burst_harness(""" +measurePlan = ['A', 'A', 'B']; // R0 measures A, R1 internal measures A (consecutive), R2 internal measures B, then settled +renderMessages({}); // external trigger: measures A -> burst starts +flushRaf(); flushRaf(); // L1 -> L2: internal render measures A again (consecutive same key) -> must proceed! +flushRaf(); flushRaf(); // L1 -> L2: internal render measures B -> proceeds! +flushRaf(); flushRaf(); // L1 -> L2: internal render measures settled +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["internal"] == 3, ( + "consecutive same-key pass (row shrink) must NOT be cut short as oscillation: " + f"got {metrics['internal']} internal renders (expected 3)" + ) + assert metrics["renders"] == 4, ( + f"burst must converge to settled: {metrics['renders']} renders (expected 4 = 1 external + 3 internal)" + ) + assert metrics["burstActive"] is False, ( + f"burst must settle: burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + f"settlement must clear seen-key memory: seenKeys {metrics['seenKeys']}" + ) + + +def test_genuine_convergence_exceeding_two_renders_completes(): + """#6717 re-gate (b): a legitimate forward convergence A->B->C->settled + (content reflow, fonts/images loading late, dynamic height) must be allowed + MORE than two measurement passes. The old flat two-render burst cap left + the geometry mis-measured (wrong row heights / clipped content); cycle-aware + tracking lets every UNSEEN key proceed until the measurements settle.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','C','D']; // three distinct forward keys, then settled +renderMessages({}); // external trigger: measures B +flushRaf(); flushRaf(); // internal render: measures C +flushRaf(); flushRaf(); // internal render: measures D +flushRaf(); flushRaf(); // internal render: measurements settle +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + lastInternal: renderLog[renderLog.length - 1].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["internal"] == 3, ( + "genuine A->B->C->settled convergence must complete with MORE than the " + "old two-render cap: " + f"{metrics['internal']} internal re-renders (expected 3)" + ) + assert metrics["renders"] == 4, ( + "the convergence chain must run to completion: " + f"{metrics['renders']} renders (expected 4 = 1 external + 3 internal)" + ) + assert metrics["lastInternal"] is True, ( + "the final render of the convergence chain must still be part of the " + "internal measurement chain (settled via _markMessageVirtualMeasurementsSettled)" + ) + assert metrics["burstActive"] is False, ( + "settlement must end the burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_overlapping_external_append_receives_fresh_budget(): + """#6717 re-gate (a): an externally initiated render (message append, + scroll-settle, genuine content change) that lands while an internal + measurement callback is still PENDING in the rAF queue must reset the burst + — external provenance overrides a pending internal callback — so the new + cycle receives a fresh budget instead of being starved by the stale burst's + seen-key memory (0 retries). + + Phase 1: the append lands between the two rAF layers and measures key B + (already seen in the stale burst). Only the external reset lets B proceed; + without it the repeated key would terminate the append's cycle immediately. + The stale layer-2 callback (queued for the DEAD burst) must then degrade to + an UNMARKED render — the append's own measurement must not revive an + internal marker onto it, because the provenance belongs to the queued + render, not to a global flag (#6717 re-gate MUST-FIX 2) — and the fresh + cycle it starts completes with its own internal render, so the append is + never starved of retries. + Phase 2: an external render that measures SETTLED lands while a layer-2 + callback is pending; the stale callback must then degrade to an UNMARKED + render (the external reset consumed its provenance), and the fresh cycle it + starts must complete with its own internal render.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','B','C', undefined, 'E', undefined, 'F', undefined]; +renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued +flushRaf(); // L1 fires -> L2 (internal callback) now pending +renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires: + // resets the burst -> fresh cycle, seen=[B] again +flushRaf(); flushRaf(); // stale L2 fires UNMARKED (R2, external won), + // fresh cycle completes (R3 internal) -> settles +renderMessages({}); // R4 external: new cycle, L1 queued +flushRaf(); // L1 fires -> L2 (internal callback) now pending +renderMessages({}); // R5 external scroll-settle: resets the burst, + // measures SETTLED (no new schedule) +flushRaf(); // stale L2 fires -> marker consumed as FALSE -> + // R6 UNMARKED render (external provenance won) +flushRaf(); flushRaf(); // R6's fresh cycle completes (R7 internal) -> settles +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + appendExternal: renderLog[1].internal, + staleL2Unmarked: renderLog[2].internal, + postAppendCycleInternal: renderLog[3].internal, + staleCallbackUnmarked: renderLog[6].internal, + postResetCycleInternal: renderLog[7].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["renders"] == 8, ( + "the overlapping append must NOT starve the new cycle: the chain must " + "continue past the append to completion " + f"({metrics['renders']} renders; expected 8)" + ) + assert metrics["appendExternal"] is False, ( + "the append is an unmarked external render" + ) + assert metrics["staleL2Unmarked"] is False, ( + "the stale layer-2 callback (queued for the burst the append reset) " + "must degrade to an UNMARKED render: the append's own measurement " + "must not revive an internal marker onto it — provenance belongs to " + "the queued render, external wins (#6717 re-gate MUST-FIX 2)" + ) + assert metrics["postAppendCycleInternal"] is True, ( + "the fresh cycle after the degraded stale callback must complete with " + "its own internal render (fresh budget — the repeated key B was not " + "treated as oscillation because the external render reset the burst)" + ) + assert metrics["staleCallbackUnmarked"] is False, ( + "external provenance must override the pending internal callback: the " + "stale internal rAF callback must degrade to an unmarked render after " + "the external render reset the burst" + ) + assert metrics["postResetCycleInternal"] is True, ( + "the cycle started by the degraded stale callback must complete with " + "its own internal render (retries were not starved)" + ) + assert metrics["burstActive"] is False, ( + "the fresh cycles must end settled: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_all_distinct_keys_terminate_burst_with_bounded_storage(): + """#6717 re-gate MUST-FIX 1: an ALL-DISTINCT monotonic key sequence + (A->B->C->D->... never repeating) must still terminate. The seen-key rule + alone only ends the burst on a REPEATED key, so a browser emitting a + never-repeating geometry would loop without limit — the #6654 CPU-runaway + class under a different trigger — while _messageVirtualMeasurementSeenKeys + grows without bound (memory). The absolute per-burst cap ends the burst + regardless of key novelty and bounds the seen-key collection to the cap. + Driven through the real two-rAF-layer chain with MORE distinct keys than + the cap, and asserting the burst stops at exactly the cap with no rAF left + queued and the seen-key memory cleared.""" + source = _measurement_burst_harness(""" +measurePlan = []; +for(let i=0;i<40;i++) measurePlan.push('K'+i); // 40 distinct keys, well over the cap +renderMessages({}); // external trigger: starts the burst +for(let i=0;i<40;i++) flushRaf(); // drain both rAF layers repeatedly +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, + rafPending: rafQueue.length, + cap: MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS, +})); +""") + metrics = json.loads(_run_node(source)) + cap = metrics["cap"] + assert metrics["internal"] == cap, ( + "an all-distinct sequence must be cut off by the absolute per-burst " + f"cap: {metrics['internal']} internal renders (expected exactly {cap})" + ) + assert metrics["renders"] == cap + 1, ( + "the all-distinct burst must terminate after the cap (1 external + cap " + f"internal renders): {metrics['renders']} renders (expected {cap + 1})" + ) + assert metrics["rafPending"] == 0, ( + "no rAF may remain queued after the cap ends the burst: " + f"rafPending {metrics['rafPending']}" + ) + assert metrics["burstActive"] is False, ( + "the cap must end the burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + "termination must clear the seen-key memory (bounded storage — at most " + f"cap keys were ever retained): seenKeys {metrics['seenKeys']}" + ) + assert metrics["queuedOrigin"] is None, ( + "no internal provenance may remain queued after the cap ends the burst: " + f"queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_queued_raf_external_scroll_wins_provenance(): + """#6717 re-gate MUST-FIX 2: when an external render (user scroll / + scroll-settle) overlaps a QUEUED internal rAF, the render that actually + fires must be treated as EXTERNAL. The old global consumable flag + (_messageVirtualMeasurementRenderPending) could be consumed by an + unrelated render, marking external renders as internal so the burst was + never reset — leaving stale virtual-window / spacer geometry until the + next unrelated render. + + Sequence: external R0 measures A (L2 queued internal); an external R1 + lands while L2 is pending — it resets the burst, measures B, and its own + layer-1 rAF COALESCES into the pending L2 (the early-return path where + the old flag leaked). The L2 that fires must be UNMARKED — external + provenance wins — so the A it measures starts a fresh burst instead of + terminating on the repeated key with zero retry queued.""" + source = _measurement_burst_harness(""" +measurePlan = ['A','B','A', undefined]; +renderMessages({}); // R0 external: measures A -> burst, L1 queued +flushRaf(); // L1 -> L2 queued with internal provenance +renderMessages({}); // R1 EXTERNAL scroll lands while L2 pending: + // resets the burst, measures B -> L1 queued +flushRaf(); // B's L1 coalesces into the pending L2 (no new rAF) +flushRaf(); // L2 fires -> must be UNMARKED (external won), + // measures A -> fresh burst -> L1 queued +flushRaf(); flushRaf(); // A's fresh cycle: L2 internal render -> settles +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + r0External: renderLog[0].internal, + r1External: renderLog[1].internal, + coalescedL2Unmarked: renderLog[2].internal, + freshCycleInternal: renderLog[3].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["renders"] == 4, ( + "the scroll overlap chain must run to completion: " + f"{metrics['renders']} renders (expected 4)" + ) + assert metrics["r0External"] is False and metrics["r1External"] is False, ( + "R0 and R1 are unmarked external renders" + ) + assert metrics["coalescedL2Unmarked"] is False, ( + "the queued internal rAF must degrade to an UNMARKED render when an " + "external request coalesced into it — external provenance wins and an " + "internal marker must never survive onto an external render" + ) + assert metrics["freshCycleInternal"] is True, ( + "the fresh cycle started by the degraded render must complete with its " + "own internal render: the repeated A key must NOT terminate it because " + "the burst was reset (zero retry queued would leave stale geometry)" + ) + assert metrics["burstActive"] is False, ( + "the fresh cycle must end settled: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" + ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index adf7df9e..7d8fef54 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -52,10 +52,57 @@ def test_virtualization_affordances_have_styling_hooks(): assert "border-radius:999px" in CSS -def test_measurement_rerenders_are_bounded_per_virtual_window_cycle(): - assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS +def test_measurement_rerenders_are_cycle_aware_per_virtual_window_burst(): assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS assert "function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)" in UI_JS - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS + # Cycle-aware burst tracking: unseen keys proceed, repeated keys terminate, + # and the seen-key memory lives in _messageVirtualMeasurementSeenKeys. + assert "_messageVirtualMeasurementSeenKeys.includes(cycleKey)" in UI_JS + assert "_messageVirtualMeasurementSeenKeys.push(cycleKey)" in UI_JS + assert "function _resetMessageVirtualMeasurementBurst()" in UI_JS + + +def test_measurement_burst_not_reset_by_cycle_key_change(): + """#6654/#6717: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh + must record the new key but never reset the burst state. Resetting on key + change lets WebKit's A->B->A->B metric oscillation renew the chain forever, + keeping the rAF/measure loop alive. The ONLY resets allowed inside the + scheduler are: the repeated-key termination (oscillation) and the fresh-burst + start (when no burst is active), guarded by if(!_messageVirtualMeasurementBurstActive).""" + idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)") + end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx) + body = UI_JS[idx:end] + # The key-change branch must only assign the key. + assert "if(_messageVirtualMeasurementCycleKey!==cycleKey){" in body + key_branch_start = body.index("if(_messageVirtualMeasurementCycleKey!==cycleKey){") + key_branch_end = body.index("}", key_branch_start) + key_branch = body[key_branch_start:key_branch_end] + assert "_messageVirtualMeasurementSeenKeys" not in key_branch, ( + "cycle-key change must not reset the burst's seen-key memory (issue #6654)" + ) + assert "_messageVirtualMeasurementBurstActive=false" not in key_branch, ( + "cycle-key change must not end the burst (issue #6654)" + ) + # The fresh-burst reset is allowed only at the start of a new external cycle. + assert "if(!_messageVirtualMeasurementBurstActive){" in body + guard_pos = body.index("if(!_messageVirtualMeasurementBurstActive){") + seen_reset_pos = body.index("_messageVirtualMeasurementSeenKeys=[];") + assert guard_pos < seen_reset_pos, ( + "the seen-key reset must be guarded by the burst-active check " + "(per-burst lifecycle, issue #6717)" + ) + # A NON-consecutive repeat (A -> B -> A) is oscillation and terminates the + # burst. A consecutive repeat (same window needing a second re-measure, e.g. + # a row shrinking 1500 -> 1400 -> 100 px) proceeds; unseen keys proceed. + assert "if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body + assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body + # The internal-measurement origin travels WITH the scheduled render request + # (threaded through both rAF layers), never as a global consumable flag that + # a coalesced external render could steal (#6717 re-gate MUST-FIX 2). + assert "origin:'internal'" in body + assert "_messageVirtualMeasurementRenderPending" not in UI_JS + # Absolute per-burst ceiling: even an all-distinct key sequence terminates, + # bounding the seen-key memory (#6717 re-gate MUST-FIX 1). + assert "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS" in UI_JS diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py index 25a42f3f..5894905e 100644 --- a/tests/test_process_wakeup_rendering.py +++ b/tests/test_process_wakeup_rendering.py @@ -70,7 +70,7 @@ let _visWithIdxCacheLen = 0; let _visWithIdxCacheSrc = null; const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT'); -const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart); +const heightEnd = src.indexOf('let _messageVirtualMeasurementSeenKeys', heightStart); if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd)); if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage')); eval(extractFunc('_stripWorkspaceDisplayPrefix')); diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 20e73100..caf171d5 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -793,7 +793,8 @@ def test_renderMessages_preserves_loading_placeholder_for_session_switch(cleanup ui_src = (REPO_ROOT / "static/ui.js").read_text() fn_start = ui_src.find("function renderMessages") assert fn_start >= 0, "renderMessages() not found in ui.js" - fn_body = ui_src[fn_start:fn_start + 1400] + # Window sized to reach the render-window reset; renderMessages() preamble grows (#6717). + fn_body = ui_src[fn_start:fn_start + 2400] compact = re.sub(r"\s+", "", fn_body) assert (