refactor(client): simplify local service lifecycle
This commit is contained in:
parent
4f1298063b
commit
75bc611ef1
51 changed files with 635 additions and 534 deletions
|
|
@ -15,7 +15,7 @@ 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 () => {
|
||||
test("health.get decodes the readiness response", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
|
||||
)
|
||||
|
|
@ -24,7 +24,7 @@ test("health.get treats an old server as ready", async () => {
|
|||
return yield* client.health.get()
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result.status).toEqual({ type: "ready" })
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
|
|
|
|||
|
|
@ -48,23 +48,11 @@ const server = Bun.serve({
|
|||
}
|
||||
if (mode === "legacy") return Response.json({ healthy: true })
|
||||
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 },
|
||||
)
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { 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 },
|
||||
)
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
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 })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,15 +20,28 @@ describe("public import boundaries", () => {
|
|||
expect(within(root, core)).toEqual([])
|
||||
expect(within(root, server)).toEqual([])
|
||||
|
||||
// The effect entry includes local service lifecycle (node spawn/fs), so it
|
||||
// bundles for bun; the boundary assertions below are what matter.
|
||||
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
|
||||
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
|
||||
|
||||
expect(within(network, effect).length).toBeGreaterThan(0)
|
||||
expect(within(network, schema).length).toBeGreaterThan(0)
|
||||
expect(within(network, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(network, core)).toEqual([])
|
||||
expect(within(network, server)).toEqual([])
|
||||
|
||||
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
|
||||
|
||||
expect(within(promiseService, effect)).toEqual([])
|
||||
expect(within(promiseService, schema)).toEqual([])
|
||||
expect(within(promiseService, protocol)).toEqual([])
|
||||
expect(within(promiseService, core)).toEqual([])
|
||||
expect(within(promiseService, server)).toEqual([])
|
||||
|
||||
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
|
||||
|
||||
expect(within(effectService, effect).length).toBeGreaterThan(0)
|
||||
expect(within(effectService, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(effectService, core)).toEqual([])
|
||||
expect(within(effectService, server)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
96
packages/client/test/promise-service.test.ts
Normal file
96
packages/client/test/promise-service.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service, type StartReason } from "../src/promise/service"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("discovers a registered service", async () => {
|
||||
const registration = await setup("graceful")
|
||||
|
||||
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
|
||||
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||
)
|
||||
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("starts a missing service with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const starts: StartReason[] = []
|
||||
|
||||
const endpoint = await Service.start({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated"],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(starts).toEqual(["missing"])
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("reports a failed registered service", async () => {
|
||||
const registration = await setup("failed-owner")
|
||||
|
||||
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
|
||||
"Background service failed to start",
|
||||
)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
|
||||
await waitForFile(registration)
|
||||
return registration
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for process ${pid}`)
|
||||
}
|
||||
|
|
@ -71,10 +71,10 @@ test("health.stop sends exact replacement identity", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
expect(await client.health.stop({ instanceID: "instance", targetVersion: "next" })).toEqual({ accepted: true })
|
||||
expect(await client.health.stop({ instanceID: "instance" })).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" })
|
||||
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Effect } from "effect"
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service } from "../src/effect/index"
|
||||
import { Service, type StartReason } from "../src/effect/service"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
|
|
@ -23,7 +23,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
|||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const starts: StartReason[] = []
|
||||
const first = run(
|
||||
Service.start({
|
||||
file: registration,
|
||||
|
|
@ -43,7 +43,6 @@ 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 () => {
|
||||
|
|
@ -51,15 +50,10 @@ test("waits for a registered service to finish starting", async () => {
|
|||
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) }),
|
||||
)
|
||||
const result = run(Service.start({ file: registration, version: "test", command: [] }))
|
||||
|
||||
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)
|
||||
})
|
||||
|
|
@ -70,23 +64,22 @@ test("reports a failed registered service without spawning", async () => {
|
|||
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.",
|
||||
})
|
||||
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow(
|
||||
"Background service failed to start",
|
||||
)
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("requests graceful replacement of the exact service instance", async () => {
|
||||
test("requests graceful stop 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 run(Service.stop({ file: registration }))
|
||||
await process.exited
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" })
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
||||
|
|
@ -120,7 +113,7 @@ test("a legacy health response is still replaced", async () => {
|
|||
const existing = spawn(registration, "legacy")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const starts: StartReason[] = []
|
||||
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue