fix: improve event stream reconnect reliability
Recover stalled event streams without dropping the session Wait briefly for reconnection before showing connection lost errors Persist Electron server logs for easier disconnect debugging
This commit is contained in:
@@ -1,6 +1,68 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGlobalUiEventBroadcaster } from './runtime.js';
|
||||
import { createGlobalUiEventBroadcaster, createMessageStreamWsRuntime } from './runtime.js';
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.readyState = 1;
|
||||
this.sent = [];
|
||||
this.closeCalls = [];
|
||||
}
|
||||
|
||||
send(payload) {
|
||||
this.sent.push(JSON.parse(payload));
|
||||
}
|
||||
|
||||
ping() {
|
||||
void 0;
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
if (this.readyState === 3) {
|
||||
return;
|
||||
}
|
||||
this.readyState = 3;
|
||||
this.closeCalls.push({ code, reason });
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (index < blocks.length) {
|
||||
const next = blocks[index++];
|
||||
return { value: encoder.encode(next), done: false };
|
||||
}
|
||||
|
||||
if (!holdOpen) {
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('event stream broadcaster', () => {
|
||||
it('fans out synthetic events to SSE and WS clients', () => {
|
||||
@@ -63,3 +125,71 @@ describe('event stream broadcaster', () => {
|
||||
expect(wsClients.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message stream websocket runtime', () => {
|
||||
it('reconnects a stalled upstream SSE stream and resumes from the last event id', async () => {
|
||||
const server = new EventEmitter();
|
||||
const wsClients = new Set();
|
||||
let triggerHealthCheckCalls = 0;
|
||||
const fetchCalls = [];
|
||||
let upstreamAttempt = 0;
|
||||
|
||||
const runtime = createMessageStreamWsRuntime({
|
||||
server,
|
||||
uiAuthController: null,
|
||||
isRequestOriginAllowed: async () => true,
|
||||
rejectWebSocketUpgrade() {
|
||||
throw new Error('upgrade should not be used in this test');
|
||||
},
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
processForwardedEventPayload() {},
|
||||
wsClients,
|
||||
triggerHealthCheck: () => {
|
||||
triggerHealthCheckCalls += 1;
|
||||
},
|
||||
heartbeatIntervalMs: 50,
|
||||
upstreamStallTimeoutMs: 20,
|
||||
upstreamReconnectDelayMs: 0,
|
||||
fetchImpl: async (_url, options) => {
|
||||
const lastEventId = options?.headers?.['Last-Event-ID'] ?? null;
|
||||
fetchCalls.push(lastEventId);
|
||||
upstreamAttempt += 1;
|
||||
|
||||
if (upstreamAttempt === 1) {
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
holdOpen: true,
|
||||
blocks: [
|
||||
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
holdOpen: false,
|
||||
blocks: [
|
||||
'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n',
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const socket = new FakeSocket();
|
||||
runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
|
||||
const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
|
||||
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
|
||||
|
||||
expect(readyFrames).toHaveLength(1);
|
||||
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
|
||||
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
|
||||
expect(triggerHealthCheckCalls).toBe(0);
|
||||
|
||||
socket.close();
|
||||
await runtime.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user