From 678d12219a3c2fe953fb952101d6348c9acb26e0 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 12:15:10 +0300 Subject: [PATCH] fix(opencode): prevent Windows live runtime hangs --- scripts/lib/opencode-live-preflight.mjs | 44 ++++++++++++++++--- .../services/team/TeamProvisioningService.ts | 8 +++- .../team/TeamProvisioningService.test.ts | 19 ++++---- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index 73661131..a765c3ac 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -97,6 +97,7 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }); let output = ''; let spawnError = ''; @@ -138,12 +139,17 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { } } -function stopChild(child) { +async function stopChild(child) { + if (child.exitCode != null || child.killed) { + return; + } + + if (process.platform === 'win32' && child.pid) { + await taskkillProcessTree(child.pid); + return; + } + return new Promise((resolve) => { - if (child.exitCode != null || child.killed) { - resolve(); - return; - } const timeout = setTimeout(() => { if (child.exitCode == null) { child.kill('SIGKILL'); @@ -158,6 +164,34 @@ function stopChild(child) { }); } +function taskkillProcessTree(pid) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(finish, 5_000); + timeout.unref?.(); + try { + const taskkill = spawn( + path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/T', '/F', '/PID', String(pid)], + { + stdio: 'ignore', + windowsHide: true, + } + ); + taskkill.once('error', finish); + taskkill.once('close', finish); + } catch { + finish(); + } + }); +} + function allocateLoopbackPort() { return new Promise((resolve, reject) => { const server = net.createServer(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 833cb9a5..f1f1946c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -152,6 +152,10 @@ import * as path from 'path'; import pidusage from 'pidusage'; import * as readline from 'readline'; +// pidusage's Windows gwmi fallback needs a non-zero cache window to finish its +// initial two-sample pass. maxage: 0 can recurse forever on Windows. +const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + import { ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, type AnthropicTeamApiKeyHelperMaterial, @@ -15298,7 +15302,7 @@ export class TeamProvisioningService { let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { - const refreshedStat = await pidusage(rssPid, { maxage: 0 }); + const refreshedStat = await pidusage(rssPid, RUNTIME_PIDUSAGE_OPTIONS); if (Number.isFinite(refreshedStat.memory) && refreshedStat.memory >= 0) { rssBytesByPid.set(rssPid, refreshedStat.memory); rssBytes = refreshedStat.memory; @@ -25558,7 +25562,7 @@ export class TeamProvisioningService { } const rssBytesByPid = new Map(); - const options = { maxage: 0 }; + const options = RUNTIME_PIDUSAGE_OPTIONS; try { const statsByPid = await pidusage(uniquePids, options); for (const [rawPid, stat] of Object.entries(statsByPid)) { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index bd26e321..b8c8730b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -176,6 +176,9 @@ import { } from '@features/tmux-installer/main'; import pidusage from 'pidusage'; +const EXPECTED_RUNTIME_PIDUSAGE_OPTIONS = + process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -2490,7 +2493,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([111, 222], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 222], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members['team-lead']).toMatchObject({ pid: 111, rssBytes: 123_000_000, @@ -2630,9 +2633,9 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenNthCalledWith(1, [111, 222], { maxage: 0 }); - expect(pidusage).toHaveBeenNthCalledWith(2, 111, { maxage: 0 }); - expect(pidusage).toHaveBeenNthCalledWith(3, 222, { maxage: 0 }); + expect(pidusage).toHaveBeenNthCalledWith(1, [111, 222], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenNthCalledWith(2, 111, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenNthCalledWith(3, 222, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000); expect(snapshot.members.alice?.rssBytes).toBe(456_000_000); }); @@ -2744,7 +2747,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team'); - expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.alice).toMatchObject({ alive: true, providerId: 'anthropic', @@ -3256,8 +3259,8 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); - expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenCalledWith(333, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', alive: false, @@ -3332,7 +3335,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', alive: false,