diff --git a/static/ui.js b/static/ui.js index ee42cfbc..3e7a6b70 100644 --- a/static/ui.js +++ b/static/ui.js @@ -536,7 +536,14 @@ 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. +let _messageVirtualMeasurementSeenKeys=[]; let _messageRenderWindowSid=null; let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; let _messageVirtualHeightCache=[]; @@ -547,8 +554,13 @@ let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('defau let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; -let _messageVirtualMeasurementRetryCount=0; let _messageVirtualMeasurementBurstActive=false; +// Provenance marker: true while an internally measurement-scheduled render is +// queued in the rAF layers. renderMessages() resets the burst on every +// UNMARKED (externally initiated) render trigger, so a message append / +// scroll-settle / other overlapping external render always starts a fresh +// cycle even when an internal measurement callback is still pending. +let _messageVirtualMeasurementRenderPending=false; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -595,8 +607,7 @@ function _clearMessageVirtualHeightCache(){ _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default'); _messageVirtualWindowKey=''; _messageVirtualMeasurementCycleKey=''; - _messageVirtualMeasurementRetryCount=0; - _messageVirtualMeasurementBurstActive=false; + _resetMessageVirtualMeasurementBurst(); _messageVirtualScrollActive=false; clearTimeout(_messageVirtualScrollSettleTimer); _messageVirtualScrollSettleTimer=0; @@ -721,6 +732,11 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){ windowMetrics.tailStart||0, ].join(':'); } +function _resetMessageVirtualMeasurementBurst(){ + _messageVirtualMeasurementSeenKeys=[]; + _messageVirtualMeasurementBurstActive=false; + _messageVirtualMeasurementRenderPending=false; +} function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualScrollActive){ _messageVirtualDeferredMeasurement=windowMetrics; @@ -730,32 +746,39 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; } - // Per-burst retry budget (#6717): the counter is preserved across ONLY the + // Cycle-aware burst tracking (#6717 re-gate): the burst follows ONLY the // internally measurement-scheduled chain (requestAnimationFrame -> - // _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here), - // so WebKit's A/B window-metric oscillation can never renew the budget and - // keep the rAF/measure loop alive (#6654). It is reset exactly once when a - // later EXTERNALLY initiated render begins a genuinely new measurement cycle - // (session load, message append, real content change) — never on cycle-key - // changes. A cycle-key change alone just records the new key above. + // _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){ - _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementSeenKeys=[]; _messageVirtualMeasurementBurstActive=true; } - if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){ - // Budget exhausted: this burst is over (no further internal re-render is - // scheduled), so the next externally initiated cycle starts fresh instead - // of being starved of its own retries. - _messageVirtualMeasurementBurstActive=false; + if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){ + // Repeated key: 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. + _resetMessageVirtualMeasurementBurst(); return; } - _messageVirtualMeasurementRetryCount++; + _messageVirtualMeasurementSeenKeys.push(cycleKey); + // Provenance marker for the internal measurement chain: the render we are + // scheduling must reach renderMessages() marked as internal (threaded + // through BOTH rAF layers via _scheduleMessageVirtualizedRender) so it + // preserves the burst instead of resetting it. + _messageVirtualMeasurementRenderPending=true; requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); - _messageVirtualMeasurementRetryCount=0; - _messageVirtualMeasurementBurstActive=false; + _resetMessageVirtualMeasurementBurst(); } function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ return !!( @@ -1486,6 +1509,13 @@ function _scheduleMessageVirtualizedRender(force){ if(_messageVirtualScrollRaf) return; _messageVirtualScrollRaf=requestAnimationFrame(()=>{ _messageVirtualScrollRaf=0; + // Consume the internal-measurement provenance marker (set by + // _scheduleMessageVirtualMeasurementRefresh before the first rAF layer and + // threaded through it into this second rAF layer). If an EXTERNAL render + // reset the burst meanwhile, this callback degrades to an unmarked render + // and starts a fresh measurement cycle instead of continuing a stale one. + const internalMeasurement=_messageVirtualMeasurementRenderPending; + _messageVirtualMeasurementRenderPending=false; const liveVisWithIdx=_getVisibleMessagesWithIdx(); const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount()); const liveKey=_messageVirtualWindowKeyFor(liveWindow); @@ -1493,14 +1523,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; } }); @@ -16565,6 +16595,18 @@ function _processWakeupCardHtml(info, rawText, extras){ function renderMessages(options){ _lastMessageRenderAt=performance.now(); + // Measurement-burst lifecycle (#6654/#6717): every UNMARKED (externally + // initiated) render — session load, message append, scroll-settle, genuine + // content change — starts a fresh measurement burst HERE, before any rAF + // coalescing or early return, so a pending internal measurement callback can + // never carry a stale burst into the new cycle (external provenance + // overrides a pending internal callback). Only the internally + // measurement-scheduled chain (options._internalMeasurement, threaded through + // both rAF layers by _scheduleMessageVirtualMeasurementRefresh and + // _scheduleMessageVirtualizedRender) preserves the burst across renders. + if(!(options&&options._internalMeasurement)){ + _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_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 994d2951..06c26b80 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1111,8 +1111,9 @@ let _messageVirtualHeightCacheSrc = {}; let _messageVirtualEstimatedRowHeight = 200; let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; -let _messageVirtualMeasurementRetryCount = 3; -let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualMeasurementSeenKeys = ['stale-key']; +let _messageVirtualMeasurementBurstActive = true; +let _messageVirtualMeasurementRenderPending = true; 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 @@ -1120,6 +1121,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({ @@ -1127,6 +1129,9 @@ console.log(JSON.stringify({ settleTimer: _messageVirtualScrollSettleTimer, deferred: _messageVirtualDeferredMeasurement, timerCleared: timerCleared, + seenKeys: _messageVirtualMeasurementSeenKeys, + burstActive: _messageVirtualMeasurementBurstActive, + renderPending: _messageVirtualMeasurementRenderPending, })); """ metrics = json.loads(_run_node(source)) @@ -1142,121 +1147,245 @@ console.log(JSON.stringify({ assert metrics["timerCleared"] is True, ( "_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer" ) - - -def test_measurement_retry_budget_is_global_across_cycle_key_oscillation(): - """#6654: _scheduleMessageVirtualMeasurementRefresh must NOT reset - _messageVirtualMeasurementRetryCount when _messageVirtualMeasurementCycleKey - changes INSIDE one measurement burst. If WebKit alternates between two - measured window-metric sets (A -> B -> A -> B), each key transition used to - reset the two-render budget, so the rAF/geometry-measurement loop never - terminated (~30% WebContent CPU). The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS - budget must cap the whole convergence burst; the burst ends at the cap (or - when measurement settles), so the NEXT externally initiated cycle gets a - fresh budget. The harness drives the thrash causally through the internal - chain (each scheduled render re-measures the next oscillating key).""" - js = UI_JS_PATH.read_text(encoding="utf-8") - source = _extract_func_script(js) + """ -let scheduled = 0; -let _messageVirtualMeasurementCycleKey = ''; -let _messageVirtualMeasurementRetryCount = 0; -let _messageVirtualMeasurementBurstActive = false; -let _messageVirtualScrollActive = false; -let _messageVirtualDeferredMeasurement = null; -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; -function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } -// Drive the burst causally: each internally scheduled render re-measures and -// re-enters the scheduler with the next WebKit-oscillated key. -let keys = ['B','A','B','A']; -let idx = 0; -function _scheduleMessageVirtualizedRender(force){ - scheduled++; - if(idxB->A->B. -_scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); -console.log(JSON.stringify({ - scheduled: scheduled, - retries: _messageVirtualMeasurementRetryCount, - burstActive: _messageVirtualMeasurementBurstActive, -})); -""" - metrics = json.loads(_run_node(source)) - assert metrics["scheduled"] == 2, ( - "cycle-key oscillation must not reset the retry budget within a burst: " - f"scheduled {metrics['scheduled']} re-renders (expected 2, the " - "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS cap for the whole burst)" - ) - assert metrics["retries"] == 2, ( - "retry count must keep accumulating across key changes within the burst: " - f"ended at {metrics['retries']} (expected 2)" + assert metrics["seenKeys"] == [], ( + "_clearMessageVirtualHeightCache must clear the measurement-burst seen-key " + f"memory: seenKeys {metrics['seenKeys']}" ) assert metrics["burstActive"] is False, ( - "the burst must end once the budget is exhausted so the next externally " - f"initiated cycle can reset it: burstActive {metrics['burstActive']}" + "_clearMessageVirtualHeightCache must end any active measurement burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["renderPending"] is False, ( + "_clearMessageVirtualHeightCache must clear the internal-measurement " + f"provenance marker: renderPending {metrics['renderPending']}" ) -def test_external_measurement_after_thrash_gets_fresh_retry_budget(): - """#6717 re-gate: exhausting the budget with the WebKit A/B thrash must NOT - starve a genuinely new externally initiated measurement cycle. Drive the - A/B oscillation causally until the per-burst budget is exhausted, then start - an independent changed-C measurement WITHOUT touching private state: - (a) C must still receive its own retries, and (b) C's own internal - oscillation must remain capped at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS.""" +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") - source = _extract_func_script(js) + """ -let scheduled = 0; + return _extract_func_script(js) + """ +// ── 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 _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualMeasurementSeenKeys = []; let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualMeasurementRenderPending = false; let _messageVirtualScrollActive = false; let _messageVirtualDeferredMeasurement = null; -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; -function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } -let keys = []; -let idx = 0; -function _scheduleMessageVirtualizedRender(force){ - scheduled++; - if(idxB->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({ - afterThrash: afterThrash, - scheduled: scheduled, - retries: _messageVirtualMeasurementRetryCount, + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + renderPending: _messageVirtualMeasurementRenderPending, })); -""" +""") metrics = json.loads(_run_node(source)) - assert metrics["afterThrash"] == 2, ( - "the A/B thrash burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_" - f"MAX_RERENDERS: scheduled {metrics['afterThrash']} (expected 2)" + 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["scheduled"] == 4, ( - "a genuinely new external measurement cycle C must still receive its " - "own retries after the thrash exhausted the budget: " - f"scheduled {metrics['scheduled']} (expected 4 = 2 thrash + 2 for C)" + assert metrics["renders"] == 3, ( + "oscillation must not keep scheduling renders: " + f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)" ) - assert metrics["retries"] == 2, ( - "C's own internal oscillation must remain capped at " - f"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS: retries " - f"{metrics['retries']} (expected 2)" + 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["renderPending"] is False, ( + "no internal render may remain pending after termination: " + f"renderPending {metrics['renderPending']}" + ) + + +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, + renderPending: _messageVirtualMeasurementRenderPending, +})); +""") + 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["renderPending"] is False, ( + "settlement must clear the seen-key memory and any pending marker: " + f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" + ) + + +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. + 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 joins the fresh cycle (R2), then R3 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, + appendCycleInternal: renderLog[2].internal && renderLog[3].internal, + staleCallbackUnmarked: renderLog[6].internal, + postResetCycleInternal: renderLog[7].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + renderPending: _messageVirtualMeasurementRenderPending, +})); +""") + 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["appendCycleInternal"] is True, ( + "the append's cycle must proceed with its own internal renders " + "(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["renderPending"] is False, ( + "settlement must clear the seen-key memory and any pending marker: " + f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 53f6d6d9..88218cde 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -52,23 +52,25 @@ 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){" 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_retry_budget_not_reset_by_cycle_key_change(): +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 _messageVirtualMeasurementRetryCount. - Resetting on key change lets WebKit's A->B->A->B metric oscillation renew - the two-render budget forever, keeping the rAF/measure loop alive. The ONLY - reset allowed inside the scheduler is the per-burst one: when a NEW - externally initiated cycle begins (no burst active), guarded by - if(!_messageVirtualMeasurementBurstActive).""" + 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] @@ -77,17 +79,21 @@ def test_measurement_retry_budget_not_reset_by_cycle_key_change(): 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 "_messageVirtualMeasurementRetryCount=0;" not in key_branch, ( - "cycle-key change must not reset the retry budget (issue #6654)" + assert "_messageVirtualMeasurementSeenKeys" not in key_branch, ( + "cycle-key change must not reset the burst's seen-key memory (issue #6654)" ) - # The per-burst reset is allowed only at the start of a new external cycle. + 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){") - reset_pos = body.index("_messageVirtualMeasurementRetryCount=0;") - assert guard_pos < reset_pos, ( - "the budget reset must be guarded by the burst-active check " + 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)" ) - # The budget guard and increment must still be present. - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in body - assert "_messageVirtualMeasurementRetryCount++;" in body + # Repeated keys terminate the burst; unseen keys proceed. + assert "if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body + assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body + assert "_messageVirtualMeasurementRenderPending=true;" in body 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'));