Fix Windows OpenCode process cleanup
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
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 { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type {
|
import type {
|
||||||
ManagedProcessRecord,
|
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(
|
function createTestManager(
|
||||||
ports: number[],
|
ports: number[],
|
||||||
options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {},
|
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 type { ChildProcess } from "node:child_process";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
|
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
|
||||||
@@ -495,12 +497,50 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
|||||||
|
|
||||||
async function resolveOpenCodeBinary(): Promise<string> {
|
async function resolveOpenCodeBinary(): Promise<string> {
|
||||||
const found = await findExecutable("opencode");
|
const found = await findExecutable("opencode");
|
||||||
if (found) {
|
if (!found) {
|
||||||
return 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> {
|
function findAvailablePort(): Promise<number> {
|
||||||
|
|||||||
Reference in New Issue
Block a user