From ed922f4f182c01c838d0efed7211f74ec4889aa6 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 18:02:43 +0200 Subject: [PATCH] perf(stream): throttle live-turn snapshot and skip redundant per-token parse (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two O(n^2) costs on the per-token/per-frame streaming path, both over the growing answer: WS2.2 — snapshotLiveTurn() serialized the whole live turn via turn.outerHTML on every render frame. That snapshot only backs mid-stream session-switch restore, and the switch path (sessions.js) plus tool/done event boundaries already capture synchronously, so the per-frame call is throttled to a coarse trailing snapshot instead of serializing the entire growing DOM each frame. WS2.3 — the token handler ran _parseStreamState() (strip tool-call XML + extract inline thinking over the FULL accumulated text) on every token, but _doRender re-parses once per throttled frame anyway and the only per-token use is the assistant-row creation gate. Skip the parse once the row exists (the common case); keep it only until the row is created. Also drop the full-string toLowerCase() allocation in _stripXmlToolCalls for a non-allocating regex test. Output-neutral: same rendered DOM; the row-creation gate and snapshot semantics are preserved (row appears within one 66ms frame; switch/settle snapshots stay synchronous). node --check + ESLint runtime guard clean. Co-Authored-By: Claude Opus 4.8 --- static/messages.js | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/static/messages.js b/static/messages.js index 85087d64..69de54f1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2122,6 +2122,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function snapshotLiveTurn(){ if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid); } + // Throttled per-frame variant. snapshotLiveTurnHtmlForSession serializes the + // whole (growing) live turn via turn.outerHTML — O(n)/frame -> O(n^2) over a + // long answer, and a real GC-pressure source. The snapshot only backs + // mid-stream session-switch restore, and the switch path (sessions.js) plus + // the stream event boundaries (tool/done) already capture synchronously, so a + // coarse trailing snapshot during streaming is sufficient. (#5455 WS2.2) + let _snapshotLiveTurnTimer=null; + function _throttledSnapshotLiveTurn(){ + if(_snapshotLiveTurnTimer) return; + _snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700); + } // Throttled variant for token-by-token updates. persistInflightState() // calls saveInflightState() which does JSON.parse + JSON.stringify + write // on the entire inflight map every call. On a fast model at 60 tok/s with @@ -3789,8 +3800,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Also handles DSML-prefixed variants from DeepSeek/Bedrock, including // spacing variants like "<|DSML |function_calls" and truncated prefixes. if(!s) return s; - const lo=String(s).toLowerCase(); - if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s; + // Case-insensitive presence check without allocating a full lowercased copy + // of the (growing) text on every call — cuts per-token/per-frame GC pressure. + // Equivalent to the previous toLowerCase()+indexOf gate. (#5455 WS2.3) + if(!/function_calls|dsml/i.test(String(s))) return s; // Support both plain and DSML-prefixed variants. s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,''); // Also remove truncated opening tags (missing closing ">" at stream tail). @@ -4639,7 +4652,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText); } scrollIfPinned(); - snapshotLiveTurn(); + _throttledSnapshotLiveTurn(); }; const frameIntervalMs=_shouldUseStreamFade()?33:66; if(sinceLastMs>=frameIntervalMs){ @@ -4695,10 +4708,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ syncInflightAssistantMessage(); if(!S.session||S.session.session_id!==activeSid) return; _completeAutomaticCompressionOnLiveProgress(activeSid); - const parsed=_parseStreamState(); if(_freshSegment) appendThinking('', _liveThinkingPlacement()); - if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); - _scheduleRender(parsed); + // Once the assistant row exists its creation gate is already satisfied, and + // the throttled _doRender re-parses once per frame anyway — so the per-token + // full-text parse here is pure waste (O(n)/token -> O(n^2) over the answer). + // Still call ensureAssistantRow() every token exactly as before (cheap; it + // also starts a new segment on a post-tool _freshSegment). Only the parse is + // skipped, and only once the row exists. (#5455 WS2.3) + if(assistantRow){ + ensureAssistantRow(); + _scheduleRender(); + }else{ + const parsed=_parseStreamState(); + if(String((parsed&&parsed.displayText)||'').trim()) ensureAssistantRow(); + _scheduleRender(parsed); + } }); source.addEventListener('interim_assistant',e=>{