refactor(server): extract opencode server manager (#865)

* refactor(server): extract opencode server manager

* test(server): replace opencode server manager mocks
This commit is contained in:
Mohamed Boudra
2026-05-10 12:36:00 +08:00
committed by GitHub
parent 371c29fecd
commit 15e15b525f
10 changed files with 450 additions and 409 deletions

View File

@@ -10,7 +10,7 @@ import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { isProviderAvailable } from "../daemon-e2e/agent-configs.js";
import { OpenCodeServerManager } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";

View File

@@ -226,6 +226,9 @@ vi.mock("./providers/opencode-agent.js", () => ({
return true;
}
},
}));
vi.mock("./providers/opencode/server-manager.js", () => ({
OpenCodeServerManager: {
getInstance: vi.fn(() => ({
shutdown: vi.fn(),

View File

@@ -25,7 +25,8 @@ import { ClaudeAgentClient } from "./providers/claude/agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import {

View File

@@ -8,7 +8,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
@@ -23,15 +24,6 @@ function createEventStream(events: unknown[]): AsyncGenerator<never> {
})();
}
function mockServerManager(): void {
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const promptAsync = vi.fn().mockResolvedValue({});
const permissionReply = vi.fn().mockResolvedValue({});
@@ -121,7 +113,7 @@ describe("OpenCode full-access mode", () => {
});
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
mockServerManager();
const serverManager = createTestOpenCodeServerManager();
mockOpenCodeClient({
agents: [
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
@@ -129,7 +121,9 @@ describe("OpenCode full-access mode", () => {
],
});
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
@@ -140,10 +134,12 @@ describe("OpenCode full-access mode", () => {
});
test("reports full-access but sends prompts through OpenCode build agent", async () => {
mockServerManager();
const serverManager = createTestOpenCodeServerManager();
const { promptAsync } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -161,13 +157,15 @@ describe("OpenCode full-access mode", () => {
});
test("auto-approves tool permissions in full-access without surfacing them", async () => {
mockServerManager();
const serverManager = createTestOpenCodeServerManager();
const { permissionReply } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -190,13 +188,15 @@ describe("OpenCode full-access mode", () => {
});
test("keeps questions separate from full-access tool auto-approval", async () => {
mockServerManager();
const serverManager = createTestOpenCodeServerManager();
const { permissionReply, questionReply } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",

View File

@@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
afterEach(() => {
vi.useRealTimers();
@@ -17,30 +18,29 @@ afterEach(() => {
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const providerList = vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
async function providerList(): Promise<unknown> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
],
},
});
}, 15_000);
}),
);
},
],
},
});
}, 15_000);
});
}
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
@@ -48,14 +48,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const serverManager = createTestOpenCodeServerManager();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000);
@@ -72,7 +68,7 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: vi.fn().mockResolvedValue({
list: async () => ({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
@@ -80,15 +76,13 @@ test("passes explicit refresh force through server acquisition", async () => {
}),
},
} as never);
const acquire = vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
});
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
const serverManager = createTestOpenCodeServerManager();
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
expect(acquire).toHaveBeenCalledWith({ force: true });
expect(serverManager.acquisitions).toEqual([{ force: true, released: true }]);
});

View File

