Files
openchamber/packages/web/server/lib/event-stream/runtime.js
T
Serhii Dziupin 13f6a0280d fix(server): rebind message-stream upstreams after a managed OpenCode restart
When the managed OpenCode process exits but a server survives on the old
port (Windows: killProcessOnPort is a no-op, so the orphaned process tree
keeps the port), restartOpenCode() times out waiting for the port and
spawns a fresh server on a NEW port. HTTP/proxy traffic follows the new
port, but the global message-stream hub's upstream SSE reader stays pinned
to the old server's /global/event stream — that connection never closes —
so new events never reach the UI and the chat stops updating until the
app is restarted (#2638).

Lifecycle now fires an optional onOpenCodeRestarted hook after a
successful managed restart; index.js wires it to the new
messageStreamRuntime.rebindUpstream(), which restarts the shared hub
(its reader re-dials buildOpenCodeUrl → the current port) and closes
directory-scoped sockets so their per-connection readers rebuild against
the new port. External servers are untouched (their port cannot change).

Fixes #2638
2026-08-05 13:59:17 +03:00

213 lines
6.3 KiB
JavaScript

import { WebSocketServer } from 'ws';
import { parseRequestPathname } from '../terminal/terminal-ws-protocol.js';
import {
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_GLOBAL_WS_PATH,
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
sendMessageStreamWsEvent,
} from './protocol.js';
import { createGlobalMessageStreamHub } from './global-hub.js';
import { createGlobalMessageStreamWsBridge } from './global-ws-bridge.js';
import { acceptDirectoryMessageStreamWsConnection } from './directory-ws-bridge.js';
import {
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
} from './upstream-reader.js';
export function createGlobalUiEventBroadcaster({
sseClients,
wsClients,
writeSseEvent,
}) {
return (payload, options = {}) => {
const hasSseClients = sseClients.size > 0;
const hasWsClients = wsClients.size > 0;
if (!hasSseClients && !hasWsClients) {
return;
}
if (hasSseClients) {
for (const res of sseClients) {
try {
writeSseEvent(res, payload);
} catch {
}
}
}
if (hasWsClients) {
for (const socket of Array.from(wsClients)) {
const sent = sendMessageStreamWsEvent(socket, payload, {
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
});
if (!sent) {
wsClients.delete(socket);
}
}
}
};
}
export function createMessageStreamWsRuntime({
server,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients,
triggerHealthCheck,
heartbeatIntervalMs = MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
upstreamStallTimeoutMs = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
upstreamReconnectDelayMs = DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
fetchImpl = fetch,
globalEventHub = null,
}) {
const wsServer = new WebSocketServer({
noServer: true,
});
// Directory-scoped streams create one upstream reader per client
// connection. Track those sockets so a managed OpenCode restart can close
// them: each reader is pinned to the port it connected at and would
// otherwise keep streaming from an orphaned process on the old port (#2638).
const directorySockets = new Set();
const ownsGlobalHub = !globalEventHub;
const globalHub = globalEventHub ?? createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
fetchImpl,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs,
});
const globalBridge = createGlobalMessageStreamWsBridge({
globalHub,
ownsGlobalHub,
wsClients,
processForwardedEventPayload,
triggerHealthCheck,
heartbeatIntervalMs,
});
wsServer.on('connection', (socket, req) => {
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
const pathname = parseRequestPathname(rawUrl);
const requestUrl = new URL(rawUrl, 'http://127.0.0.1');
const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
if (isGlobalStream) {
globalBridge.accept(socket, {
requestedLastEventId,
});
return;
}
directorySockets.add(socket);
socket.on('close', () => {
directorySockets.delete(socket);
});
acceptDirectoryMessageStreamWsConnection({
socket,
requestedLastEventId,
requestedDirectory,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients,
triggerHealthCheck,
heartbeatIntervalMs,
upstreamStallTimeoutMs,
upstreamReconnectDelayMs,
fetchImpl,
});
});
const upgradeHandler = (req, socket, head) => {
const pathname = parseRequestPathname(req.url);
if (pathname !== MESSAGE_STREAM_GLOBAL_WS_PATH && pathname !== MESSAGE_STREAM_DIRECTORY_WS_PATH) {
return;
}
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
if (!sessionToken) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
}
wsServer.handleUpgrade(req, socket, head, (ws) => {
wsServer.emit('connection', ws, req);
});
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
};
void handleUpgrade();
};
server.on('upgrade', upgradeHandler);
return {
wsServer,
/**
* Rebind all upstream readers to the current OpenCode port. Called after
* a managed process restart: the restart can land on a NEW port while
* the old process (or an orphaned survivor of it) still holds the
* previous one, and a healthy-but-pinned SSE connection never notices —
* so the UI would stop receiving events until the app restarts (#2638).
* Restarting the shared hub re-dials `buildOpenCodeUrl` (which reads the
* current port) on its next attempt; directory-scoped readers are
* rebuilt by closing their client sockets, which reconnect with
* `Last-Event-ID` and re-establish the stream against the new port.
*/
rebindUpstream() {
globalHub.stop();
globalHub.start();
for (const socket of Array.from(directorySockets)) {
try {
socket.close(1012, 'OpenCode upstream restarted');
} catch {
}
}
},
async close() {
server.off('upgrade', upgradeHandler);
globalBridge.close();
try {
for (const client of wsServer.clients) {
try {
client.terminate();
} catch {
}
}
await new Promise((resolve) => {
wsServer.close(() => resolve());
});
} catch {
} finally {
wsClients.clear();
}
},
};
}