Skip to content

Commit dc6dc21

Browse files
authored
Merge pull request #10324 from gadfort/web-log
web: cleanup logger handling
2 parents 45b7772 + 57654d7 commit dc6dc21

6 files changed

Lines changed: 88 additions & 9 deletions

File tree

src/web/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ cc_library(
169169
"@boost.beast",
170170
"@boost.geometry",
171171
"@boost.json",
172+
"@boost.system",
172173
"@spdlog",
173174
"@tcl_lang//:tcl",
174175
],

src/web/include/web/web.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <vector>
1616

1717
#include "boost/asio/ip/tcp.hpp"
18+
#include "boost/asio/steady_timer.hpp"
1819
#include "odb/db.h"
1920
#include "tcl.h"
2021
#include "utl/Logger.h"
@@ -133,6 +134,13 @@ class WebServer
133134
std::unique_ptr<boost::asio::io_context> ioc_;
134135
std::vector<std::thread> threads_;
135136

137+
// Periodic timer that drains WebLogSink so log output produced by
138+
// long-running Tcl commands streams to clients without waiting for a
139+
// debug pause/redraw or for the command to return. Reschedules
140+
// itself; cancelled in stop() before ioc_ is shut down.
141+
std::unique_ptr<boost::asio::steady_timer> log_drain_timer_;
142+
void scheduleLogDrain();
143+
136144
// Closes the Listener's acceptor before the io_context is destroyed,
137145
// avoiding a crash where the acceptor references a half-destroyed
138146
// io_context.

src/web/src/main.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -505,12 +505,12 @@ function createTclConsole(container) {
505505
tclAppend(`>>> ${cmd}\n`, 'tcl-cmd');
506506
completer.addToHistory(cmd);
507507
input.value = '';
508+
// Log output produced while the command runs streams in
509+
// separately as {"type":"log",...} push messages (handled
510+
// below in the onPush dispatch). The eval response only
511+
// carries the Tcl return value plus shutdown signaling.
508512
app.websocketManager.request({ type: 'tcl_eval', cmd })
509513
.then(data => {
510-
if (data.output) {
511-
tclAppend(data.output,
512-
data.is_error ? 'tcl-error' : '');
513-
}
514514
if (data.result) {
515515
tclAppend(data.result + '\n',
516516
data.is_error ? 'tcl-error' : '');

src/web/src/request_handler.cpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1208,7 +1208,6 @@ WebSocketResponse TclHandler::handleTclEval(const WebSocketRequest& req)
12081208
// teardown is already requested by tclExitHandler via requestStop().
12091209
const bool is_exit = (result.result == kExitResultMsg);
12101210
boost::json::object root;
1211-
root["output"] = result.output;
12121211
if (is_exit) {
12131212
tcl_eval_->logger->info(
12141213
utl::WEB, 40, "Exit requested from web GUI; shutting down.");

src/web/src/request_handler.h

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <boost/json/value.hpp>
88
#include <boost/json/value_to.hpp>
99
#include <cstdint>
10+
#include <functional>
1011
#include <map>
1112
#include <memory>
1213
#include <mutex>
@@ -36,16 +37,23 @@ class ClockTreeReport;
3637
// shutdown signal for the browser.
3738
inline constexpr const char* kExitResultMsg = "_WEB_EXITING_";
3839

39-
// Thread-safe Tcl command evaluation with output capture.
40+
// Thread-safe Tcl command evaluation. Log output emitted while the
41+
// command runs is captured by WebLogSink (registered on the logger via
42+
// addSink) and pushed to clients as {"type":"log",...} messages — do
43+
// NOT redirect the logger to a string here. redirectStringBegin clears
44+
// the entire sink list, which would unhook WebLogSink (and any other
45+
// sink) for the duration of the command and break log streaming. After
46+
// each eval the optional drain_output hook is invoked so any buffered
47+
// log output reaches clients before the eval response is sent.
4048
struct TclEvaluator
4149
{
4250
Tcl_Interp* interp;
4351
utl::Logger* logger;
4452
std::mutex mutex;
53+
std::function<void()> drain_output;
4554

4655
struct Result
4756
{
48-
std::string output;
4957
std::string result;
5058
bool is_error;
5159
};
@@ -58,12 +66,13 @@ struct TclEvaluator
5866
Result eval(const std::string& cmd)
5967
{
6068
std::lock_guard<std::mutex> lock(mutex);
61-
logger->redirectStringBegin();
6269
const int rc = Tcl_Eval(interp, cmd.c_str());
6370
Result r;
64-
r.output = logger->redirectStringEnd();
6571
r.result = Tcl_GetStringResult(interp);
6672
r.is_error = (rc != TCL_OK);
73+
if (drain_output) {
74+
drain_output();
75+
}
6776
return r;
6877
}
6978
};

src/web/src/web_serve.cpp

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@
1818
#include <utility>
1919
#include <vector>
2020

21+
#include "boost/asio/error.hpp"
2122
#include "boost/asio/io_context.hpp"
2223
#include "boost/asio/ip/tcp.hpp"
24+
#include "boost/asio/post.hpp"
25+
#include "boost/asio/steady_timer.hpp"
26+
#include "boost/asio/strand.hpp"
27+
#include "boost/system/error_code.hpp"
2328
// NOLINTNEXTLINE(misc-include-cleaner)
2429
#include "boost/beast/core.hpp"
2530
// NOLINTNEXTLINE(misc-include-cleaner)
@@ -168,6 +173,13 @@ void WebServer::serve(int port)
168173
logger_->addSink(log_sink_);
169174
viewer_hook_->setDrainLogsFn(
170175
[log_sink = std::move(log_sink)]() { log_sink->drainToClients(); });
176+
// Flush WebLogSink at the end of every Tcl eval so log output
177+
// emitted during a command reaches clients before the response
178+
// carrying the Tcl result. viewer_hook_ outlives every io thread
179+
// that can run a request handler (stop() joins io threads before
180+
// resetting viewer_hook_), so the raw pointer capture is safe.
181+
tcl_eval->drain_output
182+
= [hook = viewer_hook_.get()]() { hook->drainLogs(); };
171183

172184
TileGenerator::setDebugOverlayCallback(
173185
[weak_gen = std::weak_ptr<TileGenerator>(generator_),
@@ -210,6 +222,17 @@ void WebServer::serve(int port)
210222

211223
const std::string url = "http://localhost:" + std::to_string(handle.port);
212224

225+
// Bind the timer to a strand so all timer operations (expires_after,
226+
// async_wait, cancel) run serialized on a single io thread. Without
227+
// this, stop()'s cancel from the caller thread would race with the
228+
// async_wait handler rescheduling on a worker thread — the same
229+
// steady_timer cannot be safely mutated from multiple threads. The
230+
// initial scheduleLogDrain() below runs before io threads start, so
231+
// it is single-threaded by construction.
232+
log_drain_timer_ = std::make_unique<net::steady_timer>(
233+
net::make_strand(ioc_->get_executor()));
234+
scheduleLogDrain();
235+
213236
threads_.reserve(num_threads);
214237
for (int i = 0; i < num_threads; ++i) {
215238
threads_.emplace_back([this] { ioc_->run(); });
@@ -275,6 +298,32 @@ int WebServer::tclExitHandler(ClientData clientData,
275298
return TCL_ERROR;
276299
}
277300

301+
// Drain interval chosen to keep the browser console feeling live without
302+
// burning CPU when nothing is logged. An idle tick costs one mutex
303+
// acquire + empty-buffer check inside WebLogSink::drainToClients.
304+
static constexpr auto kLogDrainInterval = std::chrono::milliseconds(250);
305+
306+
void WebServer::scheduleLogDrain()
307+
{
308+
if (!log_drain_timer_) {
309+
return;
310+
}
311+
log_drain_timer_->expires_after(kLogDrainInterval);
312+
log_drain_timer_->async_wait([this](const boost::system::error_code& ec) {
313+
// operation_aborted means cancel() was called from stop(). Any
314+
// other error (or none) means the timer fired normally — drain and
315+
// reschedule. ioc_->stop() in stop() will discard a re-armed timer
316+
// before its next firing, so no UAF risk on shutdown.
317+
if (ec == net::error::operation_aborted) {
318+
return;
319+
}
320+
if (viewer_hook_) {
321+
viewer_hook_->drainLogs();
322+
}
323+
scheduleLogDrain();
324+
});
325+
}
326+
278327
void WebServer::requestStop()
279328
{
280329
if (!isRunning()) {
@@ -316,7 +365,20 @@ void WebServer::stop()
316365
shutdown_listener_();
317366
shutdown_listener_ = {};
318367
}
368+
// Cancel the periodic log drain so its handler stops re-arming. The
369+
// cancel is posted onto the timer's strand so it runs serialized with
370+
// scheduleLogDrain — calling cancel() directly from the caller thread
371+
// would race with the async_wait handler mutating the timer on an io
372+
// thread. Any in-flight handler completes normally; a re-armed timer
373+
// is discarded by ioc_->stop() below.
374+
if (log_drain_timer_) {
375+
auto* timer = log_drain_timer_.get();
376+
net::post(timer->get_executor(), [timer] { timer->cancel(); });
377+
}
319378
stopAndJoinIoThreads();
379+
// Reset only after threads are joined so no handler can dereference
380+
// the timer mid-shutdown.
381+
log_drain_timer_.reset();
320382
// Release without destroying — destroying io_context can crash on
321383
// residual async handlers. Leak is bounded (at most one io_context
322384
// per serve/stop cycle).

0 commit comments

Comments
 (0)