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
213 lines
6.3 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|