Repository navigation
fix(cli): stopping the preview server no longer leaves a Chrome running - #4183
Conversation
Cancel in-flight renders and thumbnail launches before draining the browser pool on shutdown, instead of only closing whatever browser was already registered. A render whose Chrome died from the shutdown signal itself was being misclassified as a transient failure and retried with a fresh, untracked browser that outlived the process. Reject new render and thumbnail requests once shutdown has begun, and await an in-flight thumbnail launch before closing it.
774665c to
3c90864
Compare
shutdown() awaited renders before closing browsers, so a render slower than preview.ts 3s exit watchdog left Chrome running when it fired. Close the thumbnail browser and drain the pool concurrently with, not after, the render wait, and bound the wait under that watchdog. A second Ctrl+C/SIGTERM during shutdown removed the one-shot signal handlers, so it hit the OS default and killed the process before cleanup ran. Use persistent handlers guarded by the existing shuttingDown flag instead. Also: getThumbnailBrowser could still hand a live lease to a request that lands after shuttingDown flips true; trim a comment over budget; replace a fixed-sleep test race with a drain-signal barrier.
drain() resets its drainPromise to null once it settles, so acquire() only waits for an in-flight drain -- a render still unwinding after shutdown could relaunch Chrome the instant that drain resolved (probeStage.ts:449-465 has exactly this gap between an abort check and a later acquireBrowser call). No non-shutdown caller reuses the pool after draining it (checked every drainBrowserPool()/drain() call site), but added a separate terminal close() rather than changing drain()'s own semantics, so a future reuse caller stays safe by default. BrowserLeasePool.close() sets a permanent closed flag before draining, and acquire() checks it both before and after its one await point, so a request already mid-await when close() lands still sees it once that await resolves. studioServer's shutdown() now calls the new closeBrowserPool() instead of drainBrowserPool(). Also bounds drain()'s own wait: a close() that hangs past 1s now gets escalated to a force-close instead of blocking the caller indefinitely, keeping total shutdown time under preview.ts's 3s exit watchdog alongside the existing render-wait bound.
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at 60215c59. No earlier GitHub reviews. The one open item from the Slack thread (the 1s drain bound reaching producer shutdown) is covered below.
Traced and sound
shutdown()ordering. The flag is set before any await, aborts go out first, and browser close runs concurrently with the bounded 2s render wait instead of after it. So a render that never settles can't hold Chrome open past preview's 3s watchdog.RENDER_SHUTDOWN_WAIT_MS(2s) sits above the pool's 1s force bound and under the watchdog, so the budget adds up.- The pool close race.
acquire()checksclosed, awaits any drain, re-checksclosed, then callsreserveEntrysynchronously in the same tick. Aclose()can't land between the re-check and the reserve, so any entry it misses was already indrain()'s snapshot, andrequestCloseawaits itslaunchPromise. Nothing launches untracked. - The thumbnail path.
- The shutdown check comes before the cached-lease return and again inside the init IIFE, which covers a request queued behind an in-flight init.
closeThumbnailBrowsernow awaits an init that was mid-launch before reading the lease.- A launch that got past the check either hits the closed pool (throws, returns
null) or sets_thumbnailBrowserLease, which the awaiting close then releases.
oninstead ofonceinpreview.tsis correct:shuttingDownmakes a repeat signal a no-op instead of falling through to the default handler.- The un-export of
closeThumbnailBrowser. Its only external user was the inline cleanup this PR replaces. bun.lock. The only change is workspace versions 0.8.59 → 0.8.62, catching up to thepackage.jsonversions already on main.
On the MINOR (1s force bound reaching producer/server.ts). I checked its reach. drain() is called only from close() and drainBrowserPool(), and the only non-test caller of the latter is producer's SIGTERM shutdown(). So the bound only ever applies to an exiting process. Force-closing a Chrome after 1s in a pod that's terminating anyway loses nothing, so I agree it's not blocking. It would matter only if someone later calls drainBrowserPool() to recycle the pool mid-process. A one-line note on drain() that it's exit-only would guard against that.
Ran locally (Linux, full build): studioServer.test.ts 20/20, browserLeasePool.test.ts 8/8.
— Rames
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approving at 949ccde8. The only change since my approval at 60215c59 is one commit that shortens the closeBrowserPool JSDoc in browserManager.ts (+1/-5, comment only, no code change). My earlier review stands.
— Rames
What changes
Stopping the preview server could leave a Chrome running. If a render was in progress when the stop signal arrived, its Chrome was killed by that same signal. The render then read that as a transient browser failure and its bounded retry launched a fresh Chrome that nothing tracked, so it outlived the server process.
createStudioServernow returns ashutdown(). It marks the server as shutting down first, cancels every in-flight render, kills tracked processes, waits for the renders to finish, closes the thumbnail browser (waiting for one that is mid-launch), and drains the browser pool.packages/engine/src/services/captureFailure.ts:158, rethrown atrenderOrchestrator.ts:1266).previewcallsshutdown()in its signal handler instead of the previous inline cleanup.closeThumbnailBrowseris no longer exported (nothing else used it).Verification
studioServer.test.ts: 17 pass on a remote Linux runner; typecheck and lint clean; comment check clean.startRender.preview.src/server/portUtils.test.tsfails one PID-provenance test on the remote runner; that file is not touched here and I did not compare it against main.shutdown()still waited on every render before closing browsers, so a render slower than the 3s exit watchdog left Chrome running when it fired; a second Ctrl+C/SIGTERM also bypassed cleanup by dropping the one-shot signal handlers. Both fixed, plus a lease-reuse gap during the shutdown race window. Two new tests (a render whosedonenever settles still gets the browser closed and the pool drained within a bound; a thumbnail request landing in that race window is refused instead of reusing a live lease) bringstudioServer.test.tsto 19/19; the fullpackages/clisuite (3442 tests), typecheck, and lint stay green on a remote Linux runner.Review
An independent review found no blocking issues. Its three should-fix items are closed: the render test settled synchronously (now asynchronous, so it fails without the wait), the thumbnail test passed without Chrome (now asserts the launch is never attempted), and a thumbnail launch could start after shutdown had begun (fixed at the launch, with a test for the window).