| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- import { NodeFileSystem } from "@effect/platform-node"
- import { afterEach, expect, test } from "bun:test"
- import { Effect } from "effect"
- import { mkdtemp, rm, writeFile } from "node:fs/promises"
- import { tmpdir } from "node:os"
- import { join } from "node:path"
- import { Service, type EnsureReason } from "../src/effect/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("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
- const directory = await temp()
- const registration = join(directory, "service.json")
- spawn(registration, "modern")
- await waitForFile(registration)
- const original = await Bun.file(registration).json()
- const starts: EnsureReason[] = []
- const first = run(
- Service.ensure({
- file: registration,
- version: "test",
- command: [],
- onStart: (reason) => starts.push(reason),
- }),
- )
- await waitForFile(registration + ".first-request")
- const resolved = await run(Service.ensure({ file: registration, version: "test" }))
- expect(resolved.url).toBe(original.url)
- await writeFile(registration + ".release", "")
- await first
- expect(starts).toEqual([])
- expect(await Bun.file(registration).json()).toEqual(original)
- expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
- })
- 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 result = run(Service.ensure({ file: registration, version: "test", command: [] }))
- await Bun.sleep(500)
- expect(process.exitCode).toBe(null)
- 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.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
- "Background service failed to start",
- )
- expect(process.exitCode).toBe(null)
- })
- test("evicts an unresponsive registered service before starting its replacement", async () => {
- const directory = await temp()
- const registration = join(directory, "service.json")
- const existing = spawn(registration, "hanging")
- await waitForFile(registration)
- const original = await Bun.file(registration).json()
- const endpoint = await run(
- Service.ensure({
- file: registration,
- version: "test",
- command: [process.execPath, fixture, registration, "delayed", "10"],
- }),
- )
- const replacement = await Bun.file(registration).json()
- expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
- expect(await existing.exited).toBe(0)
- expect(replacement.pid).not.toBe(original.pid)
- expect(endpoint.url).toBe(replacement.url)
- expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
- process.kill(replacement.pid, "SIGTERM")
- }, 20_000)
- 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 }))
- await process.exited
- expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
- })
- 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.ensure({
- 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 () => {
- const directory = await temp()
- const registration = join(directory, "service.json")
- const existing = spawn(registration, "legacy")
- await waitForFile(registration)
- const starts: EnsureReason[] = []
- const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
- await expect(result).rejects.toThrow("Missing service command")
- expect(starts).toEqual(["version-mismatch"])
- await existing.exited
- }, 10_000)
- 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.ensure({
- 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("waits for a live contender when another contender fails", async () => {
- const directory = await temp()
- const registration = join(directory, "service.json")
- const endpoint = await run(
- Service.ensure({
- file: registration,
- version: "test",
- command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
- }),
- )
- const info = await Bun.file(registration).json()
- try {
- expect(endpoint.url).toBe(info.url)
- } 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.ensure({
- 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.ensure({
- 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.ensure({
- 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.ensure({
- 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)))
- }
- function spawn(registration: string, mode: string, ...args: string[]) {
- const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
- stdout: "ignore",
- stderr: "inherit",
- })
- processes.push(subprocess)
- return subprocess
- }
- async function temp() {
- const directory = await mkdtemp(join(tmpdir(), "opencode-client-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 health(url: string) {
- return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
- }
|