* 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>
146 lines
3.7 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|