Fix Windows OpenCode process cleanup
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user