Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,120 @@ server.listen(0, "127.0.0.1", () => {
10_000,
);
});

describe("OpenCode database lock startup", () => {
effectIt.live.each([
{ mode: "transient", keepPipesOpen: false },
{ mode: "permanent", keepPipesOpen: false },
{ mode: "configuration", keepPipesOpen: false },
{ mode: "unrelated", keepPipesOpen: false },
{ mode: "transient", keepPipesOpen: true },
{ mode: "permanent", keepPipesOpen: true },
{ mode: "configuration", keepPipesOpen: true },
{ mode: "unrelated", keepPipesOpen: true },
] as const)(
"handles $mode startup failures with bounded process ownership (open pipes: $keepPipesOpen)",
({ mode, keepPipesOpen }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-lock-" });
const platform = yield* HostProcess.Platform;
const executablePath = yield* HostProcess.ExecutablePath;
const environment = yield* HostProcess.Environment;
const scriptPath = `${root}/fake.mjs`;
const attemptsPath = `${root}/attempts`;
yield* Effect.addFinalizer(() =>
fs.readFileString(`${attemptsPath}.descendants`).pipe(
Effect.orElseSucceed(() => ""),
Effect.flatMap((pids) =>
Effect.sync(() => {
for (const pid of pids.trim().split("\n").filter(Boolean)) {
try {
process.kill(Number(pid), "SIGKILL");
} catch {
// The fixture descendant may already have exited.
}
}
}),
),
),
);
yield* fs.writeFileString(
scriptPath,
`
import { readFileSync, writeFileSync, writeSync } from "node:fs";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
const path = process.env.T3_TEST_ATTEMPTS;
let attempt = 0;
try { attempt = Number(readFileSync(path, "utf8")); } catch {}
writeFileSync(path, String(++attempt));
if (${JSON.stringify(mode)} !== "transient" || attempt < 3) {
// Inherited pipes remain open after the direct child exits.
if (${keepPipesOpen}) {
const descendant = spawn(process.execPath, ["-e", "setTimeout(() => {}, 10000)"], {
detached: true,
stdio: ["ignore", "inherit", "inherit"],
});
writeFileSync(path + ".descendants", descendant.pid + "\\n", { flag: "a" });
descendant.unref();
}
// A lock mentioned outside the startup diagnostic is not a lock failure.
if (${JSON.stringify(mode)} === "unrelated") writeSync(1, "note: database is locked was seen earlier\\n");
writeSync(2, ${JSON.stringify(mode === "configuration" || mode === "unrelated" ? "Invalid configuration" : "SQLiteError: database is locked (SQLITE_BUSY)")} + "\\n");
process.exit(1);
}
const server = createServer((req, res) => res.end("ok"));
server.listen(0, "127.0.0.1", () => console.log("opencode server listening on http://127.0.0.1:" + server.address().port));
`,
);
const binaryPath = `${root}/opencode${platform === "win32" ? ".cmd" : ""}`;
yield* fs.writeFileString(
binaryPath,
platform === "win32"
? '@echo off\n"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*\n'
: '#!/bin/sh\nexec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"\n',
);
if (platform !== "win32") yield* fs.chmod(binaryPath, 0o700);
const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
const result = yield* Effect.result(
runtime.startOpenCodeServerProcess({
binaryPath,
directory: root,
port: 0,
timeoutMs: 2_000,
environment: {
...environment,
T3_TEST_NODE_BINARY: executablePath,
T3_TEST_OPENCODE_SCRIPT: scriptPath,
T3_TEST_ATTEMPTS: attemptsPath,
},
verify: () => Effect.succeed("1.14.19"),
}),
);
expect(result._tag).toBe(mode === "transient" ? "Success" : "Failure");
expect(Number(yield* fs.readFileString(attemptsPath))).toBe(
mode === "transient" ? 3 : mode === "permanent" ? 4 : 1,
);
if (result._tag === "Failure") {
expect(result.failure.reason).toBe(mode === "permanent" ? "databaseLocked" : undefined);
const stderr =
mode === "permanent"
? "SQLiteError: database is locked (SQLITE_BUSY)"
: "Invalid configuration";
expect(result.failure.detail).toContain(`stderr:\n${stderr}`);
expect(result.failure.cause).toMatchObject({ exitCode: 1, stderr: `${stderr}\n` });
}
}).pipe(
Effect.scoped,
Effect.provide([
OpenCodeRuntime.layer.pipe(
Layer.provide(OpenCodeServerLedger.layerTest),
Layer.provideMerge(NodeServices.layer),
),
FetchHttpClient.layer,
]),
),
15_000,
);
});
49 changes: 46 additions & 3 deletions packages/provider-opencode/src/server/OpenCodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
import * as Schema from "effect/Schema";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
Expand Down Expand Up @@ -85,6 +86,7 @@ export function resolveOpenCodeServerPassword(
const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;
const DEFAULT_HOSTNAME = "127.0.0.1";
const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;
const OPENCODE_SERVER_EXIT_DRAIN_TIMEOUT = "500 millis";
const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
export interface OpenCodeServerProcess {
readonly url: string;
Expand All @@ -107,8 +109,17 @@ export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERRO
readonly operation: string;
readonly cause?: unknown;
readonly detail: string;
readonly reason?: OpenCodeStartupFailureReason;
}> {}

export type OpenCodeStartupFailureReason = "databaseLocked";

// OpenCode reports a held opencode.db lock on stderr. Classify it once where the
// exit is captured so callers branch on the category, not on diagnostic text.
function classifyStartupExit(stderr: string): OpenCodeStartupFailureReason | undefined {
return /SQLITE_BUSY|database is locked/i.test(stderr) ? "databaseLocked" : undefined;
}

export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError =>
P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);

