fix(stream): skip oversized single envelopes, catch timeout clear, bypass fence for ack events

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.
This commit is contained in:
Claude 2026-04-15 08:33:48 +00:00
parent e6199613c3
commit 811df250ab
No known key found for this signature in database
2 changed files with 17 additions and 8 deletions

View file

@ -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

View file

@ -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 =