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

@ -15,6 +15,18 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("health.get treats an old server as ready", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.health.get()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.status).toEqual({ type: "ready" })
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),

View file

@ -1,13 +1,45 @@
import { rename, writeFile } from "node:fs/promises"
import { appendFile, rename, writeFile } from "node:fs/promises"
const [registration, mode] = process.argv.slice(2)
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 === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true)
.catch(() => false)
if (!owner) process.exit()
if (mode === "coordinated") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
async fetch(request) {
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
const pathname = new URL(request.url).pathname
if (pathname === "/api/service/stop" && mode === "reject-stop") {
await writeFile(registration + ".stop-attempt", "")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
setTimeout(shutdown, 25)
return Response.json({ accepted: true })
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "modern" && requests === 1) {
await writeFile(registration + ".first-request", "")
@ -15,15 +47,33 @@ const server = Bun.serve({
return new Response(null, { status: 503 })
}
if (mode === "legacy") return Response.json({ healthy: true })
return Response.json({ healthy: true, version: "test", pid: process.pid })
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json(
{ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "starting" } },
{ status: 503 },
)
if (mode === "failed-owner")
return Response.json(
{
healthy: true,
version,
pid: process.pid,
instanceID: id,
status: { type: "failed", message: "Could not open the database.", action: "Check the service logs." },
},
{ status: 503 },
)
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "ready" } })
return Response.json({ healthy: true, version, pid: process.pid })
},
})
await writeFile(
registration + ".tmp",
JSON.stringify({
id: crypto.randomUUID(),
version: mode === "legacy" ? undefined : "test",
id,
version: mode === "legacy" ? undefined : version,
url: server.url.toString(),
pid: process.pid,
}),
@ -31,7 +81,7 @@ await writeFile(
)
await rename(registration + ".tmp", registration)
const shutdown = () => {
function shutdown() {
server.stop(true)
process.exit()
}

View file

@ -16,7 +16,7 @@ test("exposes every standard HTTP API group", () => {
"generate",
"provider",
"integration",
"server.mcp",
"mcp",
"credential",
"project",
"form",
@ -61,6 +61,22 @@ test("server.get uses the public HTTP contract", async () => {
expect(request?.url).toBe("http://localhost:3000/api/server")
})
test("health.stop sends exact replacement identity", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ accepted: true })
},
})
expect(await client.health.stop({ instanceID: "instance", targetVersion: "next" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance", targetVersion: "next" })
})
test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@ -77,7 +93,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => {
},
})
const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } })
const result = await client.mcp.resource.catalog({ location: { directory: "/tmp/project" } })
expect(result.data.resources[0]?.uri).toBe("docs://readme")
expect(request?.method).toBe("GET")

View file

@ -43,6 +43,75 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(starts).toEqual([])
expect(await Bun.file(registration).json()).toEqual(original)
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
expect(await run(Service.status({ file: registration }))).toEqual({ type: "ready", version: "test" })
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "starting")
await waitForFile(registration)
const statuses: Service.Status[] = []
const result = run(
Service.start({ file: registration, version: "test", command: [], onStatus: (status) => statuses.push(status) }),
)
await Bun.sleep(500)
expect(process.exitCode).toBe(null)
expect(statuses).toContainEqual({ type: "starting", version: "test" })
expect(statuses.filter((status) => status.type === "starting")).toHaveLength(1)
await writeFile(registration + ".release", "")
expect((await result).url).toBe((await Bun.file(registration).json()).url)
})
test("reports a failed registered service without spawning", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "failed-owner")
await waitForFile(registration)
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toMatchObject({
message: "Could not open the database.",
action: "Check the service logs.",
})
expect(process.exitCode).toBe(null)
})
test("requests graceful replacement of the exact service instance", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "graceful")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }, { targetVersion: "next" }))
await process.exited
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" })
})
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
)
await waitForFile(registration + ".stop-attempt")
await Bun.sleep(500)
controller.abort()
await starting.catch(() => undefined)
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
})
test("a legacy health response is still replaced", async () => {
@ -57,9 +126,95 @@ test("a legacy health response is still replaced", async () => {
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
await existing.exited
})
}, 10_000)
function run<A, E>(effect: Effect.Effect<A, E, never>) {
test("waits for a slow winner while bounding lock probes", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("reports a contender that fails to start", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "failed"],
}),
),
).rejects.toThrow("Server process exited with code 1")
}, 10_000)
test("reports a contender terminated by a signal", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "signal"],
}),
),
).rejects.toThrow(/Server process (terminated by|exited with code)/)
}, 10_000)
test("reports a slow contender that eventually fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed-failed", "8000"],
}),
),
).rejects.toThrow("Server process exited with code 1")
}, 15_000)
test("replaces an incompatible owner that appears during startup", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const starting = run(
Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "8000"],
}),
)
await Bun.sleep(1_000)
const old = spawn(registration, "old")
await waitForFile(registration)
const endpoint = await starting
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(info.version).toBe("test")
await old.exited
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 20_000)
function run<A, E>(effect: Effect.Effect<A, E>) {
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
}