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 <artmore@protonmail.com>
This commit is contained in:
Dr. Zed
2026-04-11 22:29:09 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a3d73a8b67
commit 636dcd5314
8 changed files with 512 additions and 100 deletions
+23 -7
View File
@@ -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<string> => {
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<string> => {
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) {
+112 -31
View File
@@ -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 {
}
+33 -24
View File
@@ -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,
@@ -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.
+189 -1
View File
@@ -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,
};
};
+140 -31
View File
@@ -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,
};
};
@@ -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,
@@ -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)');
}