From 636dcd5314a2463af8f951fb909f1b98cf1789cf Mon Sep 17 00:00:00 2001 From: "Dr. Zed" <142888684+DocterZed@users.noreply.github.com> Date: Sun, 12 Apr 2026 07:29:09 +1200 Subject: [PATCH] fix: improve Windows managed OpenCode shutdown and launch behavior (#844) * fix: windows shutdown and restart orphaned cleanup * fix: launch managed OpenCode directly on Windows Unwrap OpenCode wrappers to launch directly on Windows, improving shutdown reliability and avoid orphans. * fix: restore desktopNotifyEnabled in health snapshot --------- Signed-off-by: Dr. Zed <142888684+DocterZed@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/lib/openCodeStatus.ts | 30 ++- packages/web/bin/cli.js | 143 ++++++++++--- packages/web/server/index.js | 57 +++--- .../web/server/lib/opencode/DOCUMENTATION.md | 5 +- .../web/server/lib/opencode/env-runtime.js | 190 +++++++++++++++++- packages/web/server/lib/opencode/lifecycle.js | 171 +++++++++++++--- .../opencode/opencode-resolution-runtime.js | 10 +- .../server/lib/opencode/shutdown-runtime.js | 6 +- 8 files changed, 512 insertions(+), 100 deletions(-) diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 2de11c9a..8a2e1eae 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -20,7 +20,9 @@ type OpenChamberHealthSnapshot = { lastOpenCodeError?: unknown; opencodeBinaryResolved?: unknown; opencodeBinarySource?: unknown; - opencodeShimInterpreter?: unknown; + opencodeLaunchBinary?: unknown; + opencodeLaunchArgs?: unknown; + opencodeLaunchWrapperType?: unknown; nodeBinaryResolved?: unknown; bunBinaryResolved?: unknown; }; @@ -32,7 +34,9 @@ type OpenChamberOpencodeResolution = { source?: unknown; detectedNow?: unknown; detectedSourceNow?: unknown; - shim?: unknown; + launchBinary?: unknown; + launchArgs?: unknown; + launchWrapperType?: unknown; node?: unknown; bun?: unknown; }; @@ -273,10 +277,20 @@ export const buildOpenCodeStatusReport = async (): Promise => { openChamberOpencodeResolution && typeof openChamberOpencodeResolution.source === 'string' ? openChamberOpencodeResolution.source : (openChamberHealth && typeof openChamberHealth.opencodeBinarySource === 'string' ? openChamberHealth.opencodeBinarySource : ''); - const shim = - openChamberOpencodeResolution && typeof openChamberOpencodeResolution.shim === 'string' - ? openChamberOpencodeResolution.shim - : (openChamberHealth && typeof openChamberHealth.opencodeShimInterpreter === 'string' ? openChamberHealth.opencodeShimInterpreter : ''); + const launchBinary = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.launchBinary === 'string' + ? openChamberOpencodeResolution.launchBinary + : (openChamberHealth && typeof openChamberHealth.opencodeLaunchBinary === 'string' ? openChamberHealth.opencodeLaunchBinary : ''); + const launchWrapperType = + openChamberOpencodeResolution && typeof openChamberOpencodeResolution.launchWrapperType === 'string' + ? openChamberOpencodeResolution.launchWrapperType + : (openChamberHealth && typeof openChamberHealth.opencodeLaunchWrapperType === 'string' ? openChamberHealth.opencodeLaunchWrapperType : ''); + const launchArgs = + openChamberOpencodeResolution && Array.isArray(openChamberOpencodeResolution.launchArgs) + ? openChamberOpencodeResolution.launchArgs.filter((value): value is string => typeof value === 'string') + : (openChamberHealth && Array.isArray(openChamberHealth.opencodeLaunchArgs) + ? openChamberHealth.opencodeLaunchArgs.filter((value): value is string => typeof value === 'string') + : []); const node = openChamberOpencodeResolution && typeof openChamberOpencodeResolution.node === 'string' ? openChamberOpencodeResolution.node @@ -310,7 +324,9 @@ export const buildOpenCodeStatusReport = async (): Promise => { lines.push(`- detected-now: ${detectedNow}`); lines.push(`- detected-source: ${detectedSourceNow || '(n/a)'}`); } - lines.push(`- shim: ${shim || '(n/a)'}`); + lines.push(`- launch-binary: ${launchBinary || '(n/a)'}`); + lines.push(`- launch-wrapper: ${launchWrapperType || '(n/a)'}`); + lines.push(`- launch-args: ${launchArgs.length ? launchArgs.join(' ') : '(none)'}`); lines.push(`- node: ${node || '(n/a)'}`); lines.push(`- bun: ${bun || '(n/a)'}`); if (!openChamberOpencodeResolution && openChamberOpencodeResolutionResult.error) { diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 6756c049..f4527608 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -1784,6 +1784,99 @@ function isProcessRunning(pid) { } } +function waitForProcessExit(pid, timeoutMs) { + if (!Number.isFinite(pid) || pid <= 0) { + return Promise.resolve(true); + } + + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const check = () => { + if (!isProcessRunning(pid)) { + resolve(true); + return; + } + if (Date.now() >= deadline) { + resolve(false); + return; + } + setTimeout(check, 150); + }; + check(); + }); +} + +async function terminateProcessTree(pid, options = {}) { + if (!Number.isFinite(pid) || pid <= 0) { + return true; + } + + const gracefulTimeoutMs = Number.isFinite(options.gracefulTimeoutMs) && options.gracefulTimeoutMs >= 0 + ? Math.trunc(options.gracefulTimeoutMs) + : 2500; + const forceTimeoutMs = Number.isFinite(options.forceTimeoutMs) && options.forceTimeoutMs >= 0 + ? Math.trunc(options.forceTimeoutMs) + : 3000; + + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/pid', String(pid), '/t'], { + stdio: 'ignore', + timeout: 3000, + windowsHide: true, + }); + } catch { + } + + if (await waitForProcessExit(pid, gracefulTimeoutMs)) { + return true; + } + + try { + spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { + stdio: 'ignore', + timeout: 5000, + windowsHide: true, + }); + } catch { + } + + return waitForProcessExit(pid, forceTimeoutMs); + } + + try { + process.kill(pid, 'SIGTERM'); + } catch { + } + + if (await waitForProcessExit(pid, gracefulTimeoutMs)) { + return true; + } + + try { + process.kill(pid, 'SIGKILL'); + } catch { + } + + return waitForProcessExit(pid, forceTimeoutMs); +} + +async function stopInstanceProcess(pid, options = {}) { + if (!Number.isFinite(pid) || pid <= 0) { + return true; + } + + const shutdownWaitMs = Number.isFinite(options.shutdownWaitMs) && options.shutdownWaitMs >= 0 + ? Math.trunc(options.shutdownWaitMs) + : 5000; + + if (await waitForProcessExit(pid, shutdownWaitMs)) { + return true; + } + + return terminateProcessTree(pid, options); +} + async function requestServerShutdown(port) { if (!Number.isFinite(port) || port <= 0) return false; const controller = new AbortController(); @@ -3060,18 +3153,11 @@ const commands = { const requested = await requestServerShutdown(options.port); if (Number.isFinite(systemInfo.pid) && isProcessRunning(systemInfo.pid)) { - try { - process.kill(systemInfo.pid, 'SIGTERM'); - let attempts = 0; - while (isProcessRunning(systemInfo.pid) && attempts < 20) { - await new Promise((resolve) => setTimeout(resolve, 250)); - attempts++; - } - if (isProcessRunning(systemInfo.pid)) { - process.kill(systemInfo.pid, 'SIGKILL'); - } - } catch { - } + await stopInstanceProcess(systemInfo.pid, { + shutdownWaitMs: requested ? 5000 : 0, + gracefulTimeoutMs: 2500, + forceTimeoutMs: 3000, + }).catch(() => false); } const stopped = await isPortAvailable(options.port); @@ -3142,15 +3228,14 @@ const commands = { } stopSpin?.start(`Stopping OpenChamber on port ${instance.port}...`); try { - await requestServerShutdown(instance.port); - process.kill(instance.pid, 'SIGTERM'); - let attempts = 0; - while (isProcessRunning(instance.pid) && attempts < 20) { - await new Promise((resolve) => setTimeout(resolve, 250)); - attempts++; - } - if (isProcessRunning(instance.pid)) { - process.kill(instance.pid, 'SIGKILL'); + const requested = await requestServerShutdown(instance.port); + const stopped = await stopInstanceProcess(instance.pid, { + shutdownWaitMs: requested ? 5000 : 0, + gracefulTimeoutMs: 2500, + forceTimeoutMs: 3000, + }); + if (!stopped && isProcessRunning(instance.pid)) { + throw new Error(`Timed out stopping pid ${instance.pid}`); } removePidFile(instance.pidFilePath); removeInstanceFile(instance.instanceFilePath); @@ -4667,16 +4752,12 @@ const commands = { updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`); for (const instance of runningInstances) { try { - await requestServerShutdown(instance.port); - process.kill(instance.pid, 'SIGTERM'); - let attempts = 0; - while (isProcessRunning(instance.pid) && attempts < 20) { - await new Promise((resolve) => setTimeout(resolve, 250)); - attempts++; - } - if (isProcessRunning(instance.pid)) { - process.kill(instance.pid, 'SIGKILL'); - } + const requested = await requestServerShutdown(instance.port); + await stopInstanceProcess(instance.pid, { + shutdownWaitMs: requested ? 5000 : 0, + gracefulTimeoutMs: 2500, + forceTimeoutMs: 3000, + }); removePidFile(instance.pidFilePath); } catch { } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index c9d4fc33..9401e08d 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -520,14 +520,14 @@ const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args); const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args); const resolveWslExecutablePath = (...args) => openCodeEnvRuntime.resolveWslExecutablePath(...args); const buildWslExecArgs = (...args) => openCodeEnvRuntime.buildWslExecArgs(...args); -const opencodeShimInterpreter = (...args) => openCodeEnvRuntime.opencodeShimInterpreter(...args); +const resolveManagedOpenCodeLaunchSpec = (...args) => openCodeEnvRuntime.resolveManagedOpenCodeLaunchSpec(...args); const clearResolvedOpenCodeBinary = (...args) => openCodeEnvRuntime.clearResolvedOpenCodeBinary(...args); const openCodeResolutionRuntime = createOpenCodeResolutionRuntime({ path, resolveOpencodeCliPath, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, - opencodeShimInterpreter, + resolveManagedOpenCodeLaunchSpec, getResolvedState: () => ({ resolvedOpencodeBinary, resolvedOpencodeBinarySource, @@ -740,7 +740,7 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ ensureLocalOpenCodeServerPassword, buildWslExecArgs, resolveWslExecutablePath, - opencodeShimInterpreter, + resolveManagedOpenCodeLaunchSpec, setOpenCodePort, setDetectedOpenCodeApiPrefix, setupProxy: (...args) => setupProxy(...args), @@ -778,6 +778,7 @@ const bootstrapOpenCodeAtStartup = async (...args) => { } }; const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); +const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args); const fetchAgentsSnapshot = (...args) => serverUtilsRuntime.fetchAgentsSnapshot(...args); const fetchProvidersSnapshot = (...args) => serverUtilsRuntime.fetchProvidersSnapshot(...args); @@ -807,6 +808,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({ openCodeProcess = value; }, killProcessOnPort, + waitForPortRelease, getServer: () => server, getUiAuthController: () => uiAuthController, setUiAuthController: (value) => { @@ -874,27 +876,34 @@ async function main(options = {}) { runtimeName: process.env.OPENCHAMBER_RUNTIME || 'web', serverStartedAt, gracefulShutdown, - getHealthSnapshot: () => ({ - openCodePort, - openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), - openCodeSecureConnection: isOpenCodeConnectionSecure(), - openCodeAuthSource: openCodeAuthSource || null, - openCodeApiPrefix: '', - openCodeApiPrefixDetected: true, - isOpenCodeReady, - lastOpenCodeError, - opencodeBinaryResolved: resolvedOpencodeBinary || null, - opencodeBinarySource: resolvedOpencodeBinarySource || null, - opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null, - opencodeViaWsl: useWslForOpencode, - opencodeWslBinary: resolvedWslBinary || null, - opencodeWslPath: resolvedWslOpencodePath || null, - opencodeWslDistro: resolvedWslDistro || null, - nodeBinaryResolved: resolvedNodeBinary || null, - bunBinaryResolved: resolvedBunBinary || null, - desktopNotifyEnabled: ENV_DESKTOP_NOTIFY, - planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED, - }), + getHealthSnapshot: () => { + const launchSpec = resolvedOpencodeBinary && !useWslForOpencode + ? resolveManagedOpenCodeLaunchSpec(resolvedOpencodeBinary) + : null; + return { + openCodePort, + openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), + openCodeSecureConnection: isOpenCodeConnectionSecure(), + openCodeAuthSource: openCodeAuthSource || null, + openCodeApiPrefix: '', + openCodeApiPrefixDetected: true, + isOpenCodeReady, + lastOpenCodeError, + opencodeBinaryResolved: resolvedOpencodeBinary || null, + opencodeBinarySource: resolvedOpencodeBinarySource || null, + opencodeLaunchBinary: launchSpec?.binary || null, + opencodeLaunchArgs: launchSpec?.args || [], + opencodeLaunchWrapperType: launchSpec?.wrapperType || null, + opencodeViaWsl: useWslForOpencode, + opencodeWslBinary: resolvedWslBinary || null, + opencodeWslPath: resolvedWslOpencodePath || null, + opencodeWslDistro: resolvedWslDistro || null, + nodeBinaryResolved: resolvedNodeBinary || null, + bunBinaryResolved: resolvedBunBinary || null, + desktopNotifyEnabled: ENV_DESKTOP_NOTIFY, + planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED, + }; + }, uiPassword, tunnelAuthController, readSettingsFromDiskMigrated, diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 85e87ff5..93a8a728 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -112,6 +112,7 @@ This module provides OpenCode server integration utilities for the web server ru - `refreshOpenCodeAfterConfigChange(reason, options?)` - `bootstrapOpenCodeAtStartup()` - `startHealthMonitoring(healthCheckIntervalMs)` + - `waitForPortRelease(port, timeoutMs, hostname?)` - `killProcessOnPort(port)` ## Public exports (env-runtime.js) @@ -122,10 +123,10 @@ This module provides OpenCode server integration utilities for the web server ru - `ensureOpencodeCliEnv()` - `applyOpencodeBinaryFromSettings()` - `resolveOpencodeCliPath()` + - `resolveManagedOpenCodeLaunchSpec(opencodePath)`: resolves the effective managed OpenCode launch target, unwrapping Windows package-manager shims to a direct native binary or explicit runtime+script when possible. - `resolveGitBinaryForSpawn()` - `resolveWslExecutablePath()` - `buildWslExecArgs(execArgs, distroOverride?)` - - `opencodeShimInterpreter(opencodePath)` - `isExecutable(filePath)` - `searchPathFor(binaryName)` - `clearResolvedOpenCodeBinary()` @@ -288,7 +289,7 @@ This module provides OpenCode server integration utilities for the web server ru ## Public exports (opencode-resolution-runtime.js) - `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution. - Returned API: - - `getOpenCodeResolutionSnapshot(settings)` + - `getOpenCodeResolutionSnapshot(settings)`: returns configured/resolved OpenCode binary details plus effective managed-launch fields (`launchBinary`, `launchArgs`, `launchWrapperType`) when applicable. ## Public exports (tunnel-wiring-runtime.js) - `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration. diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 0d1abdf5..86dd229c 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -631,6 +631,194 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; }; + const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat', '.com']); + + const normalizeExecutableCandidate = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return isExecutable(trimmed) ? trimmed : null; + }; + + const getWindowsNativeOpencodePackageNames = () => { + if (process.arch === 'arm64') { + return ['opencode-windows-arm64']; + } + if (process.arch === 'x64') { + // Prefer the baseline build when bypassing package-manager wrappers so the + // direct binary still runs on hosts without AVX2 support. + return ['opencode-windows-x64-baseline', 'opencode-windows-x64']; + } + return []; + }; + + const resolveNativeOpencodeBinaryFromNodeModules = (nodeModulesDir) => { + if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) { + return null; + } + + for (const packageName of getWindowsNativeOpencodePackageNames()) { + const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'); + if (isExecutable(candidate)) { + return candidate; + } + } + + return null; + }; + + const resolveOpencodeNodeLaunchSpecFromNodeModules = (nodeModulesDir) => { + if (typeof nodeModulesDir !== 'string' || nodeModulesDir.trim().length === 0) { + return null; + } + + const launcher = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode'); + if (!isExecutable(launcher) && !fs.existsSync(launcher)) { + return null; + } + + const nodeBinary = ensureNodeCliEnv() || resolveNodeCliPath() || 'node'; + return { + binary: nodeBinary, + args: [launcher], + wrapperType: 'node-launcher', + }; + }; + + const resolveNodeModulesDirFromCmdWrapper = (wrapperPath) => { + if (!wrapperPath || typeof wrapperPath !== 'string') { + return null; + } + + try { + const content = fs.readFileSync(wrapperPath, 'utf8'); + const launcherMatch = content.match(/node_modules[\\/]+opencode-ai[\\/]+bin[\\/]+opencode/i); + if (!launcherMatch) { + return null; + } + + const launcherPath = path.resolve(path.dirname(wrapperPath), launcherMatch[0]); + return path.dirname(path.dirname(path.dirname(launcherPath))); + } catch { + return null; + } + }; + + const resolveOpencodeNodeModulesDir = (opencodePath) => { + if (typeof opencodePath !== 'string' || opencodePath.trim().length === 0) { + return null; + } + + const normalized = path.resolve(opencodePath); + const lower = normalized.toLowerCase(); + const fileDir = path.dirname(normalized); + const nodeModulesCandidates = []; + const pushCandidate = (candidate) => { + if (typeof candidate !== 'string' || candidate.trim().length === 0) { + return; + } + if (!nodeModulesCandidates.includes(candidate)) { + nodeModulesCandidates.push(candidate); + } + }; + + if (lower.includes(`${path.sep}.bun${path.sep}bin${path.sep}opencode`)) { + const bunRoot = path.dirname(path.dirname(normalized)); + pushCandidate(path.join(bunRoot, 'install', 'global', 'node_modules')); + } + + if (lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode`) + || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.cmd`) + || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.bat`) + || lower.endsWith(`${path.sep}node_modules${path.sep}.bin${path.sep}opencode.exe`)) { + pushCandidate(path.dirname(fileDir)); + } + + if (lower.endsWith(`${path.sep}node_modules${path.sep}opencode-ai${path.sep}bin${path.sep}opencode`)) { + pushCandidate(path.dirname(path.dirname(fileDir))); + } + + if (path.basename(fileDir).toLowerCase() === 'npm') { + pushCandidate(path.join(fileDir, 'node_modules')); + } + + if (WINDOWS_BATCH_EXTENSIONS.has(path.extname(normalized).toLowerCase())) { + pushCandidate(resolveNodeModulesDirFromCmdWrapper(normalized)); + } + + for (const candidate of nodeModulesCandidates) { + if (resolveNativeOpencodeBinaryFromNodeModules(candidate) || resolveOpencodeNodeLaunchSpecFromNodeModules(candidate)) { + return candidate; + } + } + + return null; + }; + + const resolveManagedOpenCodeLaunchSpec = (opencodePath) => { + const fallbackBinary = typeof opencodePath === 'string' && opencodePath.trim().length > 0 + ? opencodePath.trim() + : 'opencode'; + + if (process.platform !== 'win32') { + return { binary: fallbackBinary, args: [], wrapperType: null }; + } + + const ext = path.extname(fallbackBinary).toLowerCase(); + const candidatePaths = [fallbackBinary]; + if (WINDOWS_BATCH_EXTENSIONS.has(ext)) { + candidatePaths.push(fallbackBinary.slice(0, -ext.length) + '.exe'); + } + + for (const candidate of candidatePaths) { + const nodeModulesDir = resolveOpencodeNodeModulesDir(candidate); + const nativeBinary = resolveNativeOpencodeBinaryFromNodeModules(nodeModulesDir); + if (nativeBinary) { + return { + binary: nativeBinary, + args: [], + wrapperType: nativeBinary === fallbackBinary ? null : 'native-wrapper', + }; + } + + const nodeLaunchSpec = resolveOpencodeNodeLaunchSpecFromNodeModules(nodeModulesDir); + if (nodeLaunchSpec) { + return nodeLaunchSpec; + } + + const interpreter = opencodeShimInterpreter(candidate); + if (interpreter === 'node') { + return { + binary: ensureNodeCliEnv() || resolveNodeCliPath() || 'node', + args: [candidate], + wrapperType: 'node-shebang', + }; + } + if (interpreter === 'bun') { + return { + binary: ensureBunCliEnv() || resolveBunCliPath() || 'bun', + args: [candidate], + wrapperType: 'bun-shebang', + }; + } + + const directBinary = normalizeExecutableCandidate(candidate); + if (directBinary) { + return { + binary: directBinary, + args: [], + wrapperType: directBinary === fallbackBinary ? null : 'executable-wrapper', + }; + } + } + + return { binary: fallbackBinary, args: [], wrapperType: null }; + }; + const readShebang = (opencodePath) => { if (!opencodePath || typeof opencodePath !== 'string') { return null; @@ -897,12 +1085,12 @@ export const createOpenCodeEnvRuntime = (deps) => { applyOpencodeBinaryFromSettings, getLoginShellEnvSnapshot, resolveOpencodeCliPath, + resolveManagedOpenCodeLaunchSpec, isExecutable, searchPathFor, resolveGitBinaryForSpawn, resolveWslExecutablePath, buildWslExecArgs, - opencodeShimInterpreter, clearResolvedOpenCodeBinary, }; }; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index ff095759..20d784cf 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -1,7 +1,5 @@ import { spawn, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; import net from 'node:net'; -import path from 'node:path'; export const createOpenCodeLifecycleRuntime = (deps) => { const { @@ -18,7 +16,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { ensureLocalOpenCodeServerPassword, buildWslExecArgs, resolveWslExecutablePath, - opencodeShimInterpreter, + resolveManagedOpenCodeLaunchSpec, setOpenCodePort, setDetectedOpenCodeApiPrefix, setupProxy, @@ -27,7 +25,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } = deps; const killProcessOnPort = (port) => { - if (!port) return; + if (!port || process.platform === 'win32') return; try { const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); const output = result.stdout || ''; @@ -45,6 +43,130 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } }; + const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null; + + const waitForChildProcessClose = (child, timeoutMs) => new Promise((resolve) => { + if (!child || hasChildProcessExited(child)) { + resolve(true); + return; + } + + let done = false; + const finish = (closed) => { + if (done) return; + done = true; + clearTimeout(timer); + child.off('close', onClose); + child.off('error', onError); + resolve(closed); + }; + + const onClose = () => finish(true); + const onError = () => finish(hasChildProcessExited(child)); + const timer = setTimeout(() => finish(hasChildProcessExited(child)), timeoutMs); + + child.once('close', onClose); + child.once('error', onError); + }); + + const waitForPortRelease = (port, timeoutMs, hostname = env.ENV_CONFIGURED_OPENCODE_HOSTNAME) => { + if (!port) { + return Promise.resolve(true); + } + + const probeHost = !hostname || hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]' + ? '127.0.0.1' + : hostname; + const deadline = Date.now() + timeoutMs; + + return new Promise((resolve) => { + const attempt = () => { + const socket = net.connect({ port, host: probeHost }); + let settled = false; + + const finish = (released) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + if (released || Date.now() >= deadline) { + resolve(released); + return; + } + setTimeout(attempt, 150); + }; + + socket.once('connect', () => finish(false)); + socket.once('timeout', () => finish(true)); + socket.once('error', (error) => { + if (error && typeof error === 'object' && (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH')) { + finish(true); + return; + } + finish(false); + }); + socket.setTimeout(500); + }; + + attempt(); + }); + }; + + const closeManagedOpenCodeChild = async (child) => { + if (!child) { + return; + } + + const pid = child.pid; + if (!pid || hasChildProcessExited(child)) { + await waitForChildProcessClose(child, 250); + return; + } + + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/pid', String(pid), '/t'], { + stdio: 'ignore', + timeout: 3000, + windowsHide: true, + }); + } catch { + } + + if (await waitForChildProcessClose(child, 1500)) { + return; + } + + try { + spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { + stdio: 'ignore', + timeout: 5000, + windowsHide: true, + }); + } catch { + } + + await waitForChildProcessClose(child, 3000); + return; + } + + try { + child.kill('SIGTERM'); + } catch { + } + + if (await waitForChildProcessClose(child, 2500)) { + return; + } + + try { + child.kill('SIGKILL'); + } catch { + } + + await waitForChildProcessClose(child, 1000); + }; + const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => { let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; let args = ['serve', '--hostname', hostname, '--port', String(port)]; @@ -72,26 +194,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } if (process.platform === 'win32' && !state.useWslForOpencode) { - const interpreter = opencodeShimInterpreter(binary); - if (interpreter) { - args.unshift(binary); - binary = interpreter; - } else { - try { - const shimContent = fs.readFileSync(binary, 'utf8'); - const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); - if (jsMatch) { - const candidate = path.resolve(path.dirname(binary), jsMatch[0]); - if (fs.existsSync(candidate)) { - const realInterp = opencodeShimInterpreter(candidate); - if (realInterp) { - args.unshift(candidate); - binary = realInterp; - } - } - } - } catch { + const launchSpec = resolveManagedOpenCodeLaunchSpec(binary); + if (launchSpec?.binary) { + if (launchSpec.wrapperType) { + console.log(`Launching OpenCode via ${launchSpec.wrapperType}: ${launchSpec.binary}`); } + binary = launchSpec.binary; + args = [...(Array.isArray(launchSpec.args) ? launchSpec.args : []), ...args]; } } @@ -155,11 +264,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => { return { url, - close() { - try { - child.kill('SIGTERM'); - } catch { - } + async close() { + await closeManagedOpenCodeChild(child); }, }; }; @@ -302,7 +408,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } try { - serverInstance.close(); + await serverInstance.close(); } catch { } throw new Error('Server started but health check failed (timeout)'); @@ -359,7 +465,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { if (state.openCodeProcess) { console.log('Stopping existing OpenCode process...'); try { - state.openCodeProcess.close(); + await state.openCodeProcess.close(); } catch (error) { console.warn('Error closing OpenCode process:', error); } @@ -368,7 +474,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } killProcessOnPort(portToKill); - await new Promise((resolve) => setTimeout(resolve, 250)); + if (!(await waitForPortRelease(portToKill, 5000))) { + console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released`); + } if (env.ENV_CONFIGURED_OPENCODE_PORT) { console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`); @@ -626,5 +734,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => { refreshOpenCodeAfterConfigChange, bootstrapOpenCodeAtStartup, startHealthMonitoring, + waitForPortRelease, }; }; diff --git a/packages/web/server/lib/opencode/opencode-resolution-runtime.js b/packages/web/server/lib/opencode/opencode-resolution-runtime.js index 5495f8e9..854d03ee 100644 --- a/packages/web/server/lib/opencode/opencode-resolution-runtime.js +++ b/packages/web/server/lib/opencode/opencode-resolution-runtime.js @@ -4,7 +4,7 @@ export const createOpenCodeResolutionRuntime = (dependencies) => { resolveOpencodeCliPath, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, - opencodeShimInterpreter, + resolveManagedOpenCodeLaunchSpec, getResolvedState, setResolvedOpencodeBinarySource, } = dependencies; @@ -42,7 +42,9 @@ export const createOpenCodeResolutionRuntime = (dependencies) => { source !== 'env' ? source : rawDetectedSourceNow; - const shim = resolved ? opencodeShimInterpreter(resolved) : null; + const launchSpec = resolved && !useWslForOpencode + ? resolveManagedOpenCodeLaunchSpec(resolved) + : null; return { configured, @@ -51,7 +53,9 @@ export const createOpenCodeResolutionRuntime = (dependencies) => { source, detectedNow, detectedSourceNow, - shim, + launchBinary: launchSpec?.binary || null, + launchArgs: launchSpec?.args || [], + launchWrapperType: launchSpec?.wrapperType || null, viaWsl: useWslForOpencode, wslBinary: resolvedWslBinary || null, wslPath: resolvedWslOpencodePath || null, diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index 1350e2eb..a3b151ab 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -17,6 +17,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { getOpenCodeProcess, setOpenCodeProcess, killProcessOnPort, + waitForPortRelease, getServer, getUiAuthController, setUiAuthController, @@ -58,7 +59,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { if (openCodeProcess) { console.log('Stopping OpenCode process...'); try { - openCodeProcess.close(); + await openCodeProcess.close(); } catch (error) { console.warn('Error closing OpenCode process:', error); } @@ -66,6 +67,9 @@ export const createGracefulShutdownRuntime = (dependencies) => { } killProcessOnPort(portToKill); + if (!(await waitForPortRelease(portToKill, 5000))) { + console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released during shutdown`); + } } else { console.log('Skipping OpenCode shutdown (external server)'); }