Skip to content

Commit 0df3070

Browse files
authored
test(client): accelerate service lifecycle tests (#41879)
1 parent c217ebe commit 0df3070

7 files changed

Lines changed: 176 additions & 97 deletions

File tree

packages/client/src/effect/service.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from "node:child_process"
44
import { homedir } from "node:os"
55
import { join } from "node:path"
66
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
7+
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
78

89
export * from "../service.js"
910
/** Contents of the local service registration file. */
@@ -52,11 +53,12 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
5253
// becomes discoverable. A contender is never killed merely for slow startup.
5354
/** Ensure a healthy, compatible local service is running. */
5455
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
56+
const timing = ensureTiming(options)
5557
const contenders = new Set<Contender>()
5658
let timeouts: { readonly info: Info; readonly count: number } | undefined
5759
let announced = false
5860
let lastSpawn = 0
59-
let spawnDelay = 5_000
61+
let spawnDelay = timing.spawnDelay
6062
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
6163
Effect.sync(() => {
6264
if (announced) return
@@ -80,7 +82,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
8082
})
8183
})
8284
const found = yield* Effect.gen(function* () {
83-
const registration = yield* registered(options.file, true)
85+
const registration = yield* registered(options.file, true, timing.requestTimeout)
8486
const info = registration.info
8587
const service = registration.service
8688
if (registration.timedOut && info !== undefined) {
@@ -90,28 +92,28 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
9092
}
9193
if (timeouts.count >= 3) {
9294
yield* announce("missing")
93-
yield* evict(info, options)
95+
yield* evict(info, options, timing)
9496
timeouts = undefined
9597
lastSpawn = Date.now() - spawnDelay
9698
}
9799
} else timeouts = undefined
98100
if (service !== undefined) {
99-
spawnDelay = 5_000
101+
spawnDelay = timing.spawnDelay
100102
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
101103
if (compatible && service.state === "ready") return Option.some(service)
102104
if (compatible && service.state === "failed")
103105
return yield* Effect.fail(new Error("Background service failed to start"))
104106
if (compatible) return Option.none<LocalService>()
105107
yield* announce("version-mismatch", service.version)
106-
yield* kill(service, options).pipe(Effect.ignore)
108+
yield* kill(service, options, timing).pipe(Effect.ignore)
107109
lastSpawn = 0
108110
return Option.none<LocalService>()
109111
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
110112

111113
const finished = [...contenders].filter(contenderFinished)
112114
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
113115
if (finished.some((item) => item.child.exitCode === 0)) {
114-
spawnDelay = Math.min(spawnDelay * 2, 30_000)
116+
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
115117
}
116118
finished.forEach((item) => contenders.delete(item))
117119
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
125127
}).pipe(
126128
Effect.repeat({
127129
until: Option.isSome,
128-
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
130+
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
129131
}),
130132
)
131133
if (Option.isNone(found))
@@ -150,7 +152,7 @@ function contenderFinished(contender: Contender) {
150152
/** Stop the registered local service. */
151153
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
152154
const existing = yield* find(options)
153-
if (existing !== undefined) yield* kill(existing, options)
155+
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
154156
})
155157

156158
function fallback() {
@@ -198,15 +200,19 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
198200
return (yield* probeResult(info, allowLegacy)).service
199201
})
200202

201-
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
203+
const probeResult = Effect.fnUntraced(function* (
204+
info: Info,
205+
allowLegacy = false,
206+
timeout = defaultEnsureTiming.requestTimeout,
207+
) {
202208
const endpoint = {
203209
url: info.url,
204210
auth:
205211
info.password === undefined
206212
? undefined
207213
: { type: "basic" as const, username: "opencode", password: info.password },
208214
} satisfies Endpoint
209-
const signal = AbortSignal.timeout(2_000)
215+
const signal = AbortSignal.timeout(timeout)
210216
const result = yield* Effect.promise(() =>
211217
fetch(new URL("/api/health", info.url), {
212218
headers: headers(endpoint),
@@ -249,10 +255,10 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
249255
}
250256
})
251257

252-
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
258+
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false, timeout?: number) {
253259
const info = yield* read(file)
254260
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
255-
return { info, ...(yield* probeResult(info, allowLegacy)) }
261+
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
256262
})
257263

258264
// Health-checked lookup without the version gate: lifecycle operations must be
@@ -263,7 +269,8 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
263269

264270
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
265271
// discovery window.
266-
const poll = Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(100)])
272+
const poll = (timing: EnsureTiming) =>
273+
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
267274