Expand Down Expand Up @@ -671,9 +682,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
throwOnError: true,
});

const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (
input,
) =>
const startServerAttempt: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (input) =>
Effect.gen(function* () {
// Bind this server's lifetime to the caller's scope. When the caller's
// scope closes, the spawned child is killed and all associated fibers
Expand Down Expand Up @@ -809,13 +818,22 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
const exitFiber = yield* child.exitCode.pipe(
Effect.flatMap((code) =>
Effect.gen(function* () {
// Allow trailing diagnostics to drain, but descendants can keep pipes open.
const drained = yield* Fiber.joinAll([stdoutFiber, stderrFiber]).pipe(
Effect.timeoutOption(OPENCODE_SERVER_EXIT_DRAIN_TIMEOUT),
);
if (Option.isNone(drained)) {
yield* Fiber.interruptAll([stdoutFiber, stderrFiber]);
}
const stdout = (yield* Ref.get(stdoutRef)) ?? "";
const stderr = (yield* Ref.get(stderrRef)) ?? "";
const exitCode = Number(code);
const reason = classifyStartupExit(stderr);
yield* Deferred.fail(
readyDeferred,
new OpenCodeRuntimeError({
operation: "startOpenCodeServerProcess",
...(reason ? { reason } : {}),
detail: [
`OpenCode server exited before startup completed (code: ${String(exitCode)}).`,
stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
Expand Down Expand Up @@ -887,6 +905,31 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
} satisfies OpenCodeServerProcess;
});

const startOpenCodeServerProcess: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (
input,
) =>
Effect.gen(function* () {
const ownerScope = yield* Scope.Scope;
const attempt = Effect.gen(function* () {
const attemptScope = yield* Scope.fork(ownerScope);
return yield* startServerAttempt(input).pipe(
Effect.provideService(Scope.Scope, attemptScope),
Effect.onExit((exit) =>
Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void,
),
);
});
// OpenCode instances share opencode.db. Migration/WAL locks are transient;
// configuration and executable errors are not helped by launching again.
return yield* attempt.pipe(
Effect.retry({
times: 3,
schedule: Schedule.spaced("250 millis"),
while: (error) => error.reason === "databaseLocked",
}),
);
});

const connectToOpenCodeServer: OpenCodeRuntime["Service"]["connectToOpenCodeServer"] = (
input,
) => {
Expand Down
Loading