Fix Windows OpenCode process cleanup

This commit is contained in:
agamotto
2026-06-28 19:11:22 +07:00
committed by GitHub
parent 3288e1cfb9
commit 6a23bbd121
2 changed files with 133 additions and 5 deletions
@@ -1,10 +1,12 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { findExecutable } from "../../../executable-resolution/executable-resolution.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type {
ManagedProcessRecord,
@@ -236,6 +238,58 @@ describe("OpenCodeServerManager managed process ledger", () => {
});
});
describe.runIf(process.platform === "win32")(
"OpenCodeServerManager Windows OpenCode npm install",
() => {
test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => {
const detectedOpenCode = await findExecutable("opencode");
expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe(
null,
);
expect(path.extname(detectedOpenCode!).toLowerCase()).toBe(".cmd");
const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-"));
const opencodeHomeDir = path.join(tempDir, "opencode-home");
const managedProcesses = new FakeManagedProcesses();
const manager = new OpenCodeServerManager({
logger: createTestLogger(),
managedProcesses,
resolveHomeDir: () => opencodeHomeDir,
});
let acquiredPort: number | null = null;
try {
const acquisition = await manager.acquireDedicated({
OPENCODE_AUTH_CONTENT: "{}",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_TEST_HOME: path.join(tempDir, "test-home"),
});
acquiredPort = acquisition.server.port;
const records = await managedProcesses.list();
expect(records).toHaveLength(1);
const record = records[0]!;
expect(path.extname(record.command).toLowerCase()).toBe(".exe");
expect(path.normalize(record.command).toLowerCase()).toContain(
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
);
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]);
} finally {
await manager.shutdown().catch(() => undefined);
if (acquiredPort !== null) {
await waitForClosedPort(acquiredPort, 5_000);
}
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}, 60_000);
},
);
function createTestManager(
ports: number[],
options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {},
@@ -413,3 +467,37 @@ class FakeManagedProcesses implements ManagedProcessRegistry {
};
}
}
async function waitForClosedPort(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await canConnectToPort(port))) {
return;
}
await sleep(100);
}
throw new Error(`OpenCode helper server still accepts connections on port ${port}`);
}
function canConnectToPort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
let settled = false;
const settle = (connected: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(connected);
};
socket.once("connect", () => settle(true));
socket.once("error", () => settle(false));
socket.setTimeout(500, () => settle(false));
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -1,6 +1,8 @@
import type { ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import { stat } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import type { Logger } from "pino";
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
@@ -495,12 +497,50 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (found) {
return found;
if (!found) {
throw new Error(
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
);
}
if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") {
// Global npm: <prefix>/opencode.cmd → <prefix>/node_modules/opencode-ai/bin/opencode.exe
const globalCandidate = path.join(
path.dirname(found),
"node_modules",
"opencode-ai",
"bin",
"opencode.exe",
);
if (await pathExists(globalCandidate)) return globalCandidate;
// Local/pnpm: <project>/node_modules/.bin/opencode.cmd → <project>/node_modules/opencode-ai/bin/opencode.exe
const localCandidate = path.join(
path.dirname(found),
"..",
"opencode-ai",
"bin",
"opencode.exe",
);
if (await pathExists(localCandidate)) return localCandidate;
console.warn(
"[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " +
"The process may not be properly terminated on exit. Path: %s",
found,
);
}
return found;
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await stat(filePath);
return true;
} catch {
return false;
}
throw new Error(
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
);
}
function findAvailablePort(): Promise<number> {