@@ -28,6 +28,7 @@ import {
2828 killActiveServers ,
2929 type FindPortResult ,
3030} from "../server/portUtils.js" ;
31+ import { killOrphanedProcesses , killProcessTree } from "../utils/orphanCleanup.js" ;
3132
3233export default defineCommand ( {
3334 meta : { name : "preview" , description : "Start the studio for previewing compositions" } ,
@@ -96,6 +97,14 @@ export default defineCommand({
9697 return ;
9798 }
9899
100+ // Kill orphaned chrome-headless-shell processes from previous crashed sessions.
101+ const orphansKilled = killOrphanedProcesses ( ) ;
102+ if ( orphansKilled > 0 ) {
103+ console . log (
104+ ` ${ c . dim ( `Cleaned up ${ orphansKilled } orphaned process${ orphansKilled === 1 ? "" : "es" } from a previous session.` ) } ` ,
105+ ) ;
106+ }
107+
99108 const rawArg = args . dir ;
100109 const dir = resolve ( rawArg ?? "." ) ;
101110
@@ -249,8 +258,18 @@ async function runDevMode(
249258 } ) ;
250259 }
251260
252- // Wait for child to exit. Ctrl+C sends SIGINT to the entire process group,
253- // so the child (Vite) receives it directly — no need to intercept or forward.
261+ // Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
262+ // SIGINT to the foreground process group (covers the common case), but
263+ // `kill <pid>` only targets this process — the child tree (Vite + Chrome)
264+ // would survive without explicit cleanup.
265+ // On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
266+ // propagates via the console process group instead.
267+ const shutdown = ( ) => {
268+ if ( child . pid ) killProcessTree ( child . pid ) ;
269+ } ;
270+ process . once ( "SIGINT" , shutdown ) ;
271+ process . once ( "SIGTERM" , shutdown ) ;
272+
254273 return new Promise < void > ( ( resolve ) => {
255274 child . on ( "close" , ( ) => resolve ( ) ) ;
256275 } ) ;
@@ -349,6 +368,13 @@ async function runLocalStudioMode(
349368 } ) ;
350369 }
351370
371+ // Same tree-kill handler as dev mode. No-op on Windows (see comment above).
372+ const shutdown = ( ) => {
373+ if ( child . pid ) killProcessTree ( child . pid ) ;
374+ } ;
375+ process . once ( "SIGINT" , shutdown ) ;
376+ process . once ( "SIGTERM" , shutdown ) ;
377+
352378 return new Promise < void > ( ( resolve ) => {
353379 child . on ( "close" , ( ) => resolve ( ) ) ;
354380 } ) ;
@@ -477,21 +503,42 @@ async function runEmbeddedMode(
477503 shuttingDown = true ;
478504 process . off ( "SIGINT" , shutdown ) ;
479505 process . off ( "SIGTERM" , shutdown ) ;
480- // Close the readline interface so a second Ctrl+C during the grace
481- // period below doesn't re-emit SIGINT and trigger Node's default
482- // exit-130 behaviour, contradicting our intent to exit cleanly.
483506 rl ?. close ( ) ;
484- // `server.close()` can take a second or two to drain keep-alive
485- // connections; surface progress so the terminal doesn't look frozen.
486507 console . log ( ) ;
487508 console . log ( ` ${ c . dim ( "Shutting down studio..." ) } ` ) ;
488- result . server . close ( ( ) => resolveRun ( ) ) ;
489- // If close() hangs on an open connection, force exit after a short
490- // grace period. Exit 0 because user-initiated Ctrl+C isn't an error
491- // — a non-zero code makes pnpm / npm print ELIFECYCLE.
492- setTimeout ( ( ) => process . exit ( 0 ) , 2000 ) . unref ( ) ;
509+
510+ // Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to
511+ // browser.close()), force exit. Armed before awaiting cleanup so it
512+ // can't be blocked by a stuck drainBrowserPool().
513+ setTimeout ( ( ) => process . exit ( 0 ) , 3000 ) . unref ( ) ;
514+
515+ // Kill ffmpeg first (sync, fast), then drain browsers (async, slower).
516+ const cleanup = async ( ) => {
517+ const { closeThumbnailBrowser } = await import ( "../server/studioServer.js" ) ;
518+ const { drainBrowserPool, killTrackedProcesses } = await import ( "@hyperframes/engine" ) ;
519+ killTrackedProcesses ( ) ;
520+ await closeThumbnailBrowser ( ) . catch ( ( ) => { } ) ;
521+ await drainBrowserPool ( ) . catch ( ( ) => { } ) ;
522+ } ;
523+
524+ cleanup ( )
525+ . catch ( ( ) => { } )
526+ . finally ( ( ) => {
527+ result . server . close ( ( ) => resolveRun ( ) ) ;
528+ } ) ;
493529 } ;
494530 process . once ( "SIGINT" , shutdown ) ;
495531 process . once ( "SIGTERM" , shutdown ) ;
532+
533+ // Last-resort cleanup for crash paths (unhandled exceptions/rejections)
534+ // that bypass the signal handlers. Eagerly resolve the sync killer so
535+ // the 'exit' handler (which is synchronous) can call it directly.
536+ import ( "@hyperframes/engine" )
537+ . then ( ( { killTrackedProcesses } ) => {
538+ process . once ( "exit" , ( ) => {
539+ if ( ! shuttingDown ) killTrackedProcesses ( ) ;
540+ } ) ;
541+ } )
542+ . catch ( ( ) => { } ) ;
496543 } ) ;
497544}
0 commit comments