268275
const signal = (pid: number, name: NodeJS.Signals) =>
269276
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
@@ -280,21 +287,25 @@ function same(left: Info, right: Info) {
280287
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
281288
}
282289

283-
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
290+
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
284291
const current = yield* read(options.file)
285292
if (current === undefined || !same(current, info)) return
286293
yield* signal(info.pid, "SIGTERM")
287-
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
294+
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
288295
if (Option.isSome(done)) return
289296

290297
const latest = yield* read(options.file)
291298
if (latest === undefined || !same(latest, info)) return
292299
yield* signal(info.pid, "SIGKILL")
293-
yield* stopped(info.pid).pipe(Effect.retry(poll))
300+
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
294301
})
295302

296-
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
297-
const requested = yield* requestStop(service)
303+
const kill = Effect.fnUntraced(function* (
304+
service: LocalService,
305+
options: { readonly file?: string },
306+
timing: EnsureTiming,
307+
) {
308+
const requested = yield* requestStop(service, timing.requestTimeout)
298309
if (requested === "rejected") return
299310
if (requested === "unsupported") {
300311
// A stale registration may point at a reused PID. Authenticate again
@@ -303,25 +314,25 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: { read
303314
if (current === undefined || !same(current.info, service.info)) return
304315
yield* signal(service.info.pid, "SIGTERM")
305316
}
306-
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll), Effect.option)
317+
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
307318
if (Option.isSome(done)) return
308319

309320
const latest = yield* find(options)
310321
if (latest === undefined || !same(latest.info, service.info)) return
311322
yield* signal(service.info.pid, "SIGKILL")
312-
yield* stopped(service.info.pid).pipe(Effect.retry(poll))
323+
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
313324
})
314325

315326
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
316327

