ReviewOS

also looking at this

stacks/rpx

fix(supervisor): stop reporting a shutdown as a readiness timeout

#2273
Merged glennmichael123 wants to merge fix/2272-shutdown-readiness-timeout into main
2 files +47 -1
packages/rpx/src/site-supervisor.tsmodified+8-0
Changes to packages/rpx/src/site-supervisor.ts
@@ -323,6 +323,14 @@ export class SiteSupervisor {
323323 await delay(this.pollIntervalMs)
324324 }
325325
326 // The loop also exits when `stopAll()` flips `stopped`, which is a
327 // deliberate shutdown rather than a timeout. Falling through to `fail()`
328 // there logged "did not become ready within 120s" for every site that was
329 // still booting, on every shutdown — a failure that never happened, and a
330 // second teardown on top of the one `stop()` is already running.
331 if (this.stopped)
332 return
333
326334 if (state.status === 'starting')
327335 this.fail(state, `did not become ready within ${Math.round(this.startupTimeoutMs / 1000)}s`)
328336 }
packages/rpx/test/site-supervisor.test.tsmodified+39-1
Changes to packages/rpx/test/site-supervisor.test.ts
@@ -1,10 +1,11 @@
11import type { ResolvedSite, SiteResolver } from '../src/site-resolver'
22import type { SiteLauncher, SiteProcessHandle } from '../src/site-supervisor'
33import type { RegistryEntry } from '../src/registry'
4import { afterEach, describe, expect, it } from 'bun:test'
4import { afterEach, describe, expect, it, spyOn } from 'bun:test'
55import * as fsp from 'node:fs/promises'
66import * as os from 'node:os'
77import * as path from 'node:path'
8import { log } from '../src/logger'
89import { SiteSupervisor } from '../src/site-supervisor'
910
1011/** A controllable fake process: tests resolve `exit` to simulate it dying. */
@@ -277,6 +278,43 @@ describe('SiteSupervisor', () => {
277278 await sup.stopAll()
278279 expect(proc.signals).toContain('SIGTERM')
279280 })
281
282 it('does not report a readiness timeout for a site still booting at stopAll', async () => {
283 // The readiness loop exits on `stopped` as well as on the deadline, and used
284 // to fall through to the timeout failure either way — so every shutdown
285 // logged "did not become ready within 120s" for each still-booting site, a
286 // failure that never happened. Worse for tests: the warn lands a poll after
287 // the file that caused it has ended, so it is attributed to whatever file
288 // runs next.
289 await mkdir()
290 const proc = fakeProc(321)
291 const sup = new SiteSupervisor({
292 resolver: resolverFor(stacksSite()),
293 rpxDir,
294 registryDir: rpxDir,
295 launcher: () => proc.handle,
296 pickPort: async preferred => preferred,
297 probePort: async () => false, // never becomes ready, so it is mid-boot at stopAll
298 pollIntervalMs: 5,
299 })
300 supervisors.push(sup)
301
302 const warnSpy = spyOn(log, 'warn').mockImplementation(() => {})
303 try {
304 await sup.onRequest('myapp.localhost')
305 await sup.stopAll()
306 // Let the readiness loop wake once more past the stop — the stray warn
307 // arrived a poll interval later, which is exactly why it used to surface
308 // under an unrelated test file's name.
309 await new Promise(resolve => setTimeout(resolve, 60))
310
311 const warnings = warnSpy.mock.calls.map(call => String(call[0]))
312 expect(warnings.filter(w => w.includes('did not become ready'))).toEqual([])
313 }
314 finally {
315 warnSpy.mockRestore()
316 }
317 })
280318})
281319
282320async function waitFor(pred: () => boolean | Promise<boolean>, timeoutMs = 1000): Promise<void> {