diff --git a/CHANGELOG.md b/CHANGELOG.md index b96be705..59d16096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.613] — 2026-06-23 — Release VT (eliminate O(DOM) streaming render freeze on long answers) + +### Fixed + +- **Long agent answers no longer progressively freeze the UI while streaming.** On answers of 30,000+ characters (5,000+ DOM nodes), every streamed token ran a full-DOM link-sanitization scan, so render cost grew with the answer size and the tab eventually froze (and could crash the renderer). Link URL-scheme validation now happens inline as each DOM node is created (via a wrapped streaming-markdown renderer), eliminating the per-token full-DOM rescan, and the streaming render now caches its parse result to skip redundant re-parsing. Dangerous URL schemes (`javascript:`, `data:`, `vbscript:`, etc.) are still rejected on links and images exactly as before, and the final settled render still runs the full sanitizer. Thanks @wlknight. (#4800) + ## [v0.51.612] — 2026-06-23 — Release VS (background-tab notifications + profile default workspace on fresh sessions) ### Fixed diff --git a/static/messages.js b/static/messages.js index 86f62f27..50ca2ece 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3070,7 +3070,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _smdWrittenLen=0; _smdWrittenText=''; if(!window.smd){_smdParser=null;return;} - const baseRenderer=fade ? _streamFadeRenderer(el) : window.smd.default_renderer(el); + const baseRenderer=fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el); const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer); _smdParser=window.smd.parser(renderer); } @@ -3141,9 +3141,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{window.smd.parser_write(_smdParser,delta);}catch(_){} _smdWrittenLen=displayText.length; _smdWrittenText=displayText; - // streaming-markdown does NOT sanitize URL schemes. The default live path - // scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr. - if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);} + // URL scheme safety is handled by the renderer's set_attr hook + // (_safeSmdRenderer or _streamFadeRenderer), applied inline as smd + // creates each DOM node — no post-hoc full-DOM scan needed. _scheduleStreamingKatex(); } // Allowed URL schemes for anchors and images rendered from agent-streamed markdown. @@ -3314,6 +3314,36 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; return renderer; } + // Safe renderer: wraps default_renderer with a set_attr hook that validates + // href/src URL schemes inline — no post-hoc DOM-wide querySelectorAll needed. + // Unlike _streamFadeRenderer, this does NOT wrap add_text, so smd adds new + // DOM nodes as plain text nodes (no animation spans). Used on the non-fade + // streaming path to eliminate _sanitizeSmdLinks(assistantBody) O(DOM) scans + // on every token event (#WebUI-perf). + function _safeSmdRenderer(el){ + const renderer=window.smd.default_renderer(el); + const baseSetAttr=renderer.set_attr; + renderer.set_attr=(data,attr,value)=>{ + const isHref=window.smd&&attr===window.smd.HREF; + const isSrc=window.smd&&attr===window.smd.SRC; + const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE; + if(isHref&&/^(file|workspace|session):\/\//i.test(String(value||''))){ + baseSetAttr(data,attr,_smdLinkHref(value)); + if(/^session:\/\//i.test(String(value||''))){ + const node=data&&data.nodes&&data.nodes[data.index]; + if(node&&node.classList) node.classList.add('session-link'); + } + return; + } + if((isHref||isSrc)&&!safeUrl.test(String(value||''))){ + const node=data&&data.nodes&&data.nodes[data.index]; + if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1'); + return; + } + baseSetAttr(data,attr,value); + }; + return renderer; + } function _streamFadeWordCountOf(text){ const m=String(text||'').match(/\S+/g); return m?m.length:0; @@ -3784,7 +3814,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } let _lastRenderMs=0; - function _scheduleRender(){ + // Parse-result cache: _scheduleRender can accept a pre-computed _parseStreamState() + // from the token event handler, avoiding a duplicate O(n) scan inside _doRender + // when the rAF fires before the next token arrives. + let _cachedParsed=null; + let _cachedParsedText=''; + let _cachedParsedReasoning=''; + function _scheduleRender(parsed){ + // If caller provides a pre-computed parse result, cache it for _doRender. + if(parsed){ + _cachedParsed=parsed; + _cachedParsedText=assistantText; + _cachedParsedReasoning=liveReasoningText; + } if(_renderPending) return; if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized _renderPending=true; @@ -3803,7 +3845,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Guard: a pending setTimeout+rAF can outlive stream finalization. if(_streamFinalized) return; _lastRenderMs=performance.now(); - const parsed=_parseStreamState(); + const parsed=_cachedParsed&&_cachedParsedText===assistantText&&_cachedParsedReasoning===liveReasoningText ? _cachedParsed : _parseStreamState(); + _cachedParsed=null; _renderLiveThinking(parsed); if(assistantBody){ const displayText = segmentStart===0 @@ -3898,7 +3941,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const parsed=_parseStreamState(); if(_freshSegment) appendThinking('', _liveThinkingPlacement()); if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); - _scheduleRender(); + _scheduleRender(parsed); }); source.addEventListener('interim_assistant',e=>{ diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 781d8d66..58fd5d87 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -1301,7 +1301,7 @@ def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff(): assert f"_applyToAnchor('{event_name}'" in _event_listener_body(src, event_name) token_body = _event_listener_body(src, "token") - assert "_scheduleRender();" in token_body + assert "_scheduleRender(" in token_body assert "function _upsertAnchorProcessProse" in src assert "_upsertAnchorProcessProse(displayText" in src reasoning_body = _event_listener_body(src, "reasoning") diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py index 2e75c380..e5449dc6 100644 --- a/tests/test_streaming_markdown.py +++ b/tests/test_streaming_markdown.py @@ -197,10 +197,18 @@ class TestSmdHelpers: "_smdWrittenLen=0" in fn or "_smdWrittenLen = 0" in fn ), "_smdNewParser must reset _smdWrittenLen to 0" - def test_smd_new_parser_calls_default_renderer(self): + def test_smd_new_parser_calls_safe_renderer(self): fn = extract_fn(MESSAGES_JS, "_smdNewParser") - assert fn and "default_renderer" in fn, ( - "_smdNewParser must call smd.default_renderer() to create a renderer" + assert fn and ( + "_safeSmdRenderer(" in fn or "_streamFadeRenderer(" in fn + ), ( + "_smdNewParser must use _safeSmdRenderer or _streamFadeRenderer " + "so URL scheme safety is applied inline via set_attr hook" + ) + # Verify _safeSmdRenderer itself uses smd.default_renderer internally + safefn = extract_fn(MESSAGES_JS, "_safeSmdRenderer") + assert safefn and "default_renderer" in safefn, ( + "_safeSmdRenderer must call smd.default_renderer() to create the base renderer" ) def test_smd_new_parser_calls_parser(self): @@ -706,16 +714,41 @@ class TestSmdUrlSchemeSanitization: assert "_smdFileHref" in MESSAGES_JS assert "api/media?path=" in MESSAGES_JS - def test_sanitize_called_after_smd_write(self): - # _smdWrite must invoke _sanitizeSmdLinks on assistantBody after feeding the parser, - # so anchors/images created mid-stream get their javascript:/data:/vbscript: - # hrefs/srcs stripped before the user can click them. - fn = extract_fn(MESSAGES_JS, "_smdWrite") - assert fn, "_smdWrite function not found" - assert "_sanitizeSmdLinks" in fn, ( - "_smdWrite must call _sanitizeSmdLinks(assistantBody) after parser_write " - "so unsafe URL schemes are stripped from newly-added anchors/images " - "before the user can click them" + def test_url_safety_via_renderer_set_attr(self): + # URL scheme safety is now enforced inline by the renderer's set_attr + # hook (_safeSmdRenderer or _streamFadeRenderer) as smd creates each + # DOM node, eliminating the post-hoc _sanitizeSmdLinks O(DOM) scan + # per token that caused progressive streaming freeze on long answers. + safefn = extract_fn(MESSAGES_JS, "_safeSmdRenderer") + assert safefn, "_safeSmdRenderer must exist for URL safety on the non-fade path" + assert "set_attr" in safefn, ( + "_safeSmdRenderer must override set_attr to validate href/src inline" + ) + assert "_SMD_SAFE_URL_RE" in safefn, ( + "_safeSmdRenderer set_attr must use _SMD_SAFE_URL_RE for href safety" + ) + assert "_SMD_SAFE_IMG_URL_RE" in safefn, ( + "_safeSmdRenderer set_attr must use _SMD_SAFE_IMG_URL_RE for src safety" + ) + assert "data-blocked-scheme" in safefn, ( + "_safeSmdRenderer set_attr must set data-blocked-scheme on unsafe URLs" + ) + # _safeSmdRenderer algorithm must be the same as the proven + # _streamFadeRenderer set_attr (fade path has been in production). + fadefn = extract_fn(MESSAGES_JS, "_streamFadeRenderer") + assert fadefn and "set_attr" in fadefn, "_streamFadeRenderer must also have set_attr" + # Both renderers go through _smdRendererWithoutUnderscoreEmphasis so + # the set_attr hook is part of the final parser — verify the call chain. + newparser = extract_fn(MESSAGES_JS, "_smdNewParser") + assert newparser and "fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el)" in newparser, ( + "_smdNewParser must route non-fade to _safeSmdRenderer and fade to _streamFadeRenderer" + ) + # _sanitizeSmdLinks is still defined and used at parser_end as a final + # safety net — not removed, just no longer called on every token. + assert "_sanitizeSmdLinks" in MESSAGES_JS, "_sanitizeSmdLinks must still exist" + endfn = extract_fn(MESSAGES_JS, "_smdEndParser") + assert endfn and "_sanitizeSmdLinks" in endfn, ( + "_smdEndParser must still call _sanitizeSmdLinks as a final safety net" ) def test_sanitize_called_at_parser_end(self): diff --git a/tests/test_streaming_race_fix.py b/tests/test_streaming_race_fix.py index cfaea91c..b9a3ca24 100644 --- a/tests/test_streaming_race_fix.py +++ b/tests/test_streaming_race_fix.py @@ -42,7 +42,7 @@ class TestStreamFinalized: def test_schedule_render_guards_on_stream_finalized(self): src = read('static/messages.js') - m = re.search(r'function _scheduleRender\(\)\{.*?\n \}', src, re.DOTALL) + m = re.search(r'function _scheduleRender\([^)]*\)\{.*?\n \}', src, re.DOTALL) assert m, "_scheduleRender not found" fn = m.group(0) assert '_streamFinalized' in fn, (