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
This commit is contained in:
Serhii Dziupin
2026-08-05 13:59:17 +03:00
parent 34c221b07f
commit 13f6a0280d
8 changed files with 881 additions and 0 deletions
@@ -70,6 +70,12 @@ export function createMessageStreamWsRuntime({
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,
@@ -103,6 +109,11 @@ export function createMessageStreamWsRuntime({
return;
}
directorySockets.add(socket);
socket.on('close', () => {
directorySockets.delete(socket);
});
acceptDirectoryMessageStreamWsConnection({
socket,
requestedLastEventId,
@@ -156,6 +167,27 @@ export function createMessageStreamWsRuntime({
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();