@@ -7,7 +7,8 @@ vi.mock("@opencode-ai/sdk/v2/client", () => ({
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
function createDeferred<T>(): {
promise: Promise<T>;
@@ -49,14 +50,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual(
@@ -107,14 +103,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({
@@ -164,14 +155,9 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
acquire: vi.fn().mockResolvedValue({
server: { port: 1234, url: "http://127.0.0.1:1234" },
release: vi.fn(),
}),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");

View File

@@ -1,4 +1,3 @@
import type { ChildProcess } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
@@ -11,7 +10,7 @@ import {
type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import type { Logger } from "pino";
import { z } from "zod";
@@ -43,17 +42,15 @@ import type {
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
createProviderEnvSpec,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
OpenCodeServerManager,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -159,8 +156,6 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{ name: "compact", description: "Compact the current session", argumentHint: "" },
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
@@ -251,16 +246,6 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
}),
);
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
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.",
);
}
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
if (config.type === "stdio") {
return {
@@ -414,22 +399,6 @@ function isAlreadyPresentMcpError(error: unknown): boolean {
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
}
async function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") {
const port = address.port;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error("Failed to get port")));
}
});
server.on("error", reject);
});
}
function resolvePartDedupeKey(
part: { id: string; messageID: string },
partType: "text" | "reasoning",
@@ -928,299 +897,31 @@ export const __openCodeInternals = {
},
};
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
}
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
{
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings",
);
}
return OpenCodeServerManager.instance;
}
private static registerExitHandler(): void {
if (OpenCodeServerManager.exitHandlerRegistered) {
return;
}
OpenCodeServerManager.exitHandlerRegistered = true;
const cleanup = () => {
const instance = OpenCodeServerManager.instance;
void instance?.shutdown();
};
process.on("exit", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
acquisition.release();
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<{
server: { port: number; url: string };
release: () => void;
}> {
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
server.refCount += 1;
let released = false;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
},
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
}
this.forcedRefreshPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
})
.finally(() => {
this.forcedRefreshPromise = null;
});
return this.forcedRefreshPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.startPromise) {
return this.startPromise;
}
if (this.currentServer && !this.currentServer.process.killed) {
return this.currentServer;
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
}
return result;
} finally {
this.startPromise = null;
}
}
private async rotateCurrentServer(): Promise<void> {
const existing = this.currentServer;
if (existing) {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
const serverProcess = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(port)],
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
},
);
let started = false;
let stderrBuffer = "";
let stdoutBuffer = "";
const STARTUP_BUFFER_CAP = 8192;
const appendCapped = (current: string, chunk: string): string => {
if (current.length >= STARTUP_BUFFER_CAP) {
return current;
}
const remaining = STARTUP_BUFFER_CAP - current.length;
return current + chunk.slice(0, remaining);
};
const buildStartupErrorMessage = (headline: string): string => {
const sections = [headline];
const stderrTrimmed = stderrBuffer.trim();
if (stderrTrimmed.length > 0) {
sections.push(`stderr: ${stderrTrimmed}`);
}
const stdoutTrimmed = stdoutBuffer.trim();
if (stdoutTrimmed.length > 0) {
sections.push(`stdout: ${stdoutTrimmed}`);
}
return sections.join("\n");
};
const timeout = setTimeout(() => {
if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
}
}, 30_000);
serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
stdoutBuffer = appendCapped(stdoutBuffer, output);
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
}
});
serverProcess.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
stderrBuffer = appendCapped(stderrBuffer, output);
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
});
serverProcess.on("error", (error) => {
clearTimeout(timeout);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
}
if (this.currentServer?.process === serverProcess) {
this.currentServer = null;
}
for (const retired of Array.from(this.retiredServers)) {
if (retired.process === serverProcess) {
this.retiredServers.delete(retired);
}
}
});
});
}
async shutdown(): Promise<void> {
const servers = [
...(this.currentServer ? [this.currentServer] : []),
...Array.from(this.retiredServers),
];
await Promise.all(servers.map((server) => this.killServer(server)));
this.currentServer = null;
this.retiredServers.clear();
}
private cleanupRetiredServers(): void {
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
}
}
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
return;
}
const result = await terminateWithTreeKill(server.process, {
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not report exit after SIGKILL",
);
}
}
}
interface OpenCodeServerGeneration {
process: ChildProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
interface OpenCodeAgentClientDeps {
serverManager?: OpenCodeServerManagerLike;
}
export class OpenCodeAgentClient implements AgentClient {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
private readonly serverManager: OpenCodeServerManager;
private readonly serverManager: OpenCodeServerManagerLike;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) {
constructor(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
storageRoot?: string,
deps: OpenCodeAgentClientDeps = {},
) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
this.serverManager =
deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
}
async createSession(

View File

@@ -2,20 +2,14 @@ import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeServerManager } from "./opencode-agent.js";
import { OpenCodeServerManager, type OpenCodeServerGeneration } from "./opencode/server-manager.js";
type FakeServerProcess = EventEmitter & {
killed: boolean;
kill: ReturnType<typeof vi.fn>;
};
interface FakeGeneration {
process: FakeServerProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
}
type FakeGeneration = OpenCodeServerGeneration & { process: FakeServerProcess };
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {

View File

@@ -0,0 +1,327 @@
import type { ChildProcess } from "node:child_process";
import net from "node:net";
import type { Logger } from "pino";
import { findExecutable } from "../../../../utils/executable.js";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import {
createProviderEnvSpec,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
}
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
}
export interface OpenCodeServerGeneration {
process: ChildProcess;
port: number;
url: string;
refCount: number;
retired: boolean;
}
export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
}
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
{
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings",
);
}
return OpenCodeServerManager.instance;
}
private static registerExitHandler(): void {
if (OpenCodeServerManager.exitHandlerRegistered) {
return;
}
OpenCodeServerManager.exitHandlerRegistered = true;
const cleanup = () => {
const instance = OpenCodeServerManager.instance;
void instance?.shutdown();
};
process.on("exit", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false });
acquisition.release();
return acquisition.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const server = options.force
? await this.getForcedRefreshServer()
: await this.getCurrentServer();
server.refCount += 1;
let released = false;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
},
};
}
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) {
return this.forcedRefreshPromise;
}
this.forcedRefreshPromise = Promise.resolve()
.then(async () => {
await this.rotateCurrentServer();
return this.getCurrentServer();
})
.finally(() => {
this.forcedRefreshPromise = null;
});
return this.forcedRefreshPromise;
}
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.startPromise) {
return this.startPromise;
}
if (this.currentServer && !this.currentServer.process.killed) {
return this.currentServer;
}
this.startPromise = this.startServer();
try {
const result = await this.startPromise;
if (!result.retired) {
this.currentServer = result;
}
return result;
} finally {
this.startPromise = null;
}
}
private async rotateCurrentServer(): Promise<void> {
const existing = this.currentServer;
if (existing) {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
}
}
private async startServer(): Promise<OpenCodeServerGeneration> {
const port = await findAvailablePort();
const url = `http://127.0.0.1:${port}`;
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
const serverProcess = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(port)],
{
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
},
);
let started = false;
let stderrBuffer = "";
let stdoutBuffer = "";
const STARTUP_BUFFER_CAP = 8192;
const appendCapped = (current: string, chunk: string): string => {
if (current.length >= STARTUP_BUFFER_CAP) {
return current;
}
const remaining = STARTUP_BUFFER_CAP - current.length;
return current + chunk.slice(0, remaining);
};
const buildStartupErrorMessage = (headline: string): string => {
const sections = [headline];
const stderrTrimmed = stderrBuffer.trim();
if (stderrTrimmed.length > 0) {
sections.push(`stderr: ${stderrTrimmed}`);
}
const stdoutTrimmed = stdoutBuffer.trim();
if (stdoutTrimmed.length > 0) {
sections.push(`stdout: ${stdoutTrimmed}`);
}
return sections.join("\n");
};
const timeout = setTimeout(() => {
if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
}
}, 30_000);
serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
stdoutBuffer = appendCapped(stdoutBuffer, output);
if (output.includes("listening on") && !started) {
started = true;
clearTimeout(timeout);
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
});
}
});
serverProcess.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
stderrBuffer = appendCapped(stderrBuffer, output);
this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
});
serverProcess.on("error", (error) => {
clearTimeout(timeout);
const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline)));
});
serverProcess.on("exit", (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
}
if (this.currentServer?.process === serverProcess) {
this.currentServer = null;
}
for (const retired of Array.from(this.retiredServers)) {
if (retired.process === serverProcess) {
this.retiredServers.delete(retired);
}
}
});
});
}
async shutdown(): Promise<void> {
const servers = [
...(this.currentServer ? [this.currentServer] : []),
...Array.from(this.retiredServers),
];
await Promise.all(servers.map((server) => this.killServer(server)));
this.currentServer = null;
this.retiredServers.clear();
}
private cleanupRetiredServers(): void {
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
}
}
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
return;
}
const result = await terminateWithTreeKill(server.process, {
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS },
"OpenCode server did not report exit after SIGKILL",
);
}
}
}
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
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.",
);
}
function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (typeof address === "object" && address) {
resolve(address.port);
} else {
reject(new Error("Failed to allocate port"));
}
});
});
server.on("error", reject);
});
}

View File

@@ -0,0 +1,35 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition {
force: boolean;
released: boolean;
}
export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
readonly acquisitions: TestOpenCodeServerAcquisition[] = [];
readonly server = { port: 1234, url: "http://127.0.0.1:1234" };
ensureRunningCount = 0;
async ensureRunning(): Promise<{ port: number; url: string }> {
this.ensureRunningCount += 1;
return this.server;
}
async acquire(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition: TestOpenCodeServerAcquisition = {
force: options.force,
released: false,
};
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
acquisition.released = true;
},
};
}
}
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
return new TestOpenCodeServerManager();
}