Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
736d096
feat: apply clang-format and add web GUI test files
jorge-ferreira-pii Apr 25, 2026
8f76516
fix: apply clang-tidy suggestions
jorge-ferreira-pii Apr 25, 2026
e33fd5a
fix: remove stdio.h in favor of cstdio to resolve clang-tidy warnings
jorge-ferreira-pii Apr 25, 2026
6b0d6b4
Merge master
jorge-ferreira-pii Apr 26, 2026
e3efb15
Update Web GUI and odb tests
jorge-ferreira-pii Apr 26, 2026
2be6d04
Merge branch 'master' into feature-Web-GUI
jorge-ferreira-pii Apr 27, 2026
a3f4135
Fix clang-format violations in web_serve.cpp
jorge-ferreira-pii Apr 27, 2026
9d24c6e
Fix formatting and missing brace in web_serve.cpp
jorge-ferreira-pii Apr 27, 2026
45dd46b
Fix duplicate declaration of log_sink_ in web.h
jorge-ferreira-pii Apr 27, 2026
b06b49a
Merge remote-tracking branch 'origin/master' into feature-Web-GUI
jorge-ferreira-pii Apr 27, 2026
d448ede
Merge remote-tracking branch 'origin/master' into feature-Web-GUI
jorge-ferreira-pii Apr 29, 2026
e731680
fix(web): fix server shutdown from browser exit.
jorge-ferreira-pii Apr 29, 2026
cfd4782
fix(web): link web library against gui
maliberty May 1, 2026
867d60d
Merge remote-tracking branch 'origin/master' into feature-Web-GUI
maliberty May 1, 2026
9358987
refactor(web): unify browser-exit shutdown via Tcl-result sentinel
maliberty May 1, 2026
d1c29e3
fix(web): detach browser launch with setsid; correct exit UX text
maliberty May 2, 2026
8257d3d
chore(web): untrack generated messages.txt
maliberty May 2, 2026
7ca7e75
fix(web): unbreak test linkage by decoupling web from gui.a
maliberty May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions src/web/include/web/web.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#pragma once

#include <spdlog/common.h>

#include <condition_variable>
#include <cstdint>
#include <functional>
Expand Down Expand Up @@ -103,11 +105,17 @@ class WebServer
double dbu_per_pixel,
const std::string& vis_json);

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

private:
// Stops ioc_, joins every worker thread except the current one, and
// clears threads_. Detaches the current thread if it happens to be a
// worker (would otherwise raise EDEADLK on self-join).
void stopAndJoinIoThreads();

odb::dbDatabase* db_ = nullptr;
sta::dbSta* sta_ = nullptr;
utl::Logger* logger_ = nullptr;
Expand All @@ -121,11 +129,14 @@ class WebServer
std::unique_ptr<boost::asio::io_context> ioc_;
std::vector<std::thread> threads_;

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

// Held so stop() can remove it from the Logger before viewer_hook_ is
// destroyed — the sink stores a raw pointer into the hook.
spdlog::sink_ptr log_sink_;
Comment thread
maliberty marked this conversation as resolved.
// Blocking support: waitForStop() sleeps on stop_cv_ until
// requestStop() sets stop_requested_.
std::mutex stop_mutex_;
Expand Down
28 changes: 28 additions & 0 deletions src/web/messages.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
GUI 0019 web.tcl:119 Display option must have 2 elements {control name} {value}. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L119
WEB 0001 web_serve.cpp:199 Server started on {}. INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web_serve.cpp#L199
WEB 0002 web_serve.cpp:203 Server error : {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web_serve.cpp#L203
WEB 0004 web_serve.cpp:167 Serving static files from {} INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web_serve.cpp#L167
WEB 0006 web_serve.cpp:111 Web server is already running. WARN https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web_serve.cpp#L111
WEB 0010 web.cpp:1121 Failed to open acceptor: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1121
WEB 0011 web.cpp:1126 Failed to set reuse_address option: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1126
WEB 0017 web.cpp:1132 Failed to bind to endpoint: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1132
WEB 0018 web.cpp:1138 Failed to listen: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1138
WEB 0019 web.tcl:18 -dir is required: pass the path to the web assets directory. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L18
WEB 0020 tile_generator.cpp:1705 No design loaded. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/tile_generator.cpp#L1705
WEB 0021 tile_generator.cpp:1737 Invalid image dimensions. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/tile_generator.cpp#L1737
WEB 0022 tile_generator.cpp:1744 Image dimensions {}x{} exceed max {}; clamping. WARN https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/tile_generator.cpp#L1744
WEB 0023 tile_generator.cpp:1861 PNG encode error: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/tile_generator.cpp#L1861
WEB 0024 tile_generator.cpp:1866 Saved {}x{} image to {} INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/tile_generator.cpp#L1866
WEB 0025 web.tcl:47 Resolution too high for design, defaulting to ${res_per_pixel}um per pixel WARN https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L47
WEB 0026 web.tcl:55 Area must contain 4 elements. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L55
WEB 0027 web.tcl:62 Cannot set -width if -resolution has already been specified. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L62
WEB 0028 web.tcl:96 Display option must have 2 elements {control} {value}. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L96
WEB 0029 web.tcl:67 Specified -width cannot be zero. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.tcl#L67
WEB 0030 web.cpp:1288 No STA data — timing sections will be empty. WARN https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1288
WEB 0031 web.cpp:1262 Cannot open file: {} ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1262
WEB 0032 web.cpp:1470 Saved timing report to {} INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1470
WEB 0033 web.cpp:1340 Cached {} tiles at zoom {}. INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1340
WEB 0034 web.cpp:1364 Rendered {} setup + {} hold path overlays. INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1364
WEB 0035 web.cpp:1256 No design loaded. ERROR https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web.cpp#L1256
WEB 0040 request_handler.cpp:1362 Exit requested from web GUI; shutting down. INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/request_handler.cpp#L1362
WEB 0041 web_serve.cpp:257 Web session closed. INFO https://github.com/The-OpenROAD-Project/OpenROAD/tree/master/workspace/teste/OpenROAD-flow-scripts/tools/OpenROAD/src/web/src/web_serve.cpp#L257
26 changes: 26 additions & 0 deletions src/web/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,29 @@ function tclAppend(text, className) {
app.tclOutputEl.scrollTop = app.tclOutputEl.scrollHeight;
}

// Browser UX for `exit`/`quit` typed in the Tcl console. The OpenROAD
// process keeps running in the terminal — only the web session ends.
// window.close() only succeeds when the tab was opened via JS (or via
// certain launcher integrations); when it fails we replace the page with
// a terminal overlay so the user knows the web_server stopped and they
// can close the tab manually.
function handleServerShutdown() {
// Suppress the normal "disconnected" banner — the disconnect is intentional.
if (app.websocketManager) {
app.websocketManager.onPush = () => {};
}
Comment thread
maliberty marked this conversation as resolved.
const overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;background:#1e1e1e;color:#ddd;' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;' +
'font-family:system-ui,sans-serif;font-size:16px;padding:24px;text-align:center;';
overlay.innerHTML =
'<div style="font-size:22px;margin-bottom:12px;">Web session closed</div>' +
'<div style="opacity:0.7;">OpenROAD is still running in the terminal. You can close this tab.</div>';
document.body.appendChild(overlay);
setTimeout(() => { try { window.close(); } catch (e) { /* ignore */ } }, 400);
}

function createTclConsole(container) {
const el = document.createElement('div');
el.className = 'tcl-console';
Expand Down Expand Up @@ -395,6 +418,9 @@ function createTclConsole(container) {
tclAppend(data.result + '\n',
data.is_error ? 'tcl-error' : '');
}
if (data.action === 'shutdown') {
handleServerShutdown();
}
})
.catch(err => tclAppend(`Error: ${err}\n`, 'tcl-error'));
}
Expand Down
49 changes: 49 additions & 0 deletions src/web/src/request_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <algorithm>
#include <any>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <exception>
Expand All @@ -18,6 +19,7 @@
#include <stdexcept>
#include <string>
#include <system_error>
#include <thread>
#include <utility>
#include <variant>
#include <vector>
Expand All @@ -35,6 +37,7 @@
#include "odb/geom.h"
#include "tile_generator.h"
#include "timing_report.h"
#include "utl/Logger.h"

namespace web {

Expand Down Expand Up @@ -1334,12 +1337,58 @@ TclHandler::TclHandler(std::shared_ptr<TclEvaluator> tcl_eval)
{
}

// Returns true if cmd is `exit` or `quit` (with optional surrounding
// whitespace). Evaluating these on a boost::asio worker thread would
// trigger ~WebServer from inside that worker and cause a self-join
// deadlock; they need special handling.
static bool isTerminalCommand(const std::string& cmd)
{
const auto first = cmd.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
return false;
}
const auto last = cmd.find_last_not_of(" \t\r\n");
const std::string trimmed = cmd.substr(first, last - first + 1);
return trimmed == "exit" || trimmed == "quit";
}
Comment thread
maliberty marked this conversation as resolved.
Outdated

WebSocketResponse TclHandler::handleTclEval(const WebSocketRequest& req)
{
WebSocketResponse resp;
resp.id = req.id;
resp.type = 0;
try {
if (isTerminalCommand(req.tcl_cmd)) {
tcl_eval_->logger->info(
utl::WEB, 40, "Exit requested from web GUI; shutting down.");
// Respond immediately with an `action: shutdown` marker so the
// browser can close its tab, then tear down the web server on a
// detached thread after a short delay. The delay lets this
// response flush before the WebSocket is closed, and the detached
// thread avoids a self-join inside stop() (the current worker is
// in threads_). The OpenROAD Tcl prompt keeps running — only the
// web session ends.
JsonBuilder builder;
builder.beginObject();
builder.field("output", "");
builder.field(
"result",
"Web session closed. OpenROAD is still running in the terminal.");
builder.field("is_error", false);
builder.field("action", "shutdown");
builder.endObject();
const std::string& json = builder.str();
resp.payload.assign(json.begin(), json.end());

if (tcl_eval_->close_session) {
auto close_session = tcl_eval_->close_session;
std::thread([close_session] {
std::this_thread::sleep_for(std::chrono::milliseconds(300));
close_session();
}).detach();
}
Comment thread
maliberty marked this conversation as resolved.
Outdated
return resp;
}
auto result = tcl_eval_->eval(req.tcl_cmd);
JsonBuilder builder;
builder.beginObject();
Expand Down
5 changes: 5 additions & 0 deletions src/web/src/request_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#pragma once

#include <cstdint>
#include <functional>
#include <limits>
#include <map>
#include <memory>
Expand Down Expand Up @@ -32,6 +33,10 @@ struct TclEvaluator
Tcl_Interp* interp;
utl::Logger* logger;
std::mutex mutex;
// Callback wired up by WebServer::serve() that stops the web server
// without killing the OpenROAD process. Invoked by the Tcl handler
// when the browser sends `exit`/`quit` (see handleTclEval).
std::function<void()> close_session;

struct Result
{
Expand Down
24 changes: 6 additions & 18 deletions src/web/src/web.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1192,24 +1192,12 @@ WebServer::~WebServer()

// The destructor fires during Tcl_Exit → atexit → ~OpenRoad chain.
// By this point the Tcl interpreter is partially torn down and static
// objects may be destroyed. Calling stop() (which joins 32 threads
// and tears down boost::asio's reactor) triggers SIGSEGV because the
// reactor's internal state references destroyed statics.
//
// Since the destructor only runs at process exit, the OS reclaims all
// memory and closes all sockets. We just need to stop the threads so
// the process can actually exit:
if (ioc_) {
ioc_->stop();
}
for (auto& t : threads_) {
if (t.joinable()) {
t.join();
}
}
threads_.clear();
// Close the Listener's acceptor and release the shared_ptr to it
// before the io_context goes away.
// objects may be destroyed. We avoid the full stop() path (which
// tears down boost::asio's reactor and would crash on residual async
// handlers referencing destroyed statics) — the OS reclaims memory
// and sockets at process exit, so we only need to release the worker
// threads so the process can actually exit.
stopAndJoinIoThreads();
if (shutdown_listener_) {
shutdown_listener_();
shutdown_listener_ = {};
Expand Down
90 changes: 81 additions & 9 deletions src/web/src/web_serve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <functional>
Expand All @@ -18,6 +19,10 @@
#include <utility>
#include <vector>

#ifndef _WIN32
#include <unistd.h>
#endif
Comment thread
maliberty marked this conversation as resolved.
Outdated

#include "boost/asio/io_context.hpp"
#include "boost/asio/ip/tcp.hpp"
// NOLINTNEXTLINE(misc-include-cleaner)
Expand Down Expand Up @@ -129,6 +134,9 @@ void WebServer::serve(int port)
auto clock_report = std::make_shared<ClockTreeReport>(sta_);

auto tcl_eval = std::make_shared<TclEvaluator>(interp_, logger_);
// The Tcl handler calls this when the browser sends `exit`/`quit`, so
// the web server shuts down while the OpenROAD process keeps running.
tcl_eval->close_session = [this] { stop(); };

viewer_hook_ = std::make_unique<WebViewerHook>();
gui::Gui::get()->setHeadlessViewer(viewer_hook_.get());
Expand Down Expand Up @@ -209,6 +217,58 @@ void WebServer::serve(int port)
}
}

void WebServer::stopAndJoinIoThreads()
{
if (ioc_) {
ioc_->stop();
}
const auto self_id = std::this_thread::get_id();
for (auto& t : threads_) {
if (!t.joinable()) {
continue;
}
if (t.get_id() == self_id) {
// Self-join would raise EDEADLK. ioc_->stop() above unblocks the
// worker so detaching is safe — the thread runs to completion on
// its own.
t.detach();
} else {
t.join();
}
}
threads_.clear();
void WebServer::waitForStop()
{
std::unique_lock<std::mutex> lock(stop_mutex_);
Comment thread
maliberty marked this conversation as resolved.
Comment thread
maliberty marked this conversation as resolved.
stop_cv_.wait(lock, [this] { return stop_requested_; });
stop_requested_ = false;
lock.unlock();

// Notify connected browsers so they can show "Server stopped" and
// disable auto-reconnect. broadcastAndWait() waits for the write to
// complete before stop() tears down the io_context.
if (viewer_hook_) {
constexpr auto kShutdownFlushTimeout = std::chrono::seconds(2);
viewer_hook_->sessions().broadcastAndWait(R"({"type":"shutdown"})",
kShutdownFlushTimeout);
}

stop();
}

void WebServer::requestStop()
{
if (!isRunning()) {
Comment thread
maliberty marked this conversation as resolved.
Outdated
logger_->warn(utl::WEB, 36, "Web server is not running.");
return;
}
{
std::lock_guard<std::mutex> lock(stop_mutex_);
stop_requested_ = true;
}
stop_cv_.notify_one();
}

void WebServer::waitForStop()
{
std::unique_lock<std::mutex> lock(stop_mutex_);
Expand Down Expand Up @@ -259,18 +319,30 @@ void WebServer::stop()
shutdown_listener_();
shutdown_listener_ = {};
}
if (ioc_) {
ioc_->stop();
}
for (auto& t : threads_) {
if (t.joinable()) {
t.join();
}
}
threads_.clear();
stopAndJoinIoThreads();
// Release without destroying — destroying io_context can crash on
// residual async handlers. Leak is bounded (at most one io_context
// per serve/stop cycle).
(void) ioc_.release(); // NOLINT(bugprone-unused-return-value)
generator_.reset();
// Remove the log sink before destroying viewer_hook_ — the sink
// stores a raw pointer into it and the CLI thread may emit a log
// line at any moment.
if (log_sink_) {
logger_->removeSink(log_sink_);
log_sink_.reset();
}
viewer_hook_.reset();
logger_->info(utl::WEB, 41, "Web session closed.");
// Re-emit the prompt: tclreadline does not redraw on async output
// from another thread, so without this the user would have to press
// Enter to see a live prompt. Purely visual — readline's input state
// is untouched.
// NOLINTNEXTLINE(misc-include-cleaner)
if (isatty(fileno(stdout))) {
std::fputs("openroad> ", stdout);
std::fflush(stdout);
}
}

} // namespace web
Loading