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
+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,
};
};