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: '' }; });