Skip to content

Commit 9322f5d

Browse files
authored
fix(client): surface managed startup stderr (#41793)
1 parent d6ed520 commit 9322f5d

6 files changed

Lines changed: 161 additions & 97 deletions

File tree

packages/client/src/effect/service.ts

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
22
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
3-
import { spawn, type ChildProcess } from "node:child_process"
43
import { homedir } from "node:os"
54
import { join } from "node:path"
65
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
6+
import {
7+
contenderFailure,
8+
contenderFinished,
9+
type ServiceContender,
10+
spawnServiceContender,
11+
} from "../service-contender.js"
712
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
813

914
export * from "../service.js"
@@ -18,11 +23,6 @@ export type Info = import("../service.js").Info
1823
// is all a client needs to connect. The daemon's own configuration (port,
1924
// persisted password) is CLI-owned and never read here.
2025

21-
type Contender = {
22-
readonly child: ChildProcess
23-
readonly error: () => Error | undefined
24-
}
25-
2626
// Read-only lookup: registration file plus health check and version gate.
2727
// Never spawns; escalation to ensure() is the caller's policy.
2828
/** Discover a healthy, compatible local service without starting one. */
@@ -54,7 +54,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
5454
/** Ensure a healthy, compatible local service is running. */
5555
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
5656
const timing = ensureTiming(options)
57-
const contenders = new Set<Contender>()
57+
const contenders = new Set<ServiceContender>()
5858
let timeouts: { readonly info: Info; readonly count: number } | undefined
5959
let announced = false
6060
let lastSpawn = 0
@@ -70,13 +70,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
7070
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
7171
return yield* Effect.try({
7272
try: () => {
73-
const child = spawn(command, args, { detached: true, stdio: "ignore" })
74-
let error: Error | undefined
75-
child.once("error", (cause) => {
76-
error = new Error("Failed to start server", { cause })
77-
})
78-
child.unref()
79-
return { child, error: () => error }
73+
return spawnServiceContender(command, args)
8074
},
8175
catch: (cause) => new Error("Failed to start server", { cause }),
8276
})
@@ -129,26 +123,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
129123
until: Option.isSome,
130124
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
131125
}),
126+
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
132127
)
133128
if (Option.isNone(found))
134129
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
135130
return found.value.endpoint
136131
})
137132

138-
function contenderFailure(contender: Contender) {
139-
const error = contender.error()
140-
if (error !== undefined) return error
141-
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
142-
return new Error(`Server process exited with code ${contender.child.exitCode}`)
143-
if (contender.child.signalCode !== null)
144-
return new Error(`Server process terminated by ${contender.child.signalCode}`)
145-
return undefined
146-
}
147-
148-
function contenderFinished(contender: Contender) {
149-
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
150-
}
151-
152133
/** Stop the registered local service. */
153134
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
154135
const existing = yield* find(options)

packages/client/src/promise/service.ts

Lines changed: 53 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { readFile } from "node:fs/promises"
2-
import { spawn, type ChildProcess } from "node:child_process"
32
import { homedir } from "node:os"
43
import { join } from "node:path"
54
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
5+
import {
6+
contenderFailure,
7+
contenderFinished,
8+
type ServiceContender,
9+
spawnServiceContender,
10+
} from "../service-contender.js"
611
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
712
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
813

@@ -14,11 +19,6 @@ export * from "../service.js"
1419
// intentionally implemented with Node APIs so Promise clients do not need
1520
// Effect or @effect/platform-node at runtime.
1621

