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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a3d73a8b67
commit
636dcd5314
@@ -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
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user