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 1/5] 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()); + }); +}); From cc4792a7e00bc05b0fd6ddb7ff050347918fb610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Pedro?= Date: Wed, 12 Aug 2026 10:56:20 +0100 Subject: [PATCH 2/5] chore: retry review after prior run hit CI timeout mid-review (582s, no verdict posted) From d06329bca16bb8d136570cdcdac8672944f86011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Pedro?= Date: Wed, 12 Aug 2026 11:04:19 +0100 Subject: [PATCH 3/5] chore: retry review again after second CI timeout mid-review From a1a1cfb93d23ae7a6afa0104222166df922404ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Pedro?= Date: Wed, 12 Aug 2026 11:50:39 +0100 Subject: [PATCH 4/5] fix: use Get-NetTCPConnection for locale-independent port lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The netstat-based parser matched the literal English "LISTENING" state string, which is translated on non-English Windows (e.g. "ABHÖREN", "ÉCOUTE", "ESCUTANDO"). On those systems the regex matched zero lines, so killProcessOnPort silently did nothing -- fail-open, not a regression, but ineffective for the exact users the fix targets. Replace it with `Get-NetTCPConnection -State Listen -LocalPort `, which reads the same underlying WinNT API netstat's display layer translates, so it's unaffected by OS display language. Verified against a real listening port on this machine (matched the actual owning PID). Also fixed a stale duplicate of the "killProcessOnPort is a no-op on Windows" comment left behind in server/index.js. --- packages/web/server/index.js | 4 +-- packages/web/server/lib/opencode/lifecycle.js | 22 ++++++++++----- .../web/server/lib/opencode/lifecycle.test.js | 27 ++++++++----------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c4783f4e..c4b830c9 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1080,8 +1080,8 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ return [...new Set(directories)]; }, // A managed restart can move OpenCode to a NEW port (the old one may stay - // occupied by an orphaned process, e.g. killProcessOnPort is a no-op on - // Windows). Rebind the message-stream upstream readers to the current port + // occupied if killProcessOnPort/waitForPortRelease didn't free it in time, + // on any platform). Rebind the message-stream upstream readers to the current port // so the UI keeps receiving events instead of staying pinned to the old // process (#2638). The runtime is created later by the startup pipeline; // by the time any restart runs, it is assigned. diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 6131f547..8fdacfb7 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -55,15 +55,25 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const killProcessOnPortWin32 = (port) => { try { - const result = spawnSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000, windowsHide: true }); + // Get-NetTCPConnection reads the same locale-independent WinNT API + // netstat's display layer translates (e.g. "LISTENING" renders as + // "ABHÖREN"/"ÉCOUTE"/"ESCUTANDO" on non-English Windows), so this + // works regardless of the OS display language. + const result = spawnSync( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Get-NetTCPConnection -State Listen -LocalPort ${Number.parseInt(port, 10)} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`, + ], + { 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); + for (const line of output.split(/\r?\n/)) { + const pid = Number.parseInt(line.trim(), 10); if (pid && pid !== myPid) pids.add(pid); } for (const pid of pids) { diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 84bbf29b..f07c9b5e 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -627,21 +627,12 @@ describe('killProcessOnPort on Windows', () => { 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) }; + if (cmd === 'powershell') { + return { stdout: `${orphanPid}\r\n` }; } return { stdout: '' }; }); @@ -649,7 +640,11 @@ describe('killProcessOnPort on Windows', () => { const runtime = createRuntime(); runtime.killProcessOnPort(45678); - expect(spawnSyncMock).toHaveBeenCalledWith('netstat', ['-ano'], expect.objectContaining({ windowsHide: true })); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'powershell', + expect.arrayContaining([expect.stringContaining('-LocalPort 45678')]), + expect.objectContaining({ windowsHide: true }) + ); expect(spawnSyncMock).toHaveBeenCalledWith( 'taskkill', ['/PID', String(orphanPid), '/F'], @@ -660,8 +655,8 @@ describe('killProcessOnPort on Windows', () => { it('never force-kills its own process id', () => { setPlatform('win32'); spawnSyncMock.mockImplementation((cmd) => { - if (cmd === 'netstat') { - return { stdout: netstatOutput(45678, process.pid) }; + if (cmd === 'powershell') { + return { stdout: `${process.pid}\r\n` }; } return { stdout: '' }; }); @@ -675,8 +670,8 @@ describe('killProcessOnPort on Windows', () => { 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) }; + if (cmd === 'powershell') { + return { stdout: '' }; } return { stdout: '' }; }); From b6b41c18dc3dbb5891cfb44983aeb3bf3d1a3523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Pedro?= Date: Wed, 12 Aug 2026 12:09:15 +0100 Subject: [PATCH 5/5] chore: retry review after enforcement-script HEAD-matching bug (verdict was PASS)