17-
type Contender = {
18-
readonly child: ChildProcess
19-
readonly error: () => Error | undefined
20-
}
21-
2222
/** Discover a healthy, compatible local service without starting one. */
2323
export async function discover(options: DiscoverOptions = {}) {
2424
return (await discoverLocal(options))?.endpoint
@@ -35,7 +35,7 @@ async function discoverLocal(options: DiscoverOptions) {
3535
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
3636
const timing = ensureTiming(options)
3737
const deadline = Date.now() + timing.promiseTimeout
38-
const contenders = new Set<Contender>()
38+
const contenders = new Set<ServiceContender>()
3939
let timeouts: { readonly info: Info; readonly count: number } | undefined
4040
let announced = false
4141
let lastSpawn = 0
@@ -50,79 +50,63 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
5050
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
5151
if (command === undefined) throw new Error("Missing service command")
5252
try {
53-
const child = spawn(command, args, { detached: true, stdio: "ignore" })
54-
let error: Error | undefined
55-
child.once("error", (cause) => {
56-
error = new Error("Failed to start server", { cause })
57-
})
58-
child.unref()
59-
return { child, error: () => error }
53+
return spawnServiceContender(command, args)
6054
} catch (cause) {
6155
throw new Error("Failed to start server", { cause })
6256
}
6357
}
6458

65-
while (true) {
66-
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
67-
const registration = await registered(options.file, true, timing.requestTimeout)
68-
if (registration.timedOut && registration.info !== undefined) {
69-
timeouts = {
70-
info: registration.info,
71-
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
72-
}
73-
if (timeouts.count >= 3) {
74-
announce("missing")
75-
await evict(registration.info, options, timing)
76-
timeouts = undefined
77-
lastSpawn = Date.now() - spawnDelay
78-
}
79-
} else timeouts = undefined
59+
try {
60+
while (true) {
61+
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
62+
const registration = await registered(options.file, true, timing.requestTimeout)
63+
if (registration.timedOut && registration.info !== undefined) {
64+
timeouts = {
65+
info: registration.info,
66+
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
67+
}
68+
if (timeouts.count >= 3) {
69+
announce("missing")
70+
await evict(registration.info, options, timing)
71+
timeouts = undefined
72+
lastSpawn = Date.now() - spawnDelay
73+
}
74+
} else timeouts = undefined
8075

81-
if (registration.service !== undefined) {
82-
spawnDelay = timing.spawnDelay
83-
const service = registration.service
84-
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
85-
if (compatible && service.state === "ready") return service.endpoint
86-
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
87-
if (!compatible) {
88-
announce("version-mismatch", service.version)
89-
await kill(service, options, timing).catch(() => undefined)
90-
lastSpawn = 0
91-
}
92-
} else {
93-
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
94-
const finished = [...contenders].filter(contenderFinished)
95-
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
96-
if (finished.some((item) => item.child.exitCode === 0)) {
97-
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
98-
}
99-
finished.forEach((item) => contenders.delete(item))
100-
if (failure !== undefined && contenders.size === 0) throw failure
101-
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
102-
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
103-
announce("missing")
104-
contenders.add(spawnContender())
105-
lastSpawn = Date.now()
76+
if (registration.service !== undefined) {
77+
spawnDelay = timing.spawnDelay
78+
const service = registration.service
79+
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
80+
if (compatible && service.state === "ready") return service.endpoint
81+
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
82+
if (!compatible) {
83+
announce("version-mismatch", service.version)
84+
await kill(service, options, timing).catch(() => undefined)
85+
lastSpawn = 0
86+
}
87+
} else {
88+
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
89+
const finished = [...contenders].filter(contenderFinished)
90+
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
91+
if (finished.some((item) => item.child.exitCode === 0)) {
92+
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
93+
}
94+
finished.forEach((item) => contenders.delete(item))
95+
if (failure !== undefined && contenders.size === 0) throw failure
96+
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
97+
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
98+
announce("missing")
99+
contenders.add(spawnContender())
100+
lastSpawn = Date.now()
101+
}
106102
}
103+
await delay(timing.pollInterval)
107104
}
108-
await delay(timing.pollInterval)
105+
} finally {
106+
contenders.forEach((contender) => contender.release())
109107
}
110108
}
111109

112-
function contenderFailure(contender: Contender) {
113-
const error = contender.error()
114-
if (error !== undefined) return error
115-
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
116-
return new Error(`Server process exited with code ${contender.child.exitCode}`)
117-
if (contender.child.signalCode !== null)
118-
return new Error(`Server process terminated by ${contender.child.signalCode}`)
119-
return undefined
120-
}
121-
122-
function contenderFinished(contender: Contender) {
123-
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
124-
}
125-
126110
/** Stop the registered local service. */
127111
export async function stop(options: StopOptions = {}) {
128112
const existing = await find(options)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { spawn, type ChildProcess } from "node:child_process"
2+
3+
export type ServiceContender = {
4+
readonly child: ChildProcess
5+
readonly error: () => Error | undefined
6+
readonly closed: () => boolean
7+
readonly stderr: () => string
8+
readonly release: () => void
9+
}
10+
11+
const stderrLimit = 8 * 1024
12+
13+
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
14+
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
15+
let error: Error | undefined
16+
let closed = false
17+
let stderr = Buffer.alloc(0)
18+
const onStderr = (chunk: Buffer) => {
19+
const tail = chunk.subarray(-stderrLimit)
20+
stderr =
21+
tail.length === stderrLimit
22+
? Buffer.from(tail)
23+
: Buffer.concat([stderr.subarray(-(stderrLimit - tail.length)), tail])
24+
}
25+
child.stderr?.on("data", onStderr)
26+
if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function") child.stderr.unref()
27+
child.once("error", (cause) => {
28+
error = new Error("Failed to start server", { cause })
29+
})
30+
child.once("close", () => {
31+
closed = true
32+
})
33+
child.unref()
34+
return {
35+
child,
36+
error: () => error,
37+
closed: () => closed,
38+
stderr: () => stderr.toString("utf8").trim(),
39+
release: () => {
40+
child.stderr?.off("data", onStderr)
41+
child.stderr?.resume()
42+
stderr = Buffer.alloc(0)
43+
},
44+
}
45+
}
46+
47+
export function contenderFailure(contender: ServiceContender) {
48+
const error = contender.error()
49+
if (error !== undefined) return error
50+
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
51+
return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr())
52+
if (contender.child.signalCode !== null)
53+
return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr())
54+
return undefined
55+
}
56+
57+
export function contenderFinished(contender: ServiceContender) {
58+
return contender.error() !== undefined || contender.closed()
59+
}
60+
61+
function startupError(message: string, stderr: string) {
62+
return new Error(stderr ? `${message}\n${stderr}` : message)
63+
}

packages/client/test/fixture/service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { appendFile, rename, writeFile } from "node:fs/promises"
33
const [registration, mode, delay] = process.argv.slice(2)
44
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
55
if (mode === "failed") process.exit(1)
6+
if (mode === "stderr-failed") {
7+
process.stderr.write("x".repeat(16_384) + "\nactionable startup failure\n")
8+
process.exit(1)
9+
}
610
if (mode === "record-start") {
711
await writeFile(registration + ".started", "")
812
process.exit(1)

packages/client/test/promise-service.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ test("reports a failed registered service", async () => {
7272
)
7373
})
7474

75+
test("reports a bounded contender stderr tail with native promises", async () => {
76+
const directory = await temp()
77+
const registration = join(directory, "service.json")
78+
const error = await Service.ensure({
79+
file: registration,
80+
version: "test",
81+
command: [process.execPath, fixture, registration, "stderr-failed"],
82+
}).catch((error: unknown) => error)
83+
84+
expect(error).toBeInstanceOf(Error)
85+
if (!(error instanceof Error)) throw error
86+
expect(error.message).toContain("actionable startup failure")
87+
expect(error.message.length).toBeLessThan(9_000)
88+
}, 10_000)
89+
7590
test("evicts an unresponsive registered service before starting its replacement", async () => {
7691
const directory = await temp()
7792
const registration = join(directory, "service.json")

packages/client/test/service.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,23 @@ test("reports a contender that fails to start", async () => {
201201
).rejects.toThrow("Server process exited with code 1")
202202
})
203203

204+
test("reports a bounded contender stderr tail", async () => {
205+
const directory = await temp()
206+
const registration = join(directory, "service.json")
207+
const error = await run(
208+
Service.ensure({
209+
file: registration,
210+
version: "test",
211+
command: [process.execPath, fixture, registration, "stderr-failed"],
212+
}),
213+
).catch((error: unknown) => error)
214+
215+
expect(error).toBeInstanceOf(Error)
216+
if (!(error instanceof Error)) throw error
217+
expect(error.message).toContain("actionable startup failure")
218+
expect(error.message.length).toBeLessThan(9_000)
219+
}, 10_000)
220+
204221
test("reports a contender terminated by a signal", async () => {
205222
const directory = await temp()
206223
const registration = join(directory, "service.json")

0 commit comments

Comments
 (0)