Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 1efe923426 refactor(client): share service contender handling 2026-08-11 17:14:44 -04:00
Kit Langton df7fa12b15 fix(client): surface managed startup stderr 2026-08-11 12:20:25 -04:00
7 changed files with 121 additions and 56 deletions
+21
View File
@@ -310,6 +310,27 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
}
}, 30_000)
test("managed service startup reports an actionable port conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-managed-conflict-"))
const registration = path.join(root, "state", "opencode", "service-local.json")
const message =
"Managed service port 49374 on 127.0.0.1 is already in use by another process. " +
"Configure another port with `opencode service set port <port>` and start the service again."
try {
await expect(
Effect.runPromise(
Service.ensure({
file: registration,
command: [process.execPath, "-e", `console.error(${JSON.stringify(message)}); process.exit(1)`],
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
).rejects.toThrow(message)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
const recognizing = Promise.withResolvers<void>()
+8 -28
View File
@@ -1,9 +1,14 @@
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -17,11 +22,6 @@ export type Info = import("../service.js").Info
// is all a client needs to connect. The daemon's own configuration (port,
// persisted password) is CLI-owned and never read here.
type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
}
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to ensure() is the caller's policy.
/** Discover a healthy, compatible local service without starting one. */
@@ -52,7 +52,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
// becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const contenders = new Set<Contender>()
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
@@ -68,13 +68,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({
try: () => {
const child = spawn(command, args, { detached: true, stdio: "ignore" })
let error: Error | undefined
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.unref()
return { child, error: () => error }
return spawnServiceContender(command, args)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -133,20 +127,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return found.value.endpoint
})
function contenderFailure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
+8 -28
View File
@@ -1,8 +1,13 @@
import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -13,11 +18,6 @@ export * from "../service.js"
// intentionally implemented with Node APIs so Promise clients do not need
// Effect or @effect/platform-node at runtime.
type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
}
/** Discover a healthy, compatible local service without starting one. */
export async function discover(options: DiscoverOptions = {}) {
return (await discoverLocal(options))?.endpoint
@@ -33,7 +33,7 @@ async function discoverLocal(options: DiscoverOptions) {
/** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const deadline = Date.now() + 120_000
const contenders = new Set<Contender>()
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
@@ -48,13 +48,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" })
let error: Error | undefined
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.unref()
return { child, error: () => error }
return spawnServiceContender(command, args)
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
@@ -107,20 +101,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
}
function contenderFailure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
+48
View File
@@ -0,0 +1,48 @@
import { spawn, type ChildProcess } from "node:child_process"
export type ServiceContender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
readonly closed: () => boolean
readonly stderr: () => string
}
const stderrLimit = 8 * 1024
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
let error: Error | undefined
let closed = false
let stderr = Buffer.alloc(0)
child.stderr?.on("data", (chunk: Buffer) => {
stderr = Buffer.concat([stderr, chunk]).subarray(-stderrLimit)
})
if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function")
child.stderr.unref()
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.once("close", () => {
closed = true
})
child.unref()
return { child, error: () => error, closed: () => closed, stderr: () => stderr.toString("utf8").trim() }
}
export function contenderFailure(contender: ServiceContender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr())
if (contender.child.signalCode !== null)
return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr())
return undefined
}
export function contenderFinished(contender: ServiceContender) {
return contender.error() !== undefined || contender.closed()
}
function startupError(message: string, stderr: string) {
return new Error(stderr ? `${message}\n${stderr}` : message)
}
+4
View File
@@ -3,6 +3,10 @@ import { appendFile, rename, writeFile } from "node:fs/promises"
const [registration, mode, delay] = process.argv.slice(2)
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
if (mode === "failed") process.exit(1)
if (mode === "stderr-failed") {
process.stderr.write("x".repeat(16_384) + "\nactionable startup failure\n")
process.exit(1)
}
if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
@@ -70,6 +70,21 @@ test("reports a failed registered service", async () => {
)
})
test("reports a bounded contender stderr tail with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const error = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "stderr-failed"],
}).catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("actionable startup failure")
expect(error.message.length).toBeLessThan(9_000)
}, 10_000)
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+17
View File
@@ -197,6 +197,23 @@ test("reports a contender that fails to start", async () => {
).rejects.toThrow("Server process exited with code 1")
}, 10_000)
test("reports a bounded contender stderr tail", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const error = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "stderr-failed"],
}),
).catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("actionable startup failure")
expect(error.message.length).toBeLessThan(9_000)
}, 10_000)
test("reports a contender terminated by a signal", async () => {
const directory = await temp()
const registration = join(directory, "service.json")