Merge pull request #4806 from nesquena/stage/4800-stream-perf

Release v0.51.613 — eliminate O(DOM) streaming render freeze on long answers (#4800)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-23 16:20:37 -07:00
commit 2b00bad5ce
5 files changed
+104 -22

No files matched your search

+6
View File
@@ -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
+50 -7
View File
@@ -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=>{
@@ -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")
+46 -13
View File
@@ -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):
+1 -1
View File
@@ -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, (