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