Files
openchamber/packages/web/server/lib/event-stream/global-hub.js
T
pasta-paulandBohdan Triapitsyn 1991736ebf fix(server): prevent streaming hang during long agent sessions (#1088)
* fix(server): increase WS buffer/replay limits and add backpressure warning

During long-running agent sessions (e.g. ultrawork loops with many tool
calls), the browser WebSocket client can briefly fall behind the server.
When the outbound buffer exceeds the limit, the server force-disconnects
with close code 1013, and the small replay buffer (512 events) is
insufficient to recover all missed events — leaving the UI permanently
stalled.

Changes:
- Raise MESSAGE_STREAM_WS_MAX_BUFFERED_BYTES from 4 MB to 16 MB to
  tolerate larger bursts without disconnecting
- Add MESSAGE_STREAM_WS_BACKPRESSURE_WARN_BYTES (12 MB) threshold that
  sends a one-shot "backpressure" frame to the client before the hard
  disconnect, giving it a chance to shed low-priority updates
- Raise MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT from 512 to 2048 so more
  events survive brief reconnection gaps
- Add tests for the backpressure warning behavior (emit, dedup, reset)

* fix(ui): batch event flushes under backpressure

---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-01 13:25:19 +03:00

146 lines
3.7 KiB
JavaScript

import { createUpstreamSseReader } from './upstream-reader.js';
// Raised from 512 → 2048 to improve recovery after brief disconnects during
// long-running agent sessions where many events accumulate quickly.
export const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
export function createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
fetchImpl = fetch,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs,
replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
}) {
const eventSubscribers = new Set();
const statusSubscribers = new Set();
const replay = [];
let controller = null;
let reader = null;
let connected = false;
let everConnected = false;
let buildUrlFailed = false;
const notifyStatus = (status) => {
for (const subscriber of Array.from(statusSubscribers)) {
subscriber(status);
}
};
const normalizeEvent = ({ envelope, payload }) => {
const directory =
typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
return {
envelope,
payload,
directory,
eventId,
};
};
const start = () => {
if (reader) {
return;
}
controller = new AbortController();
reader = createUpstreamSseReader({
signal: controller.signal,
stallTimeoutMs: upstreamStallTimeoutMs,
reconnectDelayMs: upstreamReconnectDelayMs,
fetchImpl,
buildUrl: () => {
buildUrlFailed = false;
try {
return new URL(buildOpenCodeUrl('/global/event', ''));
} catch {
buildUrlFailed = true;
throw new Error('OpenCode service unavailable');
}
},
getHeaders: getOpenCodeAuthHeaders,
onConnect() {
connected = true;
const wasReady = everConnected;
everConnected = true;
notifyStatus({ type: 'connect', wasReady });
},
onDisconnect({ reason }) {
connected = false;
notifyStatus({ type: 'disconnect', reason });
},
onEvent(event) {
const normalized = normalizeEvent(event);
if (normalized.eventId) {
replay.push(normalized);
if (replay.length > replayLimit) {
replay.splice(0, replay.length - replayLimit);
}
}
for (const subscriber of Array.from(eventSubscribers)) {
subscriber(normalized);
}
},
onError(error) {
if (controller?.signal.aborted) {
return;
}
notifyStatus({
type: everConnected ? 'error' : 'initial-error',
error,
buildUrlFailed,
});
},
});
void reader.start();
};
const stop = () => {
connected = false;
reader?.stop();
if (controller && !controller.signal.aborted) {
controller.abort();
}
reader = null;
controller = null;
everConnected = false;
buildUrlFailed = false;
};
return {
start,
stop,
isConnected() {
return connected;
},
hasConnected() {
return everConnected;
},
subscribeEvent(subscriber) {
eventSubscribers.add(subscriber);
return () => {
eventSubscribers.delete(subscriber);
};
},
subscribeStatus(subscriber) {
statusSubscribers.add(subscriber);
return () => {
statusSubscribers.delete(subscriber);
};
},
replayAfter(eventId) {
if (!eventId) {
return [];
}
const index = replay.findIndex((entry) => entry.eventId === eventId);
return index === -1 ? [] : replay.slice(index + 1);
},
};
}