@@ -361,6 +361,42 @@ function tclAppend(text, className) {
361361 app . tclOutputEl . scrollTop = app . tclOutputEl . scrollHeight ;
362362}
363363
364+ // Browser UX for `exit`/`quit` typed in the Tcl console. The browser
365+ // override (web_serve.cpp tclExitHandler) sets exit_requested_, and
366+ // Main.cc calls exit(EXIT_SUCCESS) once waitForStop() returns — so the
367+ // whole OpenROAD process exits, not just the web session. (Compare
368+ // `web_server -stop`, which only stops serving and arrives here as a
369+ // broadcast `type: shutdown` handled below.)
370+ // window.close() only succeeds when the tab was opened via JS (or via
371+ // certain launcher integrations); when it fails we replace the page
372+ // with a terminal overlay so the user knows OpenROAD exited and they
373+ // can close the tab manually.
374+ function handleServerShutdown ( ) {
375+ // Idempotent: invoked from both the Tcl-eval response (`action: shutdown`)
376+ // and the broadcast push (`type: shutdown`); whichever arrives first wins.
377+ if ( app . _shutdownHandled ) return ;
378+ app . _shutdownHandled = true ;
379+ // Disable auto-reconnect and suppress the "disconnected" banner —
380+ // the disconnect is intentional.
381+ if ( app . websocketManager ) {
382+ app . websocketManager . _shutdown = true ;
383+ app . websocketManager . onPush = ( ) => { } ;
384+ }
385+ const overlay = document . createElement ( 'div' ) ;
386+ overlay . style . cssText =
387+ 'position:fixed;inset:0;z-index:99999;background:#1e1e1e;color:#ddd;' +
388+ 'display:flex;flex-direction:column;align-items:center;justify-content:center;' +
389+ 'font-family:system-ui,sans-serif;font-size:16px;padding:24px;text-align:center;' ;
390+ overlay . innerHTML =
391+ '<div style="font-size:22px;margin-bottom:12px;">OpenROAD exited</div>' +
392+ '<div style="opacity:0.7;">You can close this tab.</div>' ;
393+ document . body . appendChild ( overlay ) ;
394+ // Hold the overlay visible long enough for the user to read it before
395+ // window.close() fires. 400 ms was below the perceptual threshold and
396+ // looked like the tab vanished instantly on `exit`.
397+ setTimeout ( ( ) => { try { window . close ( ) ; } catch ( e ) { /* ignore */ } } , 1500 ) ;
398+ }
399+
364400function createTclConsole ( container ) {
365401 const el = document . createElement ( 'div' ) ;
366402 el . className = 'tcl-console' ;
@@ -396,6 +432,9 @@ function createTclConsole(container) {
396432 tclAppend ( data . result + '\n' ,
397433 data . is_error ? 'tcl-error' : '' ) ;
398434 }
435+ if ( data . action === 'shutdown' ) {
436+ handleServerShutdown ( ) ;
437+ }
399438 } )
400439 . catch ( err => tclAppend ( `Error: ${ err } \n` , 'tcl-error' ) ) ;
401440 }
@@ -730,7 +769,11 @@ app.websocketManager.onPush = (msg) => {
730769 if ( text ) tclAppend ( text + '\n' , '' ) ;
731770 } else if ( msg . type === 'shutdown' ) {
732771 // Server is stopping intentionally (web_server -stop).
733- // Disable auto-reconnect and show a clear message.
772+ // Disable auto-reconnect and show a clear message. Note that
773+ // when the user typed `exit`/`quit` in the browser, the eval
774+ // response's `action: shutdown` already ran handleServerShutdown
775+ // (which set _shutdown and replaced onPush with a no-op), so
776+ // this branch only runs in the external-stop case.
734777 app . websocketManager . _shutdown = true ;
735778 statusDiv . innerHTML = '<div class="disconnected-banner">Server stopped</div>' ;
736779 statusDiv . style . display = 'block' ;
0 commit comments