317-
const requestStop = Effect.fnUntraced(function* (service: LocalService) {
328+
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
318329
if (service.info.id === undefined || service.legacy) return "unsupported" as const
319330
const response = yield* Effect.tryPromise(() =>
320331
fetch(new URL("/api/service/stop", service.info.url), {
321332
method: "POST",
322333
headers: { ...headers(service.endpoint), "content-type": "application/json" },
323334
body: JSON.stringify({ instanceID: service.info.id }),
324-
signal: AbortSignal.timeout(2_000),
335+
signal: AbortSignal.timeout(timeout),
325336
}),
326337
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
327338
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const

packages/client/src/promise/service.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { spawn, type ChildProcess } from "node:child_process"
33
import { homedir } from "node:os"
44
import { join } from "node:path"
55
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
6+
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
67
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
78

89
export * from "../service.js"
@@ -32,12 +33,13 @@ async function discoverLocal(options: DiscoverOptions) {
3233

3334
/** Ensure a healthy, compatible local service is running. */
3435
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
35-
const deadline = Date.now() + 120_000
36+
const timing = ensureTiming(options)
37+
const deadline = Date.now() + timing.promiseTimeout
3638
const contenders = new Set<Contender>()
3739
let timeouts: { readonly info: Info; readonly count: number } | undefined
3840
let announced = false
3941
let lastSpawn = 0
40-
let spawnDelay = 5_000
42+
let spawnDelay = timing.spawnDelay
4143

4244
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
4345
if (announced) return
@@ -62,37 +64,37 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
6264

6365
while (true) {
6466
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
65-
const registration = await registered(options.file, true)
67+
const registration = await registered(options.file, true, timing.requestTimeout)
6668
if (registration.timedOut && registration.info !== undefined) {
6769
timeouts = {
6870
info: registration.info,
6971
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
7072
}
7173
if (timeouts.count >= 3) {
7274
announce("missing")
73-
await evict(registration.info, options)
75+
await evict(registration.info, options, timing)
7476
timeouts = undefined
7577
lastSpawn = Date.now() - spawnDelay
7678
}
7779
} else timeouts = undefined
7880

7981
if (registration.service !== undefined) {
80-
spawnDelay = 5_000
82+
spawnDelay = timing.spawnDelay
8183
const service = registration.service
8284
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
8385
if (compatible && service.state === "ready") return service.endpoint
8486
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
8587
if (!compatible) {
8688
announce("version-mismatch", service.version)
87-
await kill(service, options).catch(() => undefined)
89+
await kill(service, options, timing).catch(() => undefined)
8890
lastSpawn = 0
8991
}
9092
} else {
9193
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
9294
const finished = [...contenders].filter(contenderFinished)
9395
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
9496
if (finished.some((item) => item.child.exitCode === 0)) {
95-
spawnDelay = Math.min(spawnDelay * 2, 30_000)
97+
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
9698
}
9799
finished.forEach((item) => contenders.delete(item))
98100
if (failure !== undefined && contenders.size === 0) throw failure
@@ -103,7 +105,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
103105
lastSpawn = Date.now()
104106
}
105107
}
106-
await delay(1_000)
108+
await delay(timing.pollInterval)
107109
}
108110
}
109111

@@ -124,7 +126,7 @@ function contenderFinished(contender: Contender) {
124126
/** Stop the registered local service. */
125127
export async function stop(options: StopOptions = {}) {
126128
const existing = await find(options)
127-
if (existing !== undefined) await kill(existing, options)
129+
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
128130
}
129131

130132
function fallback() {
@@ -161,15 +163,15 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
161163
return (await probeResult(info, allowLegacy)).service
162164
}
163165

164-
async function probeResult(info: Info, allowLegacy = false) {
166+
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
165167
const endpoint = {
166168
url: info.url,
167169
auth:
168170
info.password === undefined
169171
? undefined
170172
: { type: "basic" as const, username: "opencode", password: info.password },
171173
} satisfies Endpoint
172-
const signal = AbortSignal.timeout(2_000)
174+
const signal = AbortSignal.timeout(timeout)
173175
const result = await fetch(new URL("/api/health", info.url), {
174176
headers: headers(endpoint),
175177
signal,
@@ -206,10 +208,10 @@ async function probeResult(info: Info, allowLegacy = false) {
206208
}
207209
}
208210

209-
async function registered(file?: string, allowLegacy = false) {
211+
async function registered(file?: string, allowLegacy = false, timeout?: number) {
210212
const info = await read(file)
211213
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
212-
return { info, ...(await probeResult(info, allowLegacy)) }
214+
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
213215
}
214216

215217
async function find(options: { readonly file?: string }) {
@@ -231,10 +233,10 @@ function stopped(pid: number) {
231233
}
232234
}
233235

234-
async function waitUntilStopped(pid: number) {
235-
for (let attempt = 0; attempt <= 100; attempt++) {
236+
async function waitUntilStopped(pid: number, timing: EnsureTiming) {
237+
for (let attempt = 0; attempt <= timing.stopPollAttempts; attempt++) {
236238
if (stopped(pid)) return true
237-
if (attempt < 100) await delay(50)
239+
if (attempt < timing.stopPollAttempts) await delay(timing.stopPollInterval)
238240
}
239241
return false
240242
}
@@ -243,42 +245,42 @@ function same(left: Info, right: Info) {
243245
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
244246
}
245247

246-
async function evict(info: Info, options: { readonly file?: string }) {
248+
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
247249
const current = await read(options.file)
248250
if (current === undefined || !same(current, info)) return
249251
signal(info.pid, "SIGTERM")
250-
if (await waitUntilStopped(info.pid)) return
252+
if (await waitUntilStopped(info.pid, timing)) return
251253

252254
const latest = await read(options.file)
253255
if (latest === undefined || !same(latest, info)) return
254256
signal(info.pid, "SIGKILL")
255-
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
257+
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
256258
}
257259

258-
async function kill(service: LocalService, options: { readonly file?: string }) {
259-
const requested = await requestStop(service)
260+
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
261+
const requested = await requestStop(service, timing.requestTimeout)
260262
if (requested === "rejected") return
261263
if (requested === "unsupported") {
262264
const current = await find(options)
263265
if (current === undefined || !same(current.info, service.info)) return
264266
signal(service.info.pid, "SIGTERM")
265267
}
266-
if (await waitUntilStopped(service.info.pid)) return
268+
if (await waitUntilStopped(service.info.pid, timing)) return
267269

268270
const latest = await find(options)
269271
if (latest === undefined || !same(latest.info, service.info)) return
270272
signal(service.info.pid, "SIGKILL")
271-
if (!(await waitUntilStopped(service.info.pid)))
273+
if (!(await waitUntilStopped(service.info.pid, timing)))
272274
throw new Error(`Server process ${service.info.pid} is still running`)
273275
}
274276

275-
async function requestStop(service: LocalService) {
277+
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
276278
if (service.info.id === undefined || service.legacy) return "unsupported" as const
277279
const response = await fetch(new URL("/api/service/stop", service.info.url), {
278280
method: "POST",
279281
headers: { ...headers(service.endpoint), "content-type": "application/json" },
280282
body: JSON.stringify({ instanceID: service.info.id }),
281-
signal: AbortSignal.timeout(2_000),
283+
signal: AbortSignal.timeout(timeout),
282284
}).catch(() => undefined)
283285
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
284286
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined

0 commit comments

Comments
 (0)