From 4df32ea33243ed4bfe7e9c037e8351d8151ac97f Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sun, 2 Aug 2026 14:59:15 -0300 Subject: [PATCH 1/9] fix(perf): don't reset virtual measurement retry budget on window-key oscillation (#6654) _scheduleMessageVirtualMeasurementRefresh() reset _messageVirtualMeasurementRetryCount every time _messageVirtualMeasurementCycleKey changed. When WebKit alternates between two measured window-metric sets (A -> B -> A -> B) after getBoundingClientRect(), each key transition renewed the two-render budget and the requestAnimationFrame/geometry-measurement loop never terminated (WebContent ~30% CPU for minutes after session recovery). The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS budget now caps the whole convergence burst globally; the cycle-key branch only records the new key. The budget still resets at the correct lifecycle boundaries: _markMessageVirtualMeasurementsSettled() and _clearMessageVirtualHeightCache(). Adds a deterministic node-based regression test driving the real ui.js function with alternating keys ['A','B','A','B'] (before: 4 scheduled re-renders; after: 2), plus a stable-key guard and a structural test. --- static/ui.js | 1 - ...st_issue500_message_list_virtualization.py | 83 +++++++++++++++++++ tests/test_issue734_message_windowing.py | 18 ++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index 56af87da..13583741 100644 --- a/static/ui.js +++ b/static/ui.js @@ -727,7 +727,6 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; - _messageVirtualMeasurementRetryCount=0; } if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; _messageVirtualMeasurementRetryCount++; diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 70a8e851..6bd963ca 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1141,3 +1141,86 @@ 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. 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 globally; it resets only when measurement settles + (_markMessageVirtualMeasurementsSettled) or the cache/session resets + (_clearMessageVirtualHeightCache).""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let scheduled = 0; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; +function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } +function _scheduleMessageVirtualizedRender(force) { scheduled++; } +function requestAnimationFrame(cb) { cb(); } +eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); +// WebKit alternates between two adjacent window-metric keys across +// getBoundingClientRect passes. Before the fix every key transition reset the +// budget, so all four passes scheduled a re-render. +for (const key of ['A','B','A','B']) { + _scheduleMessageVirtualMeasurementRefresh({ key: key }); +} +console.log(JSON.stringify({ + scheduled: scheduled, + retries: _messageVirtualMeasurementRetryCount, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["scheduled"] == 2, ( + "cycle-key oscillation must not reset the retry budget: " + 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: " + f"ended at {metrics['retries']} (expected 2)" + ) + + +def test_measurement_retry_budget_still_caps_stable_key_burst(): + """#6654 guard: with a stable cycle key the budget must still cap the burst + at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS (the removed per-key reset + must not have weakened the stable-key path).""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let scheduled = 0; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; +function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } +function _scheduleMessageVirtualizedRender(force) { scheduled++; } +function requestAnimationFrame(cb) { cb(); } +eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); +for (let i = 0; i < 6; i++) { + _scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); +} +// The budget is exhausted after two renders; a settled measurement resets it. +_messageVirtualMeasurementRetryCount = 0; +_scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); +console.log(JSON.stringify({ + scheduled: scheduled, + retries: _messageVirtualMeasurementRetryCount, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["scheduled"] == 3, ( + "stable-key burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS " + f"and resume only after a budget reset: scheduled {metrics['scheduled']} " + "(expected 3: 2 capped + 1 after explicit reset)" + ) + assert metrics["retries"] == 1, ( + f"retry count after explicit reset must be 1, got {metrics['retries']}" + ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index adf7df9e..a9f5257c 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -59,3 +59,21 @@ def test_measurement_rerenders_are_bounded_per_virtual_window_cycle(): assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS + + +def test_measurement_retry_budget_not_reset_by_cycle_key_change(): + """#6654: 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.""" + 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 + assert "_messageVirtualMeasurementRetryCount=0;" not in body, ( + "cycle-key change must not reset the retry budget (issue #6654)" + ) + # The budget guard and increment must still be present. + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in body + assert "_messageVirtualMeasurementRetryCount++;" in body From f88cc782864fa76075f81959389a2c887b98a904 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 4 Aug 2026 10:59:35 -0300 Subject: [PATCH 2/9] fix(perf): per-burst retry budget lifecycle, reset only on external render (#6717) --- static/ui.js | 23 ++- ...st_issue500_message_list_virtualization.py | 152 +++++++++++------- tests/test_issue734_message_windowing.py | 24 ++- 3 files changed, 135 insertions(+), 64 deletions(-) diff --git a/static/ui.js b/static/ui.js index 13583741..ee42cfbc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -548,6 +548,7 @@ let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; let _messageVirtualMeasurementRetryCount=0; +let _messageVirtualMeasurementBurstActive=false; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -595,6 +596,7 @@ function _clearMessageVirtualHeightCache(){ _messageVirtualWindowKey=''; _messageVirtualMeasurementCycleKey=''; _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementBurstActive=false; _messageVirtualScrollActive=false; clearTimeout(_messageVirtualScrollSettleTimer); _messageVirtualScrollSettleTimer=0; @@ -728,13 +730,32 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; } - if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; + // Per-burst retry budget (#6717): the counter is preserved across 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. + if(!_messageVirtualMeasurementBurstActive){ + _messageVirtualMeasurementRetryCount=0; + _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; + return; + } _messageVirtualMeasurementRetryCount++; requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementBurstActive=false; } function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ return !!( diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 6bd963ca..994d2951 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1112,6 +1112,7 @@ let _messageVirtualEstimatedRowHeight = 200; let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; let _messageVirtualMeasurementRetryCount = 3; +let _messageVirtualMeasurementBurstActive = false; 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 @@ -1146,81 +1147,116 @@ console.log(JSON.stringify({ def test_measurement_retry_budget_is_global_across_cycle_key_oscillation(): """#6654: _scheduleMessageVirtualMeasurementRefresh must NOT reset _messageVirtualMeasurementRetryCount when _messageVirtualMeasurementCycleKey - changes. 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 globally; it resets only when measurement settles - (_markMessageVirtualMeasurementsSettled) or the cache/session resets - (_clearMessageVirtualHeightCache).""" + 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; } -function _scheduleMessageVirtualizedRender(force) { scheduled++; } -function requestAnimationFrame(cb) { cb(); } -eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); -// WebKit alternates between two adjacent window-metric keys across -// getBoundingClientRect passes. Before the fix every key transition reset the -// budget, so all four passes scheduled a re-render. -for (const key of ['A','B','A','B']) { - _scheduleMessageVirtualMeasurementRefresh({ key: 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"] == 3, ( - "stable-key burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS " - f"and resume only after a budget reset: scheduled {metrics['scheduled']} " - "(expected 3: 2 capped + 1 after explicit reset)" + 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"] == 1, ( - f"retry count after explicit reset must be 1, got {metrics['retries']}" + assert metrics["retries"] == 2, ( + "retry count must keep accumulating across key changes within the burst: " + f"ended at {metrics['retries']} (expected 2)" + ) + 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']}" + ) + + +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.""" + 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; } +let keys = []; +let idx = 0; +function _scheduleMessageVirtualizedRender(force){ + scheduled++; + if(idx=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" 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 def test_measurement_retry_budget_not_reset_by_cycle_key_change(): - """#6654: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh + """#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 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).""" 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 - assert "_messageVirtualMeasurementRetryCount=0;" not 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 "_messageVirtualMeasurementRetryCount=0;" not in key_branch, ( "cycle-key change must not reset the retry budget (issue #6654)" ) + # The per-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 " + "(per-burst lifecycle, issue #6717)" + ) # The budget guard and increment must still be present. - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in body + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in body assert "_messageVirtualMeasurementRetryCount++;" in body From e5742d5dda3632d45d7be103135268b73e844509 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sat, 8 Aug 2026 09:49:26 -0300 Subject: [PATCH 3/9] fix(perf): cycle-aware measurement burst tracking (#6654) Replace the flat 2-render burst cap with cycle-aware tracking: unseen monotonic window keys proceed (genuine convergence A->B->C->settled is never capped), while repeated oscillating keys (A->B->A->B) terminate the burst so the rAF/measure loop stays bounded. Thread an internal-measurement provenance marker through both rAF layers into renderMessages() and reset burst state on every unmarked (externally initiated) render trigger, so a pending internal callback can never starve a legitimate new cycle. --- static/ui.js | 86 +++-- ...st_issue500_message_list_virtualization.py | 329 ++++++++++++------ tests/test_issue734_message_windowing.py | 44 ++- tests/test_process_wakeup_rendering.py | 2 +- 4 files changed, 319 insertions(+), 142 deletions(-) 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')); From b1ea6141b35b29e66d152328c3514e392bb269a8 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sun, 9 Aug 2026 09:26:45 -0300 Subject: [PATCH 4/9] fix(perf): keep burst-reset before early returns within renderMessages guard window (#6654) The cycle-aware burst tracking re-gate reset the measurement burst at the top of renderMessages(), but the added block pushed the session-load loading-placeholder guard past the 1400-char window asserted by test_renderMessages_preserves_loading_placeholder_for_session_switch, and the standalone renderMessages harness in test_anchor_fallback_ownership.py lacked the new _resetMessageVirtualMeasurementBurst dependency. Compact the reset (identical behavior: every unmarked external render starts a fresh burst before any early return or rAF coalescing) and add the helper stub to the harness. CI test shard 2 is green again. --- static/ui.js | 13 +------------ tests/test_anchor_fallback_ownership.py | 1 + 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/static/ui.js b/static/ui.js index 3e7a6b70..73deb1ec 100644 --- a/static/ui.js +++ b/static/ui.js @@ -16595,18 +16595,7 @@ 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(); - } + 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_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index 6cc6d677..9e05fdde 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -675,6 +675,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 ''; }} From 05b6d5b472fbf90cb027316b59715abe8ccf46a7 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 19 Aug 2026 13:14:07 -0300 Subject: [PATCH 5/9] fix(perf): absolute burst cap + per-render provenance (#6717) --- static/ui.js | 85 ++++++-- ...st_issue500_message_list_virtualization.py | 195 +++++++++++++++--- tests/test_issue734_message_windowing.py | 9 +- 3 files changed, 241 insertions(+), 48 deletions(-) diff --git a/static/ui.js b/static/ui.js index 73deb1ec..19ee2f27 100644 --- a/static/ui.js +++ b/static/ui.js @@ -543,6 +543,16 @@ function _messageVirtualDefaultHeightForRole(role){ // 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; @@ -555,12 +565,18 @@ let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; 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; +// 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; @@ -735,7 +751,9 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){ function _resetMessageVirtualMeasurementBurst(){ _messageVirtualMeasurementSeenKeys=[]; _messageVirtualMeasurementBurstActive=false; - _messageVirtualMeasurementRenderPending=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){ @@ -761,6 +779,16 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ _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; + } 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 @@ -769,12 +797,13 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ return; } _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); }); + // 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); @@ -1494,7 +1523,7 @@ function _rememberRenderedUserRowIntrinsicHeights(){ } } } -function _scheduleMessageVirtualizedRender(force){ +function _scheduleMessageVirtualizedRender(force, request){ const container=$('messages'); const inner=$('msgInner'); if(!container||!inner) return; @@ -1506,16 +1535,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; - // 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; + // 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); diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 06c26b80..8bb93b85 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 @@ -1113,7 +1114,7 @@ let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; let _messageVirtualMeasurementSeenKeys = ['stale-key']; let _messageVirtualMeasurementBurstActive = true; -let _messageVirtualMeasurementRenderPending = 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 @@ -1131,7 +1132,7 @@ console.log(JSON.stringify({ timerCleared: timerCleared, seenKeys: _messageVirtualMeasurementSeenKeys, burstActive: _messageVirtualMeasurementBurstActive, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """ metrics = json.loads(_run_node(source)) @@ -1155,9 +1156,9 @@ console.log(JSON.stringify({ "_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']}" + assert metrics["queuedOrigin"] is None, ( + "_clearMessageVirtualHeightCache must clear any queued internal-measurement " + f"provenance: queuedOrigin {metrics['queuedOrigin']}" ) @@ -1174,7 +1175,18 @@ def _measurement_burst_harness(scenario_js: str) -> str: where the overlap/convergence regressions lived. """ js = UI_JS_PATH.read_text(encoding="utf-8") - return _extract_func_script(js) + """ + 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 = []; @@ -1185,7 +1197,7 @@ let _messageVirtualWindowKey = ''; let _messageVirtualMeasurementCycleKey = ''; let _messageVirtualMeasurementSeenKeys = []; let _messageVirtualMeasurementBurstActive = false; -let _messageVirtualMeasurementRenderPending = false; +let _messageVirtualRenderQueuedOrigin = null; let _messageVirtualScrollActive = false; let _messageVirtualDeferredMeasurement = null; let _scrollbarDragActive = false; @@ -1246,7 +1258,7 @@ console.log(JSON.stringify({ internal: renderLog.filter(r => r.internal).length, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1266,9 +1278,9 @@ console.log(JSON.stringify({ "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']}" + assert metrics["queuedOrigin"] is None, ( + "no internal render may remain queued after termination: " + f"queuedOrigin {metrics['queuedOrigin']}" ) @@ -1290,7 +1302,7 @@ console.log(JSON.stringify({ lastInternal: renderLog[renderLog.length - 1].internal, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1311,9 +1323,9 @@ console.log(JSON.stringify({ "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']}" + 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']}" ) @@ -1328,6 +1340,12 @@ def test_overlapping_external_append_receives_fresh_budget(): 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 @@ -1338,7 +1356,8 @@ 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 +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, @@ -1350,12 +1369,13 @@ 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, + staleL2Unmarked: renderLog[2].internal, + postAppendCycleInternal: renderLog[3].internal, staleCallbackUnmarked: renderLog[6].internal, postResetCycleInternal: renderLog[7].internal, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1367,10 +1387,16 @@ console.log(JSON.stringify({ 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["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 " @@ -1385,7 +1411,126 @@ console.log(JSON.stringify({ "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']}" + 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 88218cde..b7434db2 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -96,4 +96,11 @@ def test_measurement_burst_not_reset_by_cycle_key_change(): # 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 + # 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 From 0cbf16a51c36ef49482595f15c2b391ef55d2a41 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 24 Sep 2026 08:44:04 -0300 Subject: [PATCH 6/9] fix(perf): treat only non-consecutive repeat cycle keys as oscillation (#6717) --- static/ui.js | 12 ++++--- ...st_issue500_message_list_virtualization.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/static/ui.js b/static/ui.js index 19ee2f27..adff96c9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -789,10 +789,14 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ _resetMessageVirtualMeasurementBurst(); return; } - 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. + 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; } diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 8bb93b85..4b6e2af8 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1284,6 +1284,42 @@ console.log(JSON.stringify({ ) +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 From 1633658fa5a55248bd4bc89a0b4e128a0fe69c09 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 24 Sep 2026 12:57:14 +0000 Subject: [PATCH 7/9] test(#6717): assert the non-consecutive repeat condition The production guard now ends the burst only on a non-consecutive repeat (A -> B -> A), so a window needing a second re-measure is not mistaken for oscillation. Update the structural assertion to that condition. --- tests/test_issue734_message_windowing.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index b7434db2..7d8fef54 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -93,8 +93,10 @@ def test_measurement_burst_not_reset_by_cycle_key_change(): "the seen-key reset must be guarded by the burst-active check " "(per-burst lifecycle, issue #6717)" ) - # Repeated keys terminate the burst; unseen keys proceed. - assert "if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body + # 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 From 73d08d3cfb95dd10f5a911bb4520e87546f905ba Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 24 Sep 2026 13:43:12 +0000 Subject: [PATCH 8/9] test: follow the #6717 render-scheduler signature; guard the burst reset for node harnesses _scheduleMessageVirtualizedRender now takes (force, request), so the two structural probes in test_issue4346_vscroll_footer_jitter.py matched nothing. renderMessages() calls _resetMessageVirtualMeasurementBurst(), which node harnesses that extract renderMessages() alone (test_compaction_current_summary_fallback.py) don't define; guard it with typeof like the file's other optional helpers. --- static/ui.js | 3 ++- tests/test_issue4346_vscroll_footer_jitter.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index adff96c9..fd444e65 100644 --- a/static/ui.js +++ b/static/ui.js @@ -16640,7 +16640,8 @@ function _processWakeupCardHtml(info, rawText, extras){ function renderMessages(options){ _lastMessageRenderAt=performance.now(); - if(!(options&&options._internalMeasurement)){ _resetMessageVirtualMeasurementBurst(); } + // 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_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" From 28edb04ebdd226d1f8b8c188e2eeba0de7fc5811 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 24 Sep 2026 14:02:48 +0000 Subject: [PATCH 9/9] test: widen renderMessages() source window in R16d guard-order probe The #6717 burst-reset call grows the renderMessages() preamble, pushing the render-window reset past the probe's 1400-char slice. Widen it to 2400; the guard-before-reset ordering assertion is unchanged and still passes on master. --- tests/test_regressions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 28bf92ea..87b7c970 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 (