test(client): accelerate service lifecycle tests (#41879)

This commit is contained in:
Kit Langton 2026-08-11 20:50:25 -04:00 committed by GitHub
parent c217ebe2ad
commit 0df3070d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 176 additions and 97 deletions

View file

@ -4,6 +4,7 @@ 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 { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@ -52,11 +53,12 @@ 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 timing = ensureTiming(options)
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let spawnDelay = timing.spawnDelay
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@ -80,7 +82,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
})
})
const found = yield* Effect.gen(function* () {
const registration = yield* registered(options.file, true)
const registration = yield* registered(options.file, true, timing.requestTimeout)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
@ -90,20 +92,20 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options)
yield* evict(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = 5_000
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
yield* kill(service, options).pipe(Effect.ignore)
yield* kill(service, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@ -111,7 +113,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, 30_000)
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
@ -125,7 +127,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}).pipe(
Effect.repeat({
until: Option.isSome,
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
}),
)
if (Option.isNone(found))
@ -150,7 +152,7 @@ function contenderFinished(contender: Contender) {
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
})
function fallback() {
@ -198,7 +200,11 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
return (yield* probeResult(info, allowLegacy)).service
})
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
const probeResult = Effect.fnUntraced(function* (
info: Info,
allowLegacy = false,
timeout = defaultEnsureTiming.requestTimeout,
) {
const endpoint = {
url: info.url,
auth:
@ -206,7 +212,7 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(2_000)
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
@ -249,10 +255,10 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
}
})
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false, timeout?: number) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* probeResult(info, allowLegacy)) }
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
@ -263,7 +269,8 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
const poll = Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(100)])
const poll = (timing: EnsureTiming) =>
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
@ -280,21 +287,25 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll))
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
const requested = yield* requestStop(service)
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
@ -303,25 +314,25 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: { read
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll), Effect.option)
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll))
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService) {
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(2_000),
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const

View file

@ -3,6 +3,7 @@ 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 { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@ -32,12 +33,13 @@ 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 timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let spawnDelay = timing.spawnDelay
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@ -62,7 +64,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true)
const registration = await registered(options.file, true, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
@ -70,21 +72,21 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options)
await evict(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
spawnDelay = 5_000
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
await kill(service, options).catch(() => undefined)
await kill(service, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@ -92,7 +94,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, 30_000)
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
@ -103,7 +105,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
lastSpawn = Date.now()
}
}
await delay(1_000)
await delay(timing.pollInterval)
}
}
@ -124,7 +126,7 @@ function contenderFinished(contender: Contender) {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
}
function fallback() {
@ -161,7 +163,7 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
return (await probeResult(info, allowLegacy)).service
}
async function probeResult(info: Info, allowLegacy = false) {
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
@ -169,7 +171,7 @@ async function probeResult(info: Info, allowLegacy = false) {
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(2_000)
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal,
@ -206,10 +208,10 @@ async function probeResult(info: Info, allowLegacy = false) {
}
}
async function registered(file?: string, allowLegacy = false) {
async function registered(file?: string, allowLegacy = false, timeout?: number) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(await probeResult(info, allowLegacy)) }
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
}
async function find(options: { readonly file?: string }) {
@ -231,10 +233,10 @@ function stopped(pid: number) {
}
}
async function waitUntilStopped(pid: number) {
for (let attempt = 0; attempt <= 100; attempt++) {
async function waitUntilStopped(pid: number, timing: EnsureTiming) {
for (let attempt = 0; attempt <= timing.stopPollAttempts; attempt++) {
if (stopped(pid)) return true
if (attempt < 100) await delay(50)
if (attempt < timing.stopPollAttempts) await delay(timing.stopPollInterval)
}
return false
}
@ -243,42 +245,42 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }) {
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid)) return
if (await waitUntilStopped(info.pid, timing)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }) {
const requested = await requestStop(service)
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid)) return
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid)))
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService) {
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = await fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(2_000),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined

View file

@ -0,0 +1,33 @@
export type EnsureTiming = {
readonly pollInterval: number
readonly attempts: number
readonly requestTimeout: number
readonly spawnDelay: number
readonly maxSpawnDelay: number
readonly promiseTimeout: number
readonly stopPollInterval: number
readonly stopPollAttempts: number
}
const timings = new WeakMap<object, EnsureTiming>()
export const defaultEnsureTiming: EnsureTiming = {
pollInterval: 1_000,
attempts: 120,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
promiseTimeout: 120_000,
stopPollInterval: 50,
stopPollAttempts: 100,
}
export function ensureTiming(options: object) {
return timings.get(options) ?? defaultEnsureTiming
}
// Keep test timing out of the public lifecycle option types.
export function withEnsureTiming<A extends object>(options: A, overrides: Partial<EnsureTiming>): A {
timings.set(options, { ...defaultEnsureTiming, ...overrides })
return options
}

View file

@ -0,0 +1,26 @@
import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
promiseTimeout: 3_000,
stopPollInterval: 5,
}
export function accelerate<A extends object, B>(ensure: (options: A) => B) {
return (options: A) => ensure(withEnsureTiming(options, timing))
}
export 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}`)
}

View file

@ -17,7 +17,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
if (mode === "coordinated-failed-loser") await Bun.sleep(Number(delay ?? 1_500))
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
@ -30,7 +30,7 @@ const server = Bun.serve({
async fetch(request) {
const pathname = new URL(request.url).pathname
if (pathname === "/api/service/stop" && mode === "reject-stop") {
await writeFile(registration + ".stop-attempt", "")
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
@ -42,6 +42,7 @@ const server = Bun.serve({
}
if (pathname !== "/api/health") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".health-request", "")
if (mode === "hanging") {
await appendFile(registration + ".requests", process.pid + "\n")
return new Promise<Response>(() => {})

View file

@ -3,8 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service, type EnsureReason } from "../src/promise/service"
import { accelerate, waitForExit } from "./fixture/service-timing"
const fixture = join(import.meta.dir, "fixture/service.ts")
const ensure = accelerate(Service.ensure)
const processes: Bun.Subprocess[] = []
const directories: string[] = []
@ -28,7 +30,7 @@ test("ensures a missing service with native promises", async () => {
const registration = join(directory, "service.json")
const starts: EnsureReason[] = []
const endpoint = await Service.ensure({
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
@ -42,16 +44,16 @@ test("ensures a missing service with native promises", async () => {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
})
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
command: [process.execPath, fixture, registration, "coordinated-failed-loser", "300"],
})
const info = await Bun.file(registration).json()
try {
@ -60,12 +62,12 @@ test("waits for a live contender when another native contender fails", async ()
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.ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
"Background service failed to start",
)
})
@ -81,7 +83,7 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await Service.ensure({
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
@ -94,7 +96,7 @@ test("evicts an unresponsive registered service before starting its replacement"
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
}, 20_000)
})
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
@ -126,15 +128,3 @@ async function waitForFile(file: string) {
}
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}`)
}

View file

@ -5,8 +5,10 @@ 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"
import { accelerate, waitForExit } from "./fixture/service-timing"
const fixture = join(import.meta.dir, "fixture/service.ts")
const ensure = accelerate(Service.ensure)
const processes: Bun.Subprocess[] = []
const directories: string[] = []
@ -25,7 +27,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
const starts: EnsureReason[] = []
const first = run(
Service.ensure({
ensure({
file: registration,
version: "test",
command: [],
@ -34,7 +36,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
)
await waitForFile(registration + ".first-request")
const resolved = await run(Service.ensure({ file: registration, version: "test" }))
const resolved = await run(ensure({ file: registration, version: "test" }))
expect(resolved.url).toBe(original.url)
await writeFile(registration + ".release", "")
@ -50,9 +52,9 @@ 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 result = run(Service.ensure({ file: registration, version: "test", command: [] }))
const result = run(ensure({ file: registration, version: "test", command: [] }))
await Bun.sleep(500)
await waitForFile(registration + ".health-request")
expect(process.exitCode).toBe(null)
await writeFile(registration + ".release", "")
expect((await result).url).toBe((await Bun.file(registration).json()).url)
@ -64,7 +66,7 @@ test("reports a failed registered service without spawning", async () => {
const process = spawn(registration, "failed-owner")
await waitForFile(registration)
await expect(run(Service.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
await expect(run(ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
"Background service failed to start",
)
expect(process.exitCode).toBe(null)
@ -78,7 +80,7 @@ test("evicts an unresponsive registered service before starting its replacement"
const original = await Bun.file(registration).json()
const endpoint = await run(
Service.ensure({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
@ -92,7 +94,8 @@ test("evicts an unresponsive registered service before starting its replacement"
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)
await waitForExit(replacement.pid)
})
test("requests graceful stop of the exact service instance", async () => {
const directory = await temp()
@ -114,7 +117,7 @@ test("does not spawn contenders while an incompatible service rejects replacemen
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
Service.ensure({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
@ -122,8 +125,7 @@ test("does not spawn contenders while an incompatible service rejects replacemen
{ signal: controller.signal },
)
await waitForFile(registration + ".stop-attempt")
await Bun.sleep(500)
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
@ -138,18 +140,18 @@ test("a legacy health response is still replaced", async () => {
await waitForFile(registration)
const starts: EnsureReason[] = []
const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
const result = run(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({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
@ -162,17 +164,18 @@ test("waits for a slow winner while bounding lock probes", async () => {
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 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({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
command: [process.execPath, fixture, registration, "coordinated-failed-loser", "300"],
}),
)
const info = await Bun.file(registration).json()
@ -180,62 +183,63 @@ test("waits for a live contender when another contender fails", async () => {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 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({
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({
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({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed-failed", "8000"],
command: [process.execPath, fixture, registration, "delayed-failed", "500"],
}),
),
).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({
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "8000"],
command: [process.execPath, fixture, registration, "delayed", "500"],
}),
)
await Bun.sleep(1_000)
await waitForFile(registration + ".starts")
const old = spawn(registration, "old")
await waitForFile(registration)
const endpoint = await starting
@ -246,8 +250,9 @@ test("replaces an incompatible owner that appears during startup", async () => {
await old.exited
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 20_000)
})
function run<A, E>(effect: Effect.Effect<A, E>) {
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
@ -276,6 +281,17 @@ async function waitForFile(file: string) {
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
async function health(url: string) {
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}