Skip to content

Commit 67aca5a

Browse files
authored
Merge pull request #10235 from The-OpenROAD-Project-staging/feature-Web-GUI
Feature Web GUI
2 parents 7d8f560 + 7ca7e75 commit 67aca5a

7 files changed

Lines changed: 141 additions & 41 deletions

File tree

src/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,6 @@ target_link_libraries(openroad
345345
cut
346346
grt
347347
tap
348-
gui
349348
drt
350349
dst
351350
psm
@@ -358,6 +357,7 @@ target_link_libraries(openroad
358357
par
359358
est
360359
web
360+
gui
361361
absl::synchronization
362362
${ABC_LIBRARY}
363363
${TCL_LIBRARY}

src/web/include/web/web.h

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
#pragma once
55

6+
#include <spdlog/common.h>
7+
68
#include <condition_variable>
79
#include <cstdint>
810
#include <functional>
@@ -108,29 +110,37 @@ class WebServer
108110
double dbu_per_pixel,
109111
const std::string& vis_json);
110112

111-
private:
112-
// Tears down the I/O threads and cleans up hooks. Called by the
113-
// destructor; safe to call multiple times.
113+
// Tears down the I/O threads and cleans up hooks. Safe to call multiple
114+
// times and from any thread; after it returns, isRunning() is false and
115+
// serve() may be called again to restart the server.
114116
void stop();
115117

118+
private:
119+
// Stops ioc_, joins every worker thread except the current one, and
120+
// clears threads_. Detaches the current thread if it happens to be a
121+
// worker (would otherwise raise EDEADLK on self-join).
122+
void stopAndJoinIoThreads();
123+
116124
odb::dbDatabase* db_ = nullptr;
117125
sta::dbSta* sta_ = nullptr;
118126
utl::Logger* logger_ = nullptr;
119127
Tcl_Interp* interp_ = nullptr;
120128
int num_threads_ = 0;
121129
std::shared_ptr<TileGenerator> generator_;
122130
std::unique_ptr<WebViewerHook> viewer_hook_;
123-
std::shared_ptr<spdlog::sinks::sink> log_sink_;
124131

125132
// Background I/O context and worker threads (non-null while running).
126133
std::unique_ptr<boost::asio::io_context> ioc_;
127134
std::vector<std::thread> threads_;
128135

129-
// Type-erased callback that closes the Listener's acceptor before the
130-
// io_context is destroyed — prevents a crash where the acceptor's
131-
// destructor references the io_context that's mid-destruction.
136+
// Closes the Listener's acceptor before the io_context is destroyed,
137+
// avoiding a crash where the acceptor references a half-destroyed
138+
// io_context.
132139
std::function<void()> shutdown_listener_;
133140

141+
// Held so stop() can remove it from the Logger before viewer_hook_ is
142+
// destroyed — the sink stores a raw pointer into the hook.
143+
spdlog::sink_ptr log_sink_;
134144
// Blocking support: waitForStop() sleeps on stop_cv_ until
135145
// requestStop() sets stop_requested_.
136146
std::mutex stop_mutex_;

src/web/src/main.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
364400
function 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';

src/web/src/request_handler.cpp

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
#include "request_dispatcher.h"
3838
#include "tile_generator.h"
3939
#include "timing_report.h"
40+
#include "utl/Logger.h"
4041

4142
namespace web {
4243

@@ -1418,12 +1419,27 @@ WebSocketResponse TclHandler::handleTclEval(const WebSocketRequest& req)
14181419
resp.id = req.id;
14191420
resp.type = WebSocketResponse::kJson;
14201421
try {
1421-
auto result = tcl_eval_->eval(extract_string(req.raw_json, "cmd"));
1422+
const std::string cmd = extract_string(req.raw_json, "cmd");
1423+
auto result = tcl_eval_->eval(cmd);
1424+
// tclExitHandler (web_serve.cpp) sets this sentinel as the Tcl
1425+
// result whenever `exit`/`quit` is evaluated through the override —
1426+
// whether typed bare in the browser or buried in `eval`/`source`.
1427+
// Convert it to a clean shutdown signal for the browser; the actual
1428+
// teardown is already requested by tclExitHandler via requestStop().
1429+
const bool is_exit = (result.result == kExitResultMsg);
14221430
JsonBuilder builder;
14231431
builder.beginObject();
14241432
builder.field("output", result.output);
1425-
builder.field("result", result.result);
1426-
builder.field("is_error", result.is_error);
1433+
if (is_exit) {
1434+
tcl_eval_->logger->info(
1435+
utl::WEB, 40, "Exit requested from web GUI; shutting down.");
1436+
builder.field("result", "Exiting OpenROAD.");
1437+
builder.field("is_error", false);
1438+
builder.field("action", "shutdown");
1439+
} else {
1440+
builder.field("result", result.result);
1441+
builder.field("is_error", result.is_error);
1442+
}
14271443
builder.endObject();
14281444
const std::string& json = builder.str();
14291445
resp.payload.assign(json.begin(), json.end());

src/web/src/request_handler.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class RequestDispatcher;
2626
class TimingReport;
2727
class ClockTreeReport;
2828

29+
// Sentinel string set as the Tcl result by WebServer::tclExitHandler
30+
// when the browser-side Tcl `exit`/`quit` is invoked. TclHandler
31+
// detects this in handleTclEval and converts the response to a clean
32+
// shutdown signal for the browser.
33+
inline constexpr const char* kExitResultMsg = "_WEB_EXITING_";
34+
2935
// Thread-safe Tcl command evaluation with output capture.
3036
struct TclEvaluator
3137
{

src/web/src/web.cpp

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,31 @@ WebServer::WebServer(odb::dbDatabase* db,
813813
{
814814
}
815815

816+
// Defined here (not in web_serve.cpp) so the destructor's TU does not
817+
// pull in web_serve.cpp's gui::Gui::get() references — keeps WebServer
818+
// usable from tests that don't link the full gui library.
819+
void WebServer::stopAndJoinIoThreads()
820+
{
821+
if (ioc_) {
822+
ioc_->stop();
823+
}
824+
const auto self_id = std::this_thread::get_id();
825+
for (auto& t : threads_) {
826+
if (!t.joinable()) {
827+
continue;
828+
}
829+
if (t.get_id() == self_id) {
830+
// Self-join would raise EDEADLK. ioc_->stop() above unblocks the
831+
// worker so detaching is safe — the thread runs to completion on
832+
// its own.
833+
t.detach();
834+
} else {
835+
t.join();
836+
}
837+
}
838+
threads_.clear();
839+
}
840+
816841
WebServer::~WebServer()
817842
{
818843
// Wake any thread blocked in waitForStop() so it can return before
@@ -825,24 +850,12 @@ WebServer::~WebServer()
825850

826851
// The destructor fires during Tcl_Exit → atexit → ~OpenRoad chain.
827852
// By this point the Tcl interpreter is partially torn down and static
828-
// objects may be destroyed. Calling stop() (which joins 32 threads
829-
// and tears down boost::asio's reactor) triggers SIGSEGV because the
830-
// reactor's internal state references destroyed statics.
831-
//
832-
// Since the destructor only runs at process exit, the OS reclaims all
833-
// memory and closes all sockets. We just need to stop the threads so
834-
// the process can actually exit:
835-
if (ioc_) {
836-
ioc_->stop();
837-
}
838-
for (auto& t : threads_) {
839-
if (t.joinable()) {
840-
t.join();
841-
}
842-
}
843-
threads_.clear();
844-
// Close the Listener's acceptor and release the shared_ptr to it
845-
// before the io_context goes away.
853+
// objects may be destroyed. We avoid the full stop() path (which
854+
// tears down boost::asio's reactor and would crash on residual async
855+
// handlers referencing destroyed statics) — the OS reclaims memory
856+
// and sockets at process exit, so we only need to release the worker
857+
// threads so the process can actually exit.
858+
stopAndJoinIoThreads();
846859
if (shutdown_listener_) {
847860
shutdown_listener_();
848861
shutdown_listener_ = {};

src/web/src/web_serve.cpp

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ using Tcp = net::ip::tcp;
4848
// Tcl command name used to stash the original `exit` while our override
4949
// is installed. Mirrors gui::TclCmdInputWidget's kCommandRenamePrefix.
5050
static constexpr const char* kRenamedExitCmd = "::tcl::openroad::web_orig_exit";
51-
static constexpr const char* kExitResultMsg = "_WEB_EXITING_";
5251

5352
// Logger sink that accumulates lines and sends them as a batch to
5453
// connected browser clients. Flushing is explicit (via drainToClients)
@@ -141,6 +140,8 @@ void WebServer::serve(int port)
141140
// ~WebServer's self-join → std::terminate). Same pattern as
142141
// gui::TclCmdInputWidget. The handler signals waitForStop() and
143142
// sets exit_requested_; the main thread does the real exit.
143+
// TclHandler::handleTclEval detects kExitResultMsg in the Tcl
144+
// result and sends `action: "shutdown"` to the browser.
144145
exit_requested_ = false;
145146
{
146147
const std::string rename_orig
@@ -216,7 +217,15 @@ void WebServer::serve(int port)
216217
#elif defined(_WIN32)
217218
std::string open_cmd = "start " + url + " > nul 2>&1";
218219
#else
219-
std::string open_cmd = "xdg-open " + url + " > /dev/null 2>&1 &";
220+
// `setsid -f` forks the launcher into a new session, severing the
221+
// SIGHUP cascade from openroad's controlling pty. Without this,
222+
// running openroad from inside an emacs shell-mode buffer kills
223+
// the browser tab as soon as openroad exits, because emacs holds
224+
// the pty master and SIGHUPs every process in the session. Also
225+
// redirect stdin from /dev/null so xdg-open never blocks on input
226+
// inherited from the pty.
227+
std::string open_cmd
228+
= "setsid -f xdg-open " + url + " < /dev/null > /dev/null 2>&1";
220229
#endif
221230
int ret = std::system(open_cmd.c_str());
222231
(void) ret;
@@ -304,18 +313,21 @@ void WebServer::stop()
304313
shutdown_listener_();
305314
shutdown_listener_ = {};
306315
}
307-
if (ioc_) {
308-
ioc_->stop();
309-
}
310-
for (auto& t : threads_) {
311-
if (t.joinable()) {
312-
t.join();
313-
}
314-
}
315-
threads_.clear();
316+
stopAndJoinIoThreads();
317+
// Release without destroying — destroying io_context can crash on
318+
// residual async handlers. Leak is bounded (at most one io_context
319+
// per serve/stop cycle).
316320
(void) ioc_.release(); // NOLINT(bugprone-unused-return-value)
317321
generator_.reset();
322+
// Remove the log sink before destroying viewer_hook_ — the sink
323+
// stores a raw pointer into it and the CLI thread may emit a log
324+
// line at any moment.
325+
if (log_sink_) {
326+
logger_->removeSink(log_sink_);
327+
log_sink_.reset();
328+
}
318329
viewer_hook_.reset();
330+
logger_->info(utl::WEB, 41, "Web session closed.");
319331
}
320332

321333
} // namespace web

0 commit comments

Comments
 (0)