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
9 changed files with 130 additions and 137 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")
@@ -1,6 +1,5 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -15,13 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
return fileURLToPath(url, { windows: url.hostname !== "" || /^\/[a-zA-Z]:\//.test(url.pathname) })
}
const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
@@ -44,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -63,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
if (imageMimes.has(file.mime)) return [media(file)]
return []
}
@@ -249,14 +249,13 @@ Recent work
])
})
test("exposes admitted reference directory source paths in model context", () => {
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///references/harness-engineering" },
name: "harness-engineering",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
expect(directory.source).toEqual({ type: "uri", uri: "file:///references/harness-engineering" })
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -278,8 +277,8 @@ Recent work
{ type: "text", text: "Review this directory" },
{
type: "text",
text: "\n\nAttached directory: /references/harness-engineering\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
},
],
})
@@ -315,7 +314,7 @@ Recent work
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
"\n\nAttached directory: /project/src\n\nindex.ts",
"\n\nAttached directory: src/\n\nindex.ts",
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
@@ -342,9 +341,7 @@ Recent work
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: "\n\nAttached directory: /project/src\n\nindex.ts" },
])
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
@@ -376,64 +373,6 @@ Recent work
])
})
test("exposes admitted local image source paths before provider media", () => {
const data = Base64.make("AAECAw==")
const image = FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/IMG_3480.JPG" },
name: "IMG_3480.JPG",
})
expect(image.source).toEqual({ type: "uri", uri: "file:///project/IMG_3480.JPG" })
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image-path"),
type: "user",
text: "Inspect this image",
files: [image],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: "Attached file: /project/IMG_3480.JPG" },
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
])
})
test("does not add attachment location text for non-local provider media", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-remote-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "https://example.com/image.png" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(