diff --git a/src/Main.cc b/src/Main.cc index 7c5acc4dfb4..04f769ca987 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -418,6 +418,8 @@ static int tclAppInit(int& argc, // directly on the main thread, like the non-GUI path. const bool gui_enabled = gui::Gui::enabled() && !web_enabled; + auto* web_server = ord::OpenRoad::openRoad()->getWebServer(); + if (read_odb_filename) { std::string cmd = fmt::format("read_db {{{}}}", read_odb_filename); if (!gui_enabled) { @@ -476,15 +478,16 @@ static int tclAppInit(int& argc, } } - // Block until the web server is stopped (like QApplication::exec() - // for the GUI). After this returns, fall through to readline. - if (web_enabled) { - auto* server = ord::OpenRoad::openRoad()->getWebServer(); - server->waitForStop(); - // `exit` typed in the browser Tcl widget signalled stop; do the - // real process exit now from the main thread (worker threads are - // already joined by stop()). - if (server->exitRequested()) { + // If the web server is running at this point — either because of + // -web (implicit serve before script) or because the script itself + // called `web_server` — park the main thread in Tcl_DoOneEvent so + // worker threads can drive browser-typed tcl_eval requests. The + // wait exits when a browser-typed `exit` (requestExit) or + // `web_server -stop` (requestStop) wakes the loop. + if (web_server->isRunning()) { + const bool was_exit = web_server->runEventLoopUntilStop(); + web_server->stop(); + if (was_exit) { exit(EXIT_SUCCESS); } } diff --git a/src/web/include/web/web.h b/src/web/include/web/web.h index feb1b719cc0..4da275915c0 100644 --- a/src/web/include/web/web.h +++ b/src/web/include/web/web.h @@ -5,12 +5,13 @@ #include -#include +#include #include #include #include #include #include +#include #include #include @@ -64,8 +65,14 @@ ListenerHandle createAndRunListener( WebViewerHook* viewer_hook); // A layout web server. serve() starts the server in background I/O -// threads; waitForStop() blocks the calling thread until requestStop() -// is called, mirroring gui::show / gui::hide. +// threads and returns immediately. After serve(), the calling Tcl +// script (or main-thread REPL) can keep executing — each top-level +// command on the main thread is broadcast to connected browsers as a +// `console_input` echo via a Tcl_CreateObjTrace at level 1. When the +// script settles, Main.cc parks the main thread in Tcl_DoOneEvent +// servicing browser-driven Tcl evaluations until exitRequested() +// or stopRequested() returns true; then the main thread calls +// stop() to tear down. class WebServer { @@ -82,21 +89,58 @@ class WebServer // the server is already running. void serve(int port); - // True after serve() returns and before stop/destructor. + // True after serve() returns and before stop()/destructor. bool isRunning() const { return ioc_ != nullptr; } - // Block the calling thread until requestStop() is called, then - // tear down the server. Typically called on the main/Tcl thread. - void waitForStop(); + // Tears down the I/O threads and cleans up hooks. Safe to call + // multiple times and from any thread (including a worker thread — + // stopAndJoinIoThreads detaches the calling thread if it is itself + // a worker, to avoid the EDEADLK self-join). After it returns, + // isRunning() is false and serve() may be called again to restart + // the server. + void stop(); - // Signal waitForStop() to return. Safe to call from any thread - // (e.g. an ASIO worker thread executing a Tcl command). + // Park the calling (main) thread in Tcl_DoOneEvent until exitRequested + // or stopRequested is set. Returns true if exit was requested + // (caller is expected to stop() then std::exit()), false if stop + // was requested (caller stop()s and continues). No-op (returns false + // immediately) if the server isn't running. + bool runEventLoopUntilStop(); + + // Worker-thread signals to wake the main thread out of Tcl_DoOneEvent. + // requestExit() sets the exit flag; the main thread will std::exit(0) + // after stop(). requestStop() sets the stop flag; main thread will + // tear the server down but not exit the process. Both queue a no-op + // event on the main thread's Tcl event queue so a parked + // Tcl_DoOneEvent returns and the wait loop re-checks the flags. + void requestExit(); void requestStop(); - // True if `exit` was invoked from a Tcl command running on a worker - // thread. Main.cc / web.i checks this after waitForStop() returns - // and exits the process cleanly from the main thread. bool exitRequested() const { return exit_requested_; } + bool stopRequested() const { return stop_requested_; } + + // The Tcl thread id captured in serve(). Used by the level-1 + // command trace to filter out worker-thread Tcl_Eval invocations + // (browser-typed tcl_eval requests) — the browser locally echoes + // those, so we avoid a duplicate console_input broadcast. + Tcl_ThreadId mainThreadId() const { return main_thread_id_; } + + // Lock used to serialize Tcl_Eval / STA access among worker threads. + // TclEvaluator::eval() and the STA-touching request handlers in + // request_handler.cpp lock this mutex. Exposed publicly so callers + // outside web/ that may directly evaluate Tcl on the same interp + // can opt into the same serialization. + std::mutex& tclMutex() { return tcl_mutex_; } + + // Broadcasts the given top-level command text to connected browsers + // as a `{"type":"console_input","text":...}` push frame. Called from + // the level-1 command trace installed in serve(). + void broadcastConsoleInput(const std::string& cmd); + + // Broadcasts a chunk of bytes written to Tcl's stdout/stderr channels + // as a `{"type":"log","text":...}` push frame. Called from the + // Tcl_StackChannel transform's outputProc installed in serve(). + void broadcastChannelChunk(std::string_view chunk); void saveReport(const std::string& filename, int max_setup_paths, @@ -111,12 +155,13 @@ class WebServer double dbu_per_pixel, const std::string& vis_json); - // 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: + // Wakes the main thread's Tcl event loop by queuing a no-op Tcl + // event so a parked Tcl_DoOneEvent returns and the wait loop in + // runEventLoopUntilStop re-checks the exit/stop flags. Safe to + // call from any thread; no-op if main_thread_id_ is unset. + void wakeMainEventLoop(); + // 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). @@ -149,14 +194,35 @@ class WebServer // 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_; - // Blocking support: waitForStop() sleeps on stop_cv_ until - // requestStop() sets stop_requested_. - std::mutex stop_mutex_; - std::condition_variable stop_cv_; - bool stop_requested_ = false; - - // Set by tclExitHandler when `exit` is run on a worker thread. - bool exit_requested_ = false; + + // Shared STA / Tcl_Eval serialization mutex (see tclMutex()). + // TclEvaluator::eval() and the STA-touching request handlers in + // request_handler.cpp lock this mutex. + std::mutex tcl_mutex_; + + // Set by tclExitHandler / requestExit() when an exit is being + // requested from a non-main thread (browser tcl widget). + std::atomic exit_requested_{false}; + // Set by requestStop() / web_server_stop_cmd from a worker. + std::atomic stop_requested_{false}; + + // Tcl thread id of the thread that called serve(). Used as the + // target for Tcl_ThreadQueueEvent / Tcl_ThreadAlert wakeups. + Tcl_ThreadId main_thread_id_ = nullptr; + + // Level-1 command trace token; installed in serve(), removed in + // teardown(). + Tcl_Trace trace_token_ = nullptr; + + // Tcl_StackChannel handles + per-channel state for the stdout / stderr + // mirror. The Tcl_Channel pointers are returned by Tcl_StackChannel + // and unstacked in teardown(); the void* fields point at heap- + // allocated WebChannelMirror structs that the channel type's + // outputProc reads as instance data. + Tcl_Channel mirror_stdout_chan_ = nullptr; + Tcl_Channel mirror_stderr_chan_ = nullptr; + void* mirror_stdout_data_ = nullptr; + void* mirror_stderr_data_ = nullptr; // Tcl command override: replaces `exit` while the server is running // so a worker-thread `exit` doesn't run Tcl_Exit (which would self-join diff --git a/src/web/src/main.js b/src/web/src/main.js index e6394aac331..53cdaeb4b6a 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -850,6 +850,13 @@ app.websocketManager.onPush = (msg) => { let text = msg.text; if (text.endsWith('\n')) text = text.slice(0, -1); if (text) tclAppend(text + '\n', ''); + } else if (msg.type === 'console_input') { + // Top-level command executed on the main Tcl thread (script line + // or terminal-typed command), broadcast by the level-1 trace + // installed in WebServer::serve(). Render it like a + // browser-typed command so the browser console shows the same + // history as the user driving the process from elsewhere. + if (msg.text) tclAppend(`>>> ${msg.text}\n`, 'tcl-cmd'); } else if (msg.type === 'shutdown') { // Server is stopping intentionally (web_server -stop). // Disable auto-reconnect and show a clear message. Note that diff --git a/src/web/src/request_handler.cpp b/src/web/src/request_handler.cpp index 12bfd353bbf..f5941f2e192 100644 --- a/src/web/src/request_handler.cpp +++ b/src/web/src/request_handler.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -40,12 +41,109 @@ #include "odb/dbTypes.h" #include "odb/geom.h" #include "request_dispatcher.h" +#include "tcl.h" +#include "tclDecls.h" #include "tile_generator.h" #include "timing_report.h" #include "utl/Logger.h" namespace web { +//------------------------------------------------------------------------------ +// TclEvaluator::eval — main-thread Tcl_Eval with cross-thread marshalling +//------------------------------------------------------------------------------ + +namespace { + +struct EvalState +{ + std::string cmd; + TclEvaluator::Result result; + std::mutex m; + std::condition_variable cv; + bool done = false; + TclEvaluator* evaluator; +}; + +struct EvalEvent : Tcl_Event +{ + EvalState* state; +}; + +// Run the actual Tcl_Eval under the shared mutex, then drain any +// buffered log output to clients so the response arrives in-order with +// the log lines. Used both as the direct fast-path on the main thread +// and as the body of the queued event proc when called from a worker. +// +// Evaluates at the GLOBAL namespace (TCL_EVAL_GLOBAL) regardless of +// the current Tcl call-frame. Without that flag, when web_server is +// invoked from the launching terminal, web_server_wait_cmd parks in +// Tcl_DoOneEvent while *still inside the web_server proc body*; the +// event proc's Tcl_Eval would inherit that proc's local scope and a +// browser-typed `puts $x` would fail to find globals set at the +// terminal prompt (Tcl proc scopes don't auto-fall-through to global). +// Browser commands behave as if typed at the top-level REPL. +void runEvalLocked(TclEvaluator& e, + const std::string& cmd, + TclEvaluator::Result& out) +{ + std::lock_guard lock(e.mutex); + const int rc + = Tcl_EvalEx(e.interp, cmd.c_str(), /*numBytes=*/-1, TCL_EVAL_GLOBAL); + out.result = Tcl_GetStringResult(e.interp); + out.is_error = (rc != TCL_OK); + if (e.drain_output) { + e.drain_output(); + } +} + +// Tcl event proc invoked on the main thread. Runs Tcl_Eval, fills +// the worker's EvalState, signals the cv so the worker can return. +extern "C" int evalEventProc(Tcl_Event* ev_, int /*flags*/) +{ + auto* ev = static_cast(ev_); + EvalState* s = ev->state; + runEvalLocked(*s->evaluator, s->cmd, s->result); + { + std::lock_guard lk(s->m); + s->done = true; + } + s->cv.notify_one(); + return 1; +} + +} // namespace + +TclEvaluator::Result TclEvaluator::eval(const std::string& cmd) +{ + // Direct fast path when the caller is already on the main thread + // (or we don't have a known main thread, e.g. unit tests built + // without a running server). + if (main_thread_id == nullptr || Tcl_GetCurrentThread() == main_thread_id) { + Result r; + runEvalLocked(*this, cmd, r); + return r; + } + + // Worker thread: Tcl 9 isolates per-thread interp state, so + // Tcl_Eval here would not see globals set on the main thread. + // Marshal the request to the main thread and wait for the result. + EvalState state; + state.cmd = cmd; + state.evaluator = this; + + auto* ev = reinterpret_cast(Tcl_Alloc(sizeof(EvalEvent))); + ev->proc = &evalEventProc; + ev->nextPtr = nullptr; + ev->state = &state; + Tcl_ThreadQueueEvent(main_thread_id, ev, TCL_QUEUE_TAIL); + Tcl_ThreadAlert(main_thread_id); + + std::unique_lock lk(state.m); + state.cv.wait(lk, [&] { return state.done; }); + return state.result; +} + //------------------------------------------------------------------------------ // ShapeCollector — a gui::Painter that collects rectangles from // descriptor->highlight() calls for use in tile rendering. diff --git a/src/web/src/request_handler.h b/src/web/src/request_handler.h index dbaa99020a3..8c531847ca9 100644 --- a/src/web/src/request_handler.h +++ b/src/web/src/request_handler.h @@ -37,19 +37,38 @@ class ClockTreeReport; // shutdown signal for the browser. inline constexpr const char* kExitResultMsg = "_WEB_EXITING_"; -// Thread-safe Tcl command evaluation. Log output emitted while the -// command runs is captured by WebLogSink (registered on the logger via -// addSink) and pushed to clients as {"type":"log",...} messages — do -// NOT redirect the logger to a string here. redirectStringBegin clears -// the entire sink list, which would unhook WebLogSink (and any other -// sink) for the duration of the command and break log streaming. After -// each eval the optional drain_output hook is invoked so any buffered -// log output reaches clients before the eval response is sent. +// Tcl command evaluation that always runs on the main (interp-owning) +// thread. Browser worker threads call eval(); when invoked from a +// non-main thread, the implementation marshals the request to the main +// thread via Tcl_ThreadQueueEvent and waits on a condition variable for +// the result. This is required under Tcl 9, which isolates per-thread +// interpreter state — direct cross-thread Tcl_Eval doesn't see globals +// set on the main thread (e.g., `set x 1` at the launching prompt +// followed by `puts $x` in the browser console returns an unset error). +// +// Log output emitted while the command runs is captured by WebLogSink +// (registered on the logger via addSink) and pushed to clients as +// {"type":"log",...} messages — do NOT redirect the logger to a string +// here. redirectStringBegin clears the entire sink list, which would +// unhook WebLogSink (and any other sink) for the duration of the +// command and break log streaming. After each eval the optional +// drain_output hook is invoked so any buffered log output reaches +// clients before the eval response is sent. +// +// `mutex` (a reference to a mutex owned by WebServer) serializes STA / +// interp access among request handlers (handleSelect, etc.) that touch +// STA directly. Marshalled eval also locks it so main-thread Tcl_Eval +// and worker-thread STA reads don't race. +// +// `main_thread_id` is captured by serve() (Tcl_GetCurrentThread() at +// the top of serve, before any worker is spawned) and is the target +// of Tcl_ThreadQueueEvent / Tcl_ThreadAlert. struct TclEvaluator { Tcl_Interp* interp; utl::Logger* logger; - std::mutex mutex; + std::mutex& mutex; + Tcl_ThreadId main_thread_id; std::function drain_output; struct Result @@ -58,23 +77,18 @@ struct TclEvaluator bool is_error; }; - TclEvaluator(Tcl_Interp* interp, utl::Logger* logger) - : interp(interp), logger(logger) + TclEvaluator(Tcl_Interp* interp, + utl::Logger* logger, + std::mutex& mutex, + Tcl_ThreadId main_thread_id) + : interp(interp), + logger(logger), + mutex(mutex), + main_thread_id(main_thread_id) { } - Result eval(const std::string& cmd) - { - std::lock_guard lock(mutex); - const int rc = Tcl_Eval(interp, cmd.c_str()); - Result r; - r.result = Tcl_GetStringResult(interp); - r.is_error = (rc != TCL_OK); - if (drain_output) { - drain_output(); - } - return r; - } + Result eval(const std::string& cmd); }; struct WebSocketRequest diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp index 71d7e8a0fbe..c1e400a2b32 100644 --- a/src/web/src/web.cpp +++ b/src/web/src/web.cpp @@ -876,14 +876,6 @@ void WebServer::stopAndJoinIoThreads() WebServer::~WebServer() { - // Wake any thread blocked in waitForStop() so it can return before - // we tear down the io_context. - { - std::lock_guard lock(stop_mutex_); - stop_requested_ = true; - } - stop_cv_.notify_one(); - // 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. We avoid the full stop() path (which diff --git a/src/web/src/web.i b/src/web/src/web.i index 92968270e3c..5bfa40ba07b 100644 --- a/src/web/src/web.i +++ b/src/web/src/web.i @@ -21,14 +21,18 @@ web_server_cmd(int port) server->serve(port); } +// Block the calling Tcl_Eval until web_server -stop or browser-typed +// `exit`. Used by web_server when called interactively so the +// launching terminal's prompt is suppressed while the server is +// running. Tears the server down before returning; on exit-request, +// performs std::exit(0) here on the main thread. void web_server_wait_cmd() { web::WebServer *server = ord::OpenRoad::openRoad()->getWebServer(); - server->waitForStop(); - // If `exit` was typed in the browser tcl widget, do the real process - // exit here on the main thread (workers are already joined). - if (server->exitRequested()) { + const bool was_exit = server->runEventLoopUntilStop(); + server->stop(); + if (was_exit) { std::exit(EXIT_SUCCESS); } } @@ -37,6 +41,10 @@ void web_server_stop_cmd() { web::WebServer *server = ord::OpenRoad::openRoad()->getWebServer(); + // requestStop() just sets a flag and wakes the main-thread Tcl + // event loop so it can tear the server down. Safe from any thread: + // the worker thread that ran this command never tries to join + // itself. server->requestStop(); } diff --git a/src/web/src/web.tcl b/src/web/src/web.tcl index 9f5ed88aa33..6f55fd584bd 100644 --- a/src/web/src/web.tcl +++ b/src/web/src/web.tcl @@ -24,7 +24,36 @@ proc web_server { args } { } web::web_server_cmd $port - web::web_server_wait_cmd + + # When invoked interactively (i.e. tclreadline::Loop is somewhere + # above us on the call stack) we want the browser to be the only + # Tcl input surface — block here so the launching terminal's + # tclreadline prompt is suppressed until `web_server -stop` (browser + # button) or browser-typed `exit` ends the server. When invoked + # from a sourced script, return immediately so the rest of the + # script keeps running; Main.cc parks in Tcl_DoOneEvent after the + # script settles. + # + # `info script` is unreliable here because in OpenROAD it stays + # set to the most recently sourced file even after sourcing ends, + # so we walk the call frames and look for tclreadline::Loop. + set in_readline 0 + for { set i 1 } { $i <= [info frame] } { incr i } { + set frame [info frame $i] + if { [dict exists $frame proc] } { + set fproc [dict get $frame proc] + if { + $fproc eq "::tclreadline::Loop" + || $fproc eq "tclreadline::Loop" + } { + set in_readline 1 + break + } + } + } + if { $in_readline } { + web::web_server_wait_cmd + } } sta::define_cmd_args "save_image" {[-web] \ diff --git a/src/web/src/web_serve.cpp b/src/web/src/web_serve.cpp index 600ccf1684a..78f38cc0bfe 100644 --- a/src/web/src/web_serve.cpp +++ b/src/web/src/web_serve.cpp @@ -6,6 +6,7 @@ // references (which would require the full gui library including Qt // SWIG wrappers and ord::OpenRoad symbols). +#include #include #include #include @@ -25,6 +26,7 @@ #include "boost/asio/steady_timer.hpp" #include "boost/asio/strand.hpp" #include "boost/system/error_code.hpp" +#include "tclDecls.h" // NOLINTNEXTLINE(misc-include-cleaner) #include "boost/beast/core.hpp" // NOLINTNEXTLINE(misc-include-cleaner) @@ -120,6 +122,205 @@ class WebLogSink : public spdlog::sinks::base_sink std::string pending_; }; +// Tcl stdout / stderr capture -------------------------------------------- +// +// Tcl `puts` and other commands write to Tcl's stdout/stderr channels, +// not through utl::Logger, so WebLogSink never sees them. We push a +// transform onto each channel via Tcl_StackChannel: the transform's +// outputProc forwards bytes to the underlying channel (so the launching +// terminal still sees them) AND broadcasts them to connected browsers +// as a `{"type":"log","text":...}` push frame so the browser Tcl console +// shows the same output stream. Pattern mirrors src/sta/util/ReportTcl.cc. +// +// Per-channel state pinned for the lifetime of the stack — the bottom- +// of-stack channel pointer is needed by the outputProc to forward bytes +// downward (Tcl_GetStackedChannel exists in newer Tcls but we save the +// channel explicitly to match the STA pattern and avoid version churn). + +struct WebChannelMirror +{ + WebServer* server; + Tcl_Channel parent; +}; + +extern "C" { + +static int webChannelOutputProc(ClientData instanceData, + const char* buf, + int toWrite, + int* errorCodePtr) +{ + auto* m = static_cast(instanceData); + + // Forward to the next channel in the stack so the terminal still + // sees the output. + const Tcl_ChannelType* parent_type = Tcl_GetChannelType(m->parent); + Tcl_DriverOutputProc* parent_output = Tcl_ChannelOutputProc(parent_type); + ClientData parent_data = Tcl_GetChannelInstanceData(m->parent); + const int written = parent_output( + parent_data, const_cast(buf), toWrite, errorCodePtr); + + // Only broadcast bytes that actually reached the underlying channel — + // a short write means the rest didn't make it to the terminal, so we + // shouldn't pretend the browser saw them either. + if (m->server != nullptr && written > 0) { + m->server->broadcastChannelChunk(std::string_view(buf, written)); + } + return written; +} + +static int webChannelInputProc(ClientData /*instanceData*/, + char* /*buf*/, + int /*bufSize*/, + int* errorCodePtr) +{ + *errorCodePtr = EINVAL; + return -1; +} + +static int webChannelSetOptionProc(ClientData /*instanceData*/, + Tcl_Interp* /*interp*/, + const char* /*optionName*/, + const char* /*value*/) +{ + return TCL_OK; +} + +static int webChannelGetOptionProc(ClientData /*instanceData*/, + Tcl_Interp* /*interp*/, + const char* /*optionName*/, + Tcl_DString* /*dsPtr*/) +{ + return TCL_OK; +} + +static void webChannelWatchProc(ClientData /*instanceData*/, int /*mask*/) +{ +} + +static int webChannelGetHandleProc(ClientData /*instanceData*/, + int /*direction*/, + ClientData* /*handlePtr*/) +{ + return TCL_ERROR; +} + +static int webChannelBlockModeProc(ClientData /*instanceData*/, int /*mode*/) +{ + return 0; +} + +static int webChannelClose2Proc(ClientData /*instanceData*/, + Tcl_Interp* /*interp*/, + int /*flags*/) +{ + return 0; +} + +} // extern "C" + +static const Tcl_ChannelType web_channel_type = { + .typeName = "web_mirror", + .version = TCL_CHANNEL_VERSION_5, + .closeProc = nullptr, // closeProc unused with close2Proc + .inputProc = webChannelInputProc, + .outputProc = webChannelOutputProc, + .seekProc = nullptr, + .setOptionProc = webChannelSetOptionProc, + .getOptionProc = webChannelGetOptionProc, + .watchProc = webChannelWatchProc, + .getHandleProc = webChannelGetHandleProc, + .close2Proc = webChannelClose2Proc, + .blockModeProc = webChannelBlockModeProc, + .flushProc = nullptr, + .handlerProc = nullptr, + .wideSeekProc = nullptr, + .threadActionProc = nullptr, + .truncateProc = nullptr, +}; + +void WebServer::broadcastChannelChunk(std::string_view chunk) +{ + if (!viewer_hook_ || chunk.empty()) { + return; + } + // Reuse the existing `log` frame type so the browser Tcl console + // appends with the same styling as logger output. The browser strips + // a single trailing newline before re-appending one. + boost::json::object msg; + msg["type"] = "log"; + msg["text"] = std::string(chunk); + viewer_hook_->sessions().broadcast(boost::json::serialize(msg)); +} + +// Tcl_CreateObjTrace callback (level=1) that broadcasts each top-level +// command on the main thread to connected browsers as a console_input +// echo. This is what makes the rest of an `openroad foo.tcl` script +// after `web_server` show up in the browser Tcl console "as if user +// input". We deliberately skip worker-thread Tcl_Evals (browser-typed +// tcl_eval requests run via TclEvaluator on a worker, also at depth 1) +// because main.js already locally echoes those — broadcasting from the +// trace would render `>>> cmd` twice in the browser that typed it. +static int objTraceProc(ClientData clientData, + Tcl_Interp* /*interp*/, + int /*level*/, + const char* command, + Tcl_Command /*cmd*/, + int /*objc*/, + Tcl_Obj* const /*objv*/[]) +{ + if (command == nullptr) { + return TCL_OK; + } + auto* server = static_cast(clientData); + if (Tcl_GetCurrentThread() != server->mainThreadId()) { + return TCL_OK; + } + server->broadcastConsoleInput(command); + return TCL_OK; +} + +void WebServer::broadcastConsoleInput(const std::string& cmd) +{ + if (!viewer_hook_) { + return; + } + std::string trimmed = cmd; + // Drop trailing newline / whitespace that comes from script lines. + while (!trimmed.empty() + && (trimmed.back() == '\n' || trimmed.back() == '\r' + || trimmed.back() == ' ' || trimmed.back() == '\t')) { + trimmed.pop_back(); + } + if (trimmed.empty()) { + return; + } + boost::json::object msg; + msg["type"] = "console_input"; + msg["text"] = trimmed; + viewer_hook_->sessions().broadcast(boost::json::serialize(msg)); +} + +// No-op event used to wake a parked Tcl_DoOneEvent on the main thread +// after a worker sets exit_requested_ / stop_requested_. The wait loop +// re-checks the flags after each event. +static int wakeEventProc(Tcl_Event* /*ev*/, int /*flags*/) +{ + return 1; +} + +void WebServer::wakeMainEventLoop() +{ + if (main_thread_id_ == nullptr) { + return; + } + auto* ev = static_cast(ckalloc(sizeof(Tcl_Event))); + ev->proc = &wakeEventProc; + ev->nextPtr = nullptr; + Tcl_ThreadQueueEvent(main_thread_id_, ev, TCL_QUEUE_TAIL); + Tcl_ThreadAlert(main_thread_id_); +} + void WebServer::serve(int port) { if (ioc_) { @@ -127,30 +328,26 @@ void WebServer::serve(int port) return; } - // Clear any stale stop request left over from a previous session. - // Without this, a requestStop() that arrives during teardown (after - // waitForStop() cleared the flag but before stop() finishes) would - // cause the next waitForStop() to return immediately. - { - std::lock_guard lock(stop_mutex_); - stop_requested_ = false; - } + exit_requested_ = false; + stop_requested_ = false; + main_thread_id_ = Tcl_GetCurrentThread(); try { generator_ = std::make_shared(db_, sta_, logger_); auto timing_report = std::make_shared(sta_); auto clock_report = std::make_shared(sta_); - auto tcl_eval = std::make_shared(interp_, logger_); + auto tcl_eval = std::make_shared( + interp_, logger_, tcl_mutex_, main_thread_id_); // Override Tcl's `exit` so a user typing `exit` in the browser tcl // widget doesn't run Tcl_Exit on the worker thread (which triggers - // ~WebServer's self-join → std::terminate). Same pattern as - // gui::TclCmdInputWidget. The handler signals waitForStop() and - // sets exit_requested_; the main thread does the real exit. - // TclHandler::handleTclEval detects kExitResultMsg in the Tcl - // result and sends `action: "shutdown"` to the browser. - exit_requested_ = false; + // ~WebServer's self-join → std::terminate). The handler signals + // the main thread via requestExit(); runEventLoopUntilStop wakes + // and the caller (web_server_wait_cmd or Main.cc) calls stop() + // and then std::exit on the main thread. TclHandler::handleTclEval + // detects kExitResultMsg in the Tcl result and sends + // `action: "shutdown"` to the browser. { const std::string rename_orig = std::string("rename exit ") + kRenamedExitCmd; @@ -159,6 +356,16 @@ void WebServer::serve(int port) interp_, "exit", &WebServer::tclExitHandler, this, nullptr); } + // Level-1 command trace: broadcast each top-level command on the + // main thread to browsers as a console_input echo. Installed only + // while the server is running. + trace_token_ = Tcl_CreateObjTrace(interp_, + /*level=*/1, + /*flags=*/0, + &objTraceProc, + this, + /*delProc=*/nullptr); + viewer_hook_ = std::make_unique(); gui::Gui::get()->setHeadlessViewer(viewer_hook_.get()); gui::Gui::get()->setChartFactory( @@ -181,6 +388,29 @@ void WebServer::serve(int port) tcl_eval->drain_output = [hook = viewer_hook_.get()]() { hook->drainLogs(); }; + // Install Tcl_StackChannel mirrors on stdout / stderr so that + // `puts` and similar Tcl-level output reaches the browser console + // in addition to the launching terminal. The transform's instance + // data points at the channel below (the original stdout / STA + // ReportTcl encap), so writes pass straight through after we + // broadcast. + Tcl_Channel stdout_chan = Tcl_GetStdChannel(TCL_STDOUT); + if (stdout_chan != nullptr) { + auto* mirror + = new WebChannelMirror{.server = this, .parent = stdout_chan}; + mirror_stdout_data_ = mirror; + mirror_stdout_chan_ = Tcl_StackChannel( + interp_, &web_channel_type, mirror, TCL_WRITABLE, stdout_chan); + } + Tcl_Channel stderr_chan = Tcl_GetStdChannel(TCL_STDERR); + if (stderr_chan != nullptr) { + auto* mirror + = new WebChannelMirror{.server = this, .parent = stderr_chan}; + mirror_stderr_data_ = mirror; + mirror_stderr_chan_ = Tcl_StackChannel( + interp_, &web_channel_type, mirror, TCL_WRITABLE, stderr_chan); + } + TileGenerator::setDebugOverlayCallback( [weak_gen = std::weak_ptr(generator_), hook = viewer_hook_.get()](std::vector& image, @@ -264,36 +494,13 @@ void WebServer::serve(int port) } } -void WebServer::waitForStop() -{ - std::unique_lock lock(stop_mutex_); - 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(); -} - int WebServer::tclExitHandler(ClientData clientData, Tcl_Interp* interp, int /*argc*/, const char* /*argv*/[]) { auto* self = static_cast(clientData); - self->exit_requested_ = true; - // Wake waitForStop() on the main thread so it can join the worker - // threads (including the one currently executing this handler) and - // exit cleanly via std::exit() from the main thread. - self->requestStop(); + self->requestExit(); Tcl_SetResult(interp, const_cast(kExitResultMsg), TCL_STATIC); return TCL_ERROR; } @@ -324,47 +531,62 @@ void WebServer::scheduleLogDrain() }); } -void WebServer::requestStop() +void WebServer::requestExit() { if (!isRunning()) { - logger_->warn(utl::WEB, 36, "Web server is not running."); return; } - { - std::lock_guard lock(stop_mutex_); - stop_requested_ = true; + exit_requested_ = true; + wakeMainEventLoop(); +} + +void WebServer::requestStop() +{ + // No warning if the server already stopped — common in races where + // browser-typed `exit` (or another path) tore the server down before + // a `web_server -stop` arrived. + if (!isRunning()) { + return; } - stop_cv_.notify_one(); + stop_requested_ = true; + wakeMainEventLoop(); } -void WebServer::stop() +bool WebServer::runEventLoopUntilStop() { - // Restore the original Tcl `exit` command before tearing down — pairs - // with the rename in serve(). Skip if not installed (stop() is safe - // to call multiple times). - if (Tcl_FindCommand(interp_, kRenamedExitCmd, nullptr, 0) != nullptr) { - Tcl_DeleteCommand(interp_, "exit"); - const std::string restore - = std::string("rename ") + kRenamedExitCmd + " exit"; - Tcl_Eval(interp_, restore.c_str()); + if (!isRunning()) { + return false; + } + // Service timers, idle handlers, and cross-thread queued events + // (the wakeup events posted by requestExit/requestStop), but + // explicitly NOT TCL_FILE_EVENTS — otherwise tclreadline's stdin + // file handler would still read lines from the launching terminal, + // defeating the "browser is the only Tcl input surface" model when + // web_server is invoked interactively. + constexpr int kEventMask + = TCL_TIMER_EVENTS | TCL_IDLE_EVENTS | TCL_WINDOW_EVENTS; + while (!exitRequested() && !stopRequested() && isRunning()) { + Tcl_DoOneEvent(kEventMask); } + return exitRequested(); +} +void WebServer::stop() +{ + // Notify browsers first so they show "Server stopped" instead of + // attempting to reconnect. if (viewer_hook_) { - TileGenerator::setDebugOverlayCallback({}); - if (gui::Gui::get()->getHeadlessViewer() == viewer_hook_.get()) { - gui::Gui::get()->setHeadlessViewer(nullptr); - } - gui::Gui::get()->setChartFactory({}); - } - if (log_sink_) { - logger_->removeSink(log_sink_); - log_sink_.reset(); + constexpr auto kShutdownFlushTimeout = std::chrono::seconds(2); + viewer_hook_->sessions().broadcastAndWait(R"({"type":"shutdown"})", + kShutdownFlushTimeout); } + // Stop accepting new connections. if (shutdown_listener_) { shutdown_listener_(); shutdown_listener_ = {}; } + // Cancel the periodic log drain so its handler stops re-arming. The // cancel is posted onto the timer's strand so it runs serialized with // scheduleLogDrain — calling cancel() directly from the caller thread @@ -375,23 +597,77 @@ void WebServer::stop() auto* timer = log_drain_timer_.get(); net::post(timer->get_executor(), [timer] { timer->cancel(); }); } + + // Stop ioc_ and join workers BEFORE touching the Tcl interpreter — + // otherwise a worker mid-Tcl_Eval would race with the trace removal + // / exit-command restoration below. stopAndJoinIoThreads handles + // the self-join case (current thread is one of the workers — e.g. + // browser-typed shutdown delivered via a worker that ends up calling + // stop() through the atexit chain) by detaching that thread. stopAndJoinIoThreads(); + // Reset only after threads are joined so no handler can dereference // the timer mid-shutdown. log_drain_timer_.reset(); + + // Unstack the stdout / stderr mirror channels. Tcl_UnstackChannel + // calls our channel type's close2Proc, which is a no-op; we then + // free the heap-allocated instance data ourselves. + if (mirror_stdout_chan_ != nullptr) { + Tcl_UnstackChannel(interp_, mirror_stdout_chan_); + mirror_stdout_chan_ = nullptr; + } + if (mirror_stderr_chan_ != nullptr) { + Tcl_UnstackChannel(interp_, mirror_stderr_chan_); + mirror_stderr_chan_ = nullptr; + } + delete static_cast(mirror_stdout_data_); + mirror_stdout_data_ = nullptr; + delete static_cast(mirror_stderr_data_); + mirror_stderr_data_ = nullptr; + + // Remove the level-1 command trace. + if (trace_token_ != nullptr) { + Tcl_DeleteTrace(interp_, trace_token_); + trace_token_ = nullptr; + } + + // Restore the original Tcl `exit` command — pairs with the rename + // in serve(). + if (Tcl_FindCommand(interp_, kRenamedExitCmd, nullptr, 0) != nullptr) { + Tcl_DeleteCommand(interp_, "exit"); + const std::string restore + = std::string("rename ") + kRenamedExitCmd + " exit"; + Tcl_Eval(interp_, restore.c_str()); + } + + if (viewer_hook_) { + TileGenerator::setDebugOverlayCallback({}); + if (gui::Gui::get()->getHeadlessViewer() == viewer_hook_.get()) { + gui::Gui::get()->setHeadlessViewer(nullptr); + } + gui::Gui::get()->setChartFactory({}); + } + // Release without destroying — destroying io_context can crash on - // residual async handlers. Leak is bounded (at most one io_context + // 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. + // stores a raw pointer into the hook 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(); + + main_thread_id_ = nullptr; + exit_requested_ = false; + stop_requested_ = false; + logger_->info(utl::WEB, 41, "Web session closed."); } diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp index ecdc38027d4..ecd60801041 100644 --- a/src/web/test/cpp/TestRequestHandler.cpp +++ b/src/web/test/cpp/TestRequestHandler.cpp @@ -393,7 +393,11 @@ class SelectHandlerTest : public tst::Nangate45Fixture .bbox = {100, 100, 200, 200}}; gen_ = std::make_shared( getDb(), /*sta=*/nullptr, getLogger()); - tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger()); + tcl_eval_ = std::make_shared( + /*interp=*/nullptr, + getLogger(), + tcl_mutex_, + /*main_thread_id=*/nullptr); handler_ = std::make_unique(gen_, tcl_eval_); } @@ -416,6 +420,7 @@ class SelectHandlerTest : public tst::Nangate45Fixture } std::shared_ptr gen_; + std::mutex tcl_mutex_; std::shared_ptr tcl_eval_; std::unique_ptr handler_; SessionState state_; diff --git a/src/web/test/cpp/TestSnap.cpp b/src/web/test/cpp/TestSnap.cpp index ef11e86db60..17a3d0c5e0b 100644 --- a/src/web/test/cpp/TestSnap.cpp +++ b/src/web/test/cpp/TestSnap.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2026, The OpenROAD Authors #include +#include #include #include #include @@ -193,7 +194,11 @@ class SnapHandlerTest : public tst::Nangate45Fixture placeInst("BUF_X16", "buf1", 0, 0); gen_ = std::make_shared( getDb(), /*sta=*/nullptr, getLogger()); - tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger()); + tcl_eval_ = std::make_shared( + /*interp=*/nullptr, + getLogger(), + tcl_mutex_, + /*main_thread_id=*/nullptr); handler_ = std::make_unique(gen_, tcl_eval_); } @@ -211,6 +216,7 @@ class SnapHandlerTest : public tst::Nangate45Fixture } std::shared_ptr gen_; + std::mutex tcl_mutex_; std::shared_ptr tcl_eval_; std::unique_ptr handler_; };