From 811df250abd5e09dd20748488f5138b793a127df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 08:33:48 +0000 Subject: [PATCH] fix(stream): skip oversized single envelopes, catch timeout clear, bypass fence for ack events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warning: the replay-cap \`and capped\` guard was letting the first envelope through even when its own size exceeded the cap, defeating the safety goal. Skip individual oversized envelopes entirely — the final done checkpoint reconciles missed content. Suggestion: the fence-fallback setTimeout called clearResumeFence fire-and-forget. Wrap in .catch() so any future throw path doesn't surface as an unhandled promise rejection. Suggestion: call-style events carrying an ack callback now bypass the replay fence. Holding them for up to RESUME_FENCE_TIMEOUT_MS (10s) could exceed the backend's WEBSOCKET_EVENT_CALLER_TIMEOUT, causing sio.call() to time out even though the client eventually processes the event. Ack events don't carry seq and don't mutate streamed content, so bypassing is safe against the replay race the fence exists to prevent. --- backend/open_webui/socket/main.py | 8 ++++++-- src/lib/components/chat/Chat.svelte | 17 +++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1b7f67ba50..95d7ca962d 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -789,7 +789,9 @@ async def resume_stream(sid, data): # Cap payload bytes. Keep the newest entries that fit; older ones # are either already in the DB-backed content or will arrive via the - # final done:True checkpoint. + # final done:True checkpoint. A single envelope larger than the cap + # is skipped rather than allowed through — forcing it past the cap + # would still exceed Socket.IO's buffer. total_bytes = 0 capped = [] for env in reversed(envelopes): @@ -797,7 +799,9 @@ async def resume_stream(sid, data): size = len(json.dumps(env)) except Exception: continue - if total_bytes + size > RESUME_STREAM_REPLAY_MAX_BYTES and capped: + if size > RESUME_STREAM_REPLAY_MAX_BYTES: + continue + if total_bytes + size > RESUME_STREAM_REPLAY_MAX_BYTES: break capped.append(env) total_bytes += size diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index c5dc02b012..68053ccd4e 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -451,12 +451,15 @@ let message = history.messages[event.message_id]; if (message) { - // Buffer live frames during replay; _replayed frames skip the fence. - // Store the ack callback alongside the event so Socket.IO - // call-style events (confirmation/execute/input) don't lose - // their response path when buffered and later replayed. + // Buffer live frames during replay; _replayed frames skip + // the fence. Call-style events that carry an ack callback + // ALSO skip the fence — the server is waiting on the ack + // and buffering could exceed WEBSOCKET_EVENT_CALLER_TIMEOUT + // (up to RESUME_FENCE_TIMEOUT_MS of delay). Ack events + // don't carry seq and don't mutate streamed content, so + // bypassing is safe against the original replay race. const queue = resumeQueueByMessageId.get(event.message_id); - if (queue && !event?._replayed) { + if (queue && !event?._replayed && !cb) { queue.push({ event, cb }); return; } @@ -701,7 +704,9 @@ message.id, setTimeout(() => { console.warn('resume-stream fence timed out for', message.id); - clearResumeFence(message.id); + clearResumeFence(message.id).catch((e) => + console.error('resume fence timeout flush failed', e) + ); }, RESUME_FENCE_TIMEOUT_MS) ); const requestId =