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()); + }); +});