feat: expose background service lifecycle (#36895)

This commit is contained in:
Kit Langton 2026-07-14 16:38:22 -04:00 committed by GitHub
commit ece2b16cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2421 additions and 293 deletions

View file

@ -55,6 +55,7 @@
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",

View file

@ -0,0 +1,143 @@
#!/usr/bin/env bun
import { Service } from "@opencode-ai/client/effect"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
const env = {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
spawnService()
spawnService()
const registration = await waitForRegistration()
const info = await Schema.decodeUnknownPromise(Service.Info)(await Bun.file(registration).json())
if (info.id === undefined || info.password === undefined) throw new Error("Registration is missing service identity")
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const health = await waitForReady(info.url, headers)
if (health.pid !== info.pid || health.instanceID !== info.id)
throw new Error("Health identity does not match registration")
const tokenHealth = await fetch(
new URL(`/api/health?auth_token=${token}`, info.url),
{ signal: AbortSignal.timeout(5_000) },
)
if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(
new URL(`/openapi.json?auth_token=${token}`, info.url),
{ signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedHealth.status !== 401) throw new Error("Compiled service exposed health without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
const winner = processes.find((process) => process.pid === info.pid)
const loser = processes.find((process) => process.pid !== info.pid)
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id, targetVersion: "smoke-next" }),
signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()),
)
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
} catch (cause) {
failure = cause
} finally {
processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited))
}
const output = await Promise.all(errors)
await fs.rm(root, { recursive: true, force: true })
if (failure)
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
cause: failure,
})
function spawnService() {
const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" })
processes.push(process)
errors.push(new Response(process.stderr).text())
return process
}
async function waitForRegistration() {
const directory = path.join(root, "state", "opencode")
for (let attempt = 0; attempt < 400; attempt++) {
const files = await fs.readdir(directory).catch(() => [])
const file = files.find(
(file) => file === "service.json" || (file.startsWith("service-") && file.endsWith(".json")),
)
if (file) return path.join(directory, file)
await Bun.sleep(25)
}
throw new Error("Compiled service did not publish registration")
}
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const health = await fetch(new URL("/api/health", url), {
headers,
signal: AbortSignal.timeout(1_000),
})
.then((response) => response.json())
.then(Schema.decodeUnknownPromise(ServiceStatus.Health))
.catch(() => undefined)
if (health === undefined) {
await Bun.sleep(25)
continue
}
if (health.status.type === "ready") return health
if (health.status.type === "failed") throw new Error(health.status.message)
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
}
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}

View file

@ -9,7 +9,7 @@ export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
yield* Service.stop(options)
yield* Service.stop(options, { targetVersion: options.version })
const transport = yield* Service.start(options)
process.stdout.write(transport.url + EOL)
}),

View file

@ -8,7 +8,13 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const found = yield* Service.discover(yield* ServiceConfig.options())
process.stdout.write((found ? found.url : "stopped") + EOL)
const options = yield* ServiceConfig.options()
const status = yield* Service.status(options)
if (status.type !== "ready") {
process.stdout.write(status.type + EOL)
return
}
const found = yield* Service.discover({ ...options, version: undefined })
process.stdout.write((found?.url ?? status.type) + EOL)
}),
)

View file

@ -7,11 +7,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { start } from "@opencode-ai/server/process"
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
@ -28,7 +27,7 @@ export type Options = {
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
),
)
@ -38,15 +37,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file)
if (
serviceOptions !== undefined &&
lockScope !== undefined &&
(yield* Service.discover(serviceOptions)) !== undefined
) {
yield* Scope.close(lockScope, Exit.void)
return
if (serviceOptions !== undefined) {
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
Effect.as(true),
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
)
if (!acquired) return yield* Effect.void
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
}
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
if (options.mode === "stdio") {
@ -61,77 +60,82 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* start({
const instanceID = randomUUID()
const server = yield* start({
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
restartContinuity: options.mode === "service",
instanceID,
service:
serviceOptions === undefined
? undefined
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
if (lockScope !== undefined) {
yield* register(address, password)
yield* Scope.close(lockScope, Exit.void)
}
const url = HttpServer.formatAddress(address)
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose()
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
})
const acquireServiceLock = Effect.fnUntraced(function* (file: string) {
const flock = yield* EffectFlock.Service
const scope = yield* Scope.make()
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
yield* flock
.acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 })
.pipe(Effect.provideService(Scope.Scope, scope))
return scope
})
// The latest atomic registration wins. A displaced process notices the new id,
// exits, and cannot remove its successor's registration from its finalizer.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
) {
const fs = yield* FileSystem.FileSystem
const options = yield* ServiceConfig.options()
const id = randomUUID()
const temp = options.file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const encoded = yield* encodeInfo({
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, options.file)
const currentID = fs.readFileString(options.file).pipe(
}
const encoded = yield* encodeInfo(info)
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* publish
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
const assertRegistration = Effect.gen(function* () {
const found = yield* current
if (
found !== undefined &&
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
)
return
yield* publish
})
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
current.pipe(
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
yield* assertRegistration.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.forkScoped,
)
})
function waitForStdinClose() {

View file

@ -16,7 +16,7 @@ export type Args = {
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
readonly reload?: () => Promise<void>
}
@ -27,9 +27,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
const password = yield* Env.password
const endpoint = {
url: args.server,
auth: password
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Service.Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
@ -51,19 +49,14 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
const reconnectOptions = { ...options, version: undefined }
return {
endpoint,
reconnect: (attempt) =>
Effect.runPromise(
Effect.gen(function* () {
if (attempt > 3) return yield* Service.start(reconnectOptions)
const endpoint = yield* Service.discover(reconnectOptions)
if (endpoint !== undefined) return endpoint
return yield* Effect.fail(new Error("Background server is unavailable"))
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
reconnect: (onStatus, signal) =>
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
signal,
}),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
@ -80,7 +73,8 @@ const resolveManaged = Effect.fnUntraced(function* (
const compatible = yield* Service.discover(options)
if (compatible !== undefined) return compatible
const existing = yield* Service.discover({ ...options, version: undefined })
if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
if (existing !== undefined)
return yield* Effect.fail(new Error("Background server version does not match this client"))
return yield* Service.start(options)
})

View file

@ -1,7 +1,8 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Schema } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
@ -20,25 +21,63 @@ const keys = ["hostname", "port", "password"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (version === undefined) return false
if (version === installedVersion) return true
const prefix = `0.0.0-${channel}-`
if (!version.startsWith(prefix)) return false
return /^\d+(?:\.\d+)?$/.test(version.slice(prefix.length))
}
export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string,
file: string,
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
const registration = yield* decodeRegistration(text.value).pipe(Effect.option)
if (Option.isNone(registration)) return
if (!versionBelongsToChannel(registration.value.version, channel, installedVersion)) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (keys.includes(key as Key)) return key as Key
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
}
const env = Effect.gen(function* () {
const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
const name = filename()
const file = path.join(global.state, name)
return {
fs,
file: path.join(global.state, filename),
configFile: path.join(global.config, filename),
file,
legacyFile: path.join(global.state, "service.json"),
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file } = yield* env
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
@ -50,7 +89,7 @@ export const options = Effect.fnUntraced(function* () {
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
const { fs, configFile } = yield* paths
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
@ -58,7 +97,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () {
})
const write = Effect.fn("cli.service-config.write")(function* (value: Info) {
const { fs, configFile } = yield* env
const { fs, configFile } = yield* paths
const temp = configFile + ".tmp"
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
@ -93,6 +132,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
return yield* password()
}
}
throw new Error(`Unknown service config key: ${key}`)
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {

View file

@ -35,6 +35,61 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service filenames isolate installation channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("preview registration migration never moves stable discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
const legacy = path.join(root, "service.json")
const target = path.join(root, ServiceConfig.filename("preview-a"))
try {
await fs.writeFile(
legacy,
JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
)
await Effect.runPromise(
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(legacy).exists()).toBe(true)
expect(await Bun.file(target).json()).toMatchObject({ id: "old-preview" })
await fs.rm(target)
await fs.writeFile(legacy, JSON.stringify({ id: "stable", version: "1.2.3", url: "http://localhost:4096", pid: 1 }))
await Effect.runPromise(
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(legacy).exists()).toBe(true)
expect(await Bun.file(target).exists()).toBe(false)
await fs.writeFile(
legacy,
JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
)
await fs.writeFile(target, JSON.stringify({ id: "current-preview" }))
await Effect.runPromise(
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(legacy).exists()).toBe(true)
expect(await Bun.file(target).json()).toMatchObject({ id: "current-preview" })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("concurrent service processes elect one server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
const database = path.join(root, "opencode.db")
@ -74,18 +129,55 @@ test("concurrent service processes elect one server", async () => {
}),
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
const registration = path.join(root, "state", "opencode", "service-local.json")
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
try {
const registration = path.join(root, "state", "opencode", "service-local.json")
const info = await waitForInfo(registration)
const winner = info.pid === first.pid ? first : second
const loser = info.pid === first.pid ? second : first
const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)])
const winner = processes.find((process) => process.pid === info.pid)
const losers = processes.filter((process) => process.pid !== info.pid)
const exited = await Promise.all(
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
)
expect(exited).toBe(true)
expect(winner.exitCode).toBe(null)
expect(exited).toEqual(losers.map(() => true))
expect(winner?.exitCode).toBe(null)
expect(
await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toMatchObject({
healthy: true,
pid: info.pid,
instanceID: info.id,
status: { type: "ready" },
})
const blockedTemp = registration + "." + info.id + ".tmp"
await fs.mkdir(blockedTemp)
await fs.rm(registration)
await Bun.sleep(6_000)
expect(await Bun.file(registration).exists()).toBe(false)
await fs.rm(blockedTemp, { recursive: true })
const restored = await waitForInfo(registration)
expect(restored.id).toBe(info.id)
expect(restored.pid).toBe(info.pid)
await fs.writeFile(registration, "not-json")
const repaired = await waitForInfo(registration)
expect(repaired.id).toBe(info.id)
expect(repaired.pid).toBe(info.pid)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
const contenderExited = await Promise.race([
contender.exited.then(() => true),
Bun.sleep(10_000).then(() => false),
])
expect(contenderExited).toBe(true)
expect((await waitForInfo(registration)).id).toBe(info.id)
} finally {
contender.kill("SIGTERM")
await contender.exited
}
expect(
await withDatabase(
database,
@ -100,13 +192,70 @@ test("concurrent service processes elect one server", async () => {
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(
Service.stop({ file: registration }, { targetVersion: "next" }).pipe(Effect.provide(NodeFileSystem.layer)),
)
await winner?.exited
} finally {
first.kill("SIGTERM")
second.kill("SIGTERM")
await Promise.all([first.exited, second.exited])
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.map((process) => process.exited))
try {
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
}
}, 60_000)
test("a failed service stays registered and owns the lock until stopped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const database = path.join(root, "database")
await fs.mkdir(database)
const env = {
...process.env,
HOME: root,
OPENCODE_DB: database,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
const info = await waitForInfo(registration)
const status = await Effect.runPromise(
Service.status({ file: registration }).pipe(
Effect.filterOrFail((status) => status.type === "failed"),
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
Effect.provide(NodeFileSystem.layer),
),
)
expect(status).toEqual({
type: "failed",
version: info.version,
message: "The background service could not start.",
action: "Run `opencode service restart` after checking the service logs.",
})
expect(owner.exitCode).toBe(null)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
expect((await waitForInfo(registration)).id).toBe(info.id)
expect(owner.exitCode).toBe(null)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
})
}, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped))
@ -143,7 +292,7 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
}
async function waitForInfo(file: string) {
for (let attempt = 0; attempt < 200; attempt++) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
.json()
.catch(() => undefined)