From 678d12219a3c2fe953fb952101d6348c9acb26e0 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 12:15:10 +0300 Subject: [PATCH 1/3] 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, From 8fde8cefbfe91a0ece72d5d1e3cd5b01b62415c4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:45:33 +0300 Subject: [PATCH 2/3] fix(opencode): harden Windows live preflight cleanup --- scripts/lib/opencode-live-preflight.mjs | 90 ++++++++--- .../services/team/TeamProvisioningService.ts | 8 +- .../team/TeamProvisioningService.test.ts | 6 +- test/scripts/opencodeLivePreflight.test.ts | 147 ++++++++++++++++++ 4 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 test/scripts/opencodeLivePreflight.test.ts diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index a765c3ac..1fc7d734 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -4,6 +4,10 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; +const CHILD_CLOSE_GRACE_MS = 3_000; +const CHILD_FORCE_CLOSE_GRACE_MS = 1_000; +const TASKKILL_TIMEOUT_MS = 5_000; + export async function preflightOpenCodeLiveEnvironment(input) { const repoRoot = input.repoRoot; const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode'; @@ -139,44 +143,54 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { } } -async function stopChild(child) { - if (child.exitCode != null || child.killed) { +async function stopChild(child, options = {}) { + const platform = options.platform ?? process.platform; + const killProcessTree = options.killProcessTree ?? taskkillProcessTree; + const closeGraceMs = options.closeGraceMs ?? CHILD_CLOSE_GRACE_MS; + const forceCloseGraceMs = options.forceCloseGraceMs ?? CHILD_FORCE_CLOSE_GRACE_MS; + + if (hasChildExited(child)) { return; } - if (process.platform === 'win32' && child.pid) { - await taskkillProcessTree(child.pid); + if (platform === 'win32' && child.pid) { + await killProcessTree(child.pid); + } else if (!child.killed) { + sendChildSignal(child, 'SIGTERM'); + } + + if (await waitForChildClose(child, closeGraceMs)) { return; } - return new Promise((resolve) => { - const timeout = setTimeout(() => { - if (child.exitCode == null) { - child.kill('SIGKILL'); - } - resolve(); - }, 3_000); - child.once('close', () => { - clearTimeout(timeout); - resolve(); - }); - child.kill('SIGTERM'); - }); + if (!hasChildExited(child)) { + sendChildSignal(child, 'SIGKILL'); + if (!(await waitForChildClose(child, forceCloseGraceMs))) { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref?.(); + } + } } function taskkillProcessTree(pid) { return new Promise((resolve) => { let done = false; + let taskkill = null; const finish = () => { if (done) return; done = true; clearTimeout(timeout); resolve(); }; - const timeout = setTimeout(finish, 5_000); - timeout.unref?.(); + const timeout = setTimeout(() => { + if (taskkill) { + sendChildSignal(taskkill, 'SIGTERM'); + } + finish(); + }, TASKKILL_TIMEOUT_MS); try { - const taskkill = spawn( + taskkill = spawn( path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/T', '/F', '/PID', String(pid)], { @@ -184,6 +198,7 @@ function taskkillProcessTree(pid) { windowsHide: true, } ); + taskkill.unref?.(); taskkill.once('error', finish); taskkill.once('close', finish); } catch { @@ -192,6 +207,36 @@ function taskkillProcessTree(pid) { }); } +function waitForChildClose(child, timeoutMs) { + if (hasChildExited(child)) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let done = false; + const finish = (closed) => { + if (done) return; + done = true; + clearTimeout(timeout); + resolve(closed); + }; + const timeout = setTimeout(() => finish(false), timeoutMs); + child.once('close', () => finish(true)); + }); +} + +function hasChildExited(child) { + return child.exitCode != null || child.signalCode != null; +} + +function sendChildSignal(child, signal) { + try { + child.kill(signal); + } catch { + // Process may already be gone between liveness checks and the kill call. + } +} + function allocateLoopbackPort() { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -224,3 +269,8 @@ function skip(reason) { function compactOutput(value) { return value.replace(/\s+/g, ' ').trim().slice(0, 1_200); } + +export const __opencodeLivePreflightTestHooks = { + stopChild, + taskkillProcessTree, +}; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f1f1946c..516f8047 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -152,10 +152,6 @@ 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, @@ -576,6 +572,10 @@ import type { ToolCallMeta, } from '@shared/types'; +// 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 }; + const logger = createLogger('Service:TeamProvisioning'); const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b8c8730b..80e3d67e 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -15453,9 +15453,9 @@ describe('TeamProvisioningService', () => { }); expect(spawnCli).toHaveBeenCalled(); - expect(progressUpdates[0]?.warnings).toEqual(expect.arrayContaining([ - expect.stringContaining('9 primary teammates'), - ])); + expect(progressUpdates[0]?.warnings).toEqual( + expect.arrayContaining([expect.stringContaining('9 primary teammates')]) + ); expect(progressUpdates[0]?.warnings?.join('\n')).toContain('Launches above 8 teammates'); }); diff --git a/test/scripts/opencodeLivePreflight.test.ts b/test/scripts/opencodeLivePreflight.test.ts new file mode 100644 index 00000000..0176d371 --- /dev/null +++ b/test/scripts/opencodeLivePreflight.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node + +import { EventEmitter } from 'events'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +interface StopChildOptions { + platform?: string; + killProcessTree?: (pid: number) => Promise; + closeGraceMs?: number; + forceCloseGraceMs?: number; +} + +interface OpenCodeLivePreflightTestHooks { + __opencodeLivePreflightTestHooks: { + stopChild(child: FakeChild, options?: StopChildOptions): Promise; + taskkillProcessTree(pid: number): Promise; + }; +} + +const runOnPosix = process.platform === 'win32' ? it.skip : it; + +describe('opencode live preflight cleanup', () => { + let tempDir = ''; + const originalSystemRoot = process.env.SystemRoot; + const originalTaskkillArgsPath = process.env.FAKE_TASKKILL_ARGS_PATH; + + afterEach(async () => { + restoreEnvValue('SystemRoot', originalSystemRoot); + restoreEnvValue('FAKE_TASKKILL_ARGS_PATH', originalTaskkillArgsPath); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + it('waits for child close after Windows process-tree cleanup', async () => { + const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + const child = new FakeChild({ pid: 1234 }); + const killProcessTree = vi.fn(() => { + child.signalCode = 'SIGTERM'; + child.emit('close'); + return Promise.resolve(); + }); + + await stopChild(child, { + closeGraceMs: 5, + forceCloseGraceMs: 5, + killProcessTree, + platform: 'win32', + }); + + expect(killProcessTree).toHaveBeenCalledWith(1234); + expect(child.kill).not.toHaveBeenCalled(); + expect(child.stdout.destroy).not.toHaveBeenCalled(); + expect(child.unref).not.toHaveBeenCalled(); + }); + + it('detaches pipes when Windows process-tree cleanup and direct kill both fail to close', async () => { + const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + const child = new FakeChild({ pid: 5678 }); + const killProcessTree = vi.fn(() => Promise.resolve()); + + await stopChild(child, { + closeGraceMs: 1, + forceCloseGraceMs: 1, + killProcessTree, + platform: 'win32', + }); + + expect(killProcessTree).toHaveBeenCalledWith(5678); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(child.stdout.destroy).toHaveBeenCalled(); + expect(child.stderr.destroy).toHaveBeenCalled(); + expect(child.unref).toHaveBeenCalled(); + }); + + runOnPosix('invokes taskkill.exe with process-tree flags', async () => { + const { taskkillProcessTree } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-taskkill-test-')); + const system32Dir = path.join(tempDir, 'System32'); + const taskkillArgsPath = path.join(tempDir, 'taskkill-args.txt'); + + await fs.mkdir(system32Dir, { recursive: true }); + await writeExecutable(path.join(system32Dir, 'taskkill.exe'), fakeTaskkillScript()); + process.env.SystemRoot = tempDir; + process.env.FAKE_TASKKILL_ARGS_PATH = taskkillArgsPath; + + await taskkillProcessTree(4242); + + await expect(fs.readFile(taskkillArgsPath, 'utf8')).resolves.toBe('/T /F /PID 4242\n'); + }); +}); + +class FakeChild extends EventEmitter { + readonly kill = vi.fn(); + readonly stderr = { destroy: vi.fn() }; + readonly stdout = { destroy: vi.fn() }; + readonly unref = vi.fn(); + exitCode: number | null = null; + killed = false; + pid: number; + signalCode: string | null = null; + + constructor(input: { pid: number }) { + super(); + this.pid = input.pid; + this.kill.mockImplementation((signal: string) => { + this.killed = true; + return signal === 'SIGKILL'; + }); + } +} + +async function loadTestHooks(): Promise { + const moduleUrl = pathToFileURL( + path.join(process.cwd(), 'scripts/lib/opencode-live-preflight.mjs') + ).href; + return (await import(`${moduleUrl}?t=${Date.now()}`)) as OpenCodeLivePreflightTestHooks; +} + +async function writeExecutable(filePath: string, content: string): Promise { + await fs.writeFile(filePath, content, 'utf8'); + // eslint-disable-next-line sonarjs/file-permissions -- The taskkill fixture must be executable for child_process.spawn. + await fs.chmod(filePath, 0o755); +} + +function fakeTaskkillScript(): string { + return `#!/usr/bin/env node +const fs = require('node:fs'); + +fs.writeFileSync(process.env.FAKE_TASKKILL_ARGS_PATH, process.argv.slice(2).join(' ') + '\\n'); +process.exit(0); +`; +} + +function restoreEnvValue(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} From e6b9490c449a10d8c146e9eba59fb0a1b17d9bd6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 13:09:33 +0300 Subject: [PATCH 3/3] fix(opencode): widen Windows pidusage cache window --- src/main/services/team/TeamProvisioningService.ts | 7 ++++--- test/main/services/team/TeamProvisioningService.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 516f8047..d55d0e91 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -572,9 +572,10 @@ import type { ToolCallMeta, } from '@shared/types'; -// 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 }; +// pidusage's Windows wmic/gwmi fallback needs a non-zero cache window to finish +// its initial two-sample pass. Keep this above slow PowerShell startup time, or +// the first sample can expire before the recursive second read and loop again. +const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; const logger = createLogger('Service:TeamProvisioning'); const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 80e3d67e..ad7b7129 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -177,7 +177,7 @@ import { import pidusage from 'pidusage'; const EXPECTED_RUNTIME_PIDUSAGE_OPTIONS = - process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {});