From ced65062c87993cd4bef5a72354abb38b499165b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Pedro?= Date: Wed, 12 Aug 2026 10:19:29 +0100 Subject: [PATCH] fix: kill orphaned process on Windows before OpenCode restart killProcessOnPort() was a no-op on win32 (POSIX-only, via lsof/kill), so a restart could leave the old OpenCode process holding the port while a new instance spawned on a different one. That's a plausible contributor to a chronic pattern seen in production logs: repeated "OpenCode process exited, restarting" cycles and hundreds of ECONNRESET/proxy errors over multiple days on Windows. Give killProcessOnPort a real Windows branch: parse `netstat -ano` for PIDs listening on the target port, filter out our own pid, and force-kill each via `taskkill /PID /F` (no /T -- we don't own that process, so only the listener itself is killed, not any children it may have). waitForPortRelease()'s existing soft-fail-and-warn behavior is left untouched -- it's a deliberate safety net for any platform where the port doesn't free up in time, not just Windows, and the restart already rebinds event-stream readers to the actual resulting port via onOpenCodeRestarted. --- packages/web/server/lib/opencode/lifecycle.js | 38 ++++++++-- .../web/server/lib/opencode/lifecycle.test.js | 76 ++++++++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 03734316..6131f547 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -53,8 +53,35 @@ export const createOpenCodeLifecycleRuntime = (deps) => { now = Date.now, } = deps; + const killProcessOnPortWin32 = (port) => { + try { + const result = spawnSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000, windowsHide: true }); + const output = result.stdout || ''; + const myPid = process.pid; + const listeningPidPattern = /^\s*TCP\s+\S*:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/gim; + const pids = new Set(); + let match; + while ((match = listeningPidPattern.exec(output)) !== null) { + if (Number.parseInt(match[1], 10) !== port) continue; + const pid = Number.parseInt(match[2], 10); + if (pid && pid !== myPid) pids.add(pid); + } + for (const pid of pids) { + try { + spawnSync('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', timeout: 3000, windowsHide: true }); + } catch { + } + } + } catch { + } + }; + const killProcessOnPort = (port) => { - if (!port || process.platform === 'win32') return; + if (!port) return; + if (process.platform === 'win32') { + killProcessOnPortWin32(port); + return; + } try { const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); const output = result.stdout || ''; @@ -698,10 +725,11 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } // The restart may have landed on a NEW port (the old one can remain - // occupied by an orphaned process, e.g. Windows killProcessOnPort is a - // no-op). Upstream event readers pinned to the old process would keep - // the UI silent forever, so rebind them to the current port. Best - // effort: a failure here must not fail the restart itself. + // occupied if killProcessOnPort/waitForPortRelease didn't free it in + // time, on any platform). Upstream event readers pinned to the old + // process would keep the UI silent forever, so rebind them to the + // current port. Best effort: a failure here must not fail the restart + // itself. try { onOpenCodeRestarted?.(); } catch (error) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index a5a6d39d..84bbf29b 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -2,11 +2,12 @@ import { EventEmitter } from 'node:events'; import { afterEach, describe, expect, it, vi } from 'vitest'; const spawnMock = vi.fn(); +const spawnSyncMock = vi.fn(); const recordStartupPerformanceMock = vi.fn(); vi.mock('node:child_process', () => ({ spawn: spawnMock, - spawnSync: vi.fn(), + spawnSync: spawnSyncMock, })); vi.mock('./startup-performance.js', () => ({ recordStartupPerformance: recordStartupPerformanceMock, @@ -20,6 +21,7 @@ const originalFetch = globalThis.fetch; afterEach(() => { spawnMock.mockReset(); + spawnSyncMock.mockReset(); recordStartupPerformanceMock.mockReset(); globalThis.fetch = originalFetch; if (typeof originalOpencodeBinary === 'string') { @@ -613,3 +615,75 @@ describe('OpenCode lifecycle', () => { await server.close(); }); }); + +describe('killProcessOnPort on Windows', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + const setPlatform = (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + }; + + const netstatOutput = (port, pid) => [ + '', + 'Active Connections', + '', + ' Proto Local Address Foreign Address State PID', + ` TCP 0.0.0.0:${port} 0.0.0.0:0 LISTENING ${pid}`, + '', + ].join('\r\n'); + + it('force-kills the process listening on the target port via taskkill', () => { + setPlatform('win32'); + const orphanPid = 54321; + spawnSyncMock.mockImplementation((cmd) => { + if (cmd === 'netstat') { + return { stdout: netstatOutput(45678, orphanPid) }; + } + return { stdout: '' }; + }); + + const runtime = createRuntime(); + runtime.killProcessOnPort(45678); + + expect(spawnSyncMock).toHaveBeenCalledWith('netstat', ['-ano'], expect.objectContaining({ windowsHide: true })); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'taskkill', + ['/PID', String(orphanPid), '/F'], + expect.objectContaining({ windowsHide: true }) + ); + }); + + it('never force-kills its own process id', () => { + setPlatform('win32'); + spawnSyncMock.mockImplementation((cmd) => { + if (cmd === 'netstat') { + return { stdout: netstatOutput(45678, process.pid) }; + } + return { stdout: '' }; + }); + + const runtime = createRuntime(); + runtime.killProcessOnPort(45678); + + expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything()); + }); + + it('does nothing when no process is listening on the target port', () => { + setPlatform('win32'); + spawnSyncMock.mockImplementation((cmd) => { + if (cmd === 'netstat') { + return { stdout: netstatOutput(9999, 54321) }; + } + return { stdout: '' }; + }); + + const runtime = createRuntime(); + runtime.killProcessOnPort(45678); + + expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything()); + }); +});