From dceb61237d76db10e303bef25d4672f30a718c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Thu, 4 Jun 2026 20:32:36 +0200 Subject: [PATCH] tui: fix nested `source` abandoning the calling script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tui::Source kept the script position in single member fields (loading_lines/idx/...), so a sourced line that was itself `source inner` overwrote them: when the inner file finished, the outer script's remaining lines were silently dropped. The state is now a stack of SourceFrames — the stack is the call chain. A nested source just pushes a frame (the running driver, ticker thread or headless drain, picks it up next line) and the caller's frame resumes when it pops. Each frame still prints its own "source: (N line(s))" summary; an interactive-line abort clears the whole chain; depth capped at 32 like the core script engine. The Computing modal shows the top frame. Regression-tested headless via BootDispatch (tests/tui/test_source.cpp): nested-then-continue, and self-recursion hitting the depth guard. Co-Authored-By: Claude Opus 4.8 --- src/frontends/tui/shell.cpp | 52 +++++++++++++++++---------- src/frontends/tui/tui.cpp | 12 ++++--- src/frontends/tui/tui.hpp | 15 +++++--- tests/tui/test_source.cpp | 71 +++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 tests/tui/test_source.cpp diff --git a/src/frontends/tui/shell.cpp b/src/frontends/tui/shell.cpp index 1ca4bc5..27f4a4d 100644 --- a/src/frontends/tui/shell.cpp +++ b/src/frontends/tui/shell.cpp @@ -281,20 +281,29 @@ void Tui::Source(const std::string &filename) { if (const char *home = std::getenv("HOME")) expanded = std::string(home) + expanded.substr(1); } + if (source_stack.size() >= 32) { // same depth guard as the core engine + Print("source: nesting too deep, skipping " + filename); + return; + } std::ifstream f(expanded); if (!f) { Print("source failed: cannot open " + filename); return; } // Slurp the whole file so we can drive line-by-line processing from the // event loop (one line per posted task). This lets the screen redraw // between lines and surface the "Computing…" modal. - loading_lines.clear(); + SourceFrame fr; + fr.filename = filename; std::string line; - while (std::getline(f, line)) loading_lines.push_back(line); + while (std::getline(f, line)) fr.lines.push_back(std::move(line)); + + // Nested source (a sourced line is itself `source …`): just stack the + // frame — the driver already running (ticker thread or headless drain) + // picks it up on the next ProcessNextSourceLine, and the caller's frame + // resumes when it finishes. + bool nested = !source_stack.empty(); + source_stack.push_back(std::move(fr)); + if (nested) return; - loading_filename = filename; - loading_idx = 0; - loading_executed = 0; - loading_lineno = 0; loading_prev_in_source = in_source; source_origin_screen = screen_idx; // a sourced line that leaves this screen aborts in_source = true; @@ -332,9 +341,18 @@ void Tui::Source(const std::string &filename) { void Tui::ProcessNextSourceLine() { if (!loading.load()) return; - while (loading_idx < loading_lines.size()) { - const std::string &raw = loading_lines[loading_idx++]; - ++loading_lineno; + while (!source_stack.empty()) { + if (source_stack.back().idx >= source_stack.back().lines.size()) { + // Frame done: summarise it and resume the caller's frame. + const SourceFrame &done = source_stack.back(); + Print("source: " + done.filename + + " (" + std::to_string(done.executed) + " line(s))"); + source_stack.pop_back(); + continue; + } + SourceFrame &fr = source_stack.back(); + const std::string raw = fr.lines[fr.idx++]; + ++fr.lineno; size_t start = raw.find_first_not_of(" \t"); if (start == std::string::npos) continue; if (raw[start] == '#') continue; @@ -343,28 +361,26 @@ void Tui::ProcessNextSourceLine() { trimmed.pop_back(); if (trimmed.empty()) continue; + ++fr.executed; + int lineno = fr.lineno; // copies: Submit can push a nested frame, + // which may reallocate and invalidate `fr`. input = trimmed; cursor_pos = (int)input.size(); Submit(); - ++loading_executed; if (screen_idx != source_origin_screen) { - Print("source: line " + std::to_string(loading_lineno) + Print("source: line " + std::to_string(lineno) + " is interactive (would open a screen) — aborting."); screen_idx = source_origin_screen; - loading.store(false); - computing_open = false; - tick_in_flight.store(false); - in_source = loading_prev_in_source; - return; + source_stack.clear(); // an abort cancels the whole chain + break; } // One effective line per tick — ack so the ticker can pace the next. tick_in_flight.store(false); return; } - Print("source: " + loading_filename - + " (" + std::to_string(loading_executed) + " line(s))"); + // Stack drained (or aborted): close up. loading.store(false); computing_open = false; tick_in_flight.store(false); diff --git a/src/frontends/tui/tui.cpp b/src/frontends/tui/tui.cpp index 408bd46..17bb812 100644 --- a/src/frontends/tui/tui.cpp +++ b/src/frontends/tui/tui.cpp @@ -12,7 +12,6 @@ using namespace ftxui; Tui::Tui() : cursor_pos(0), history_idx(-1), scroll_offset(0), quit(false), in_source(false), loading(false), tick_in_flight(false), - loading_idx(0), loading_executed(0), loading_lineno(0), loading_prev_in_source(false), screen_ptr(nullptr), screen_idx(4), // boot to the dashboard; console (screen 0) is now a sub-screen connect_m1_idx(0), connect_m2_idx(0), @@ -64,12 +63,17 @@ void Tui::Run() { // script is opened from the dashboard. The Renderer re-reads the live // progress every frame. auto computing_modal = Renderer([this] { - std::string progress = std::to_string(loading_executed) + " / " - + std::to_string((int)loading_lines.size()) + " lines"; + std::string fname, progress; + if (!source_stack.empty()) { // top frame = the file currently running + const SourceFrame &fr = source_stack.back(); + fname = fr.filename; + progress = std::to_string(fr.executed) + " / " + + std::to_string((int)fr.lines.size()) + " lines"; + } return vbox({ text(" Computing… ") | bold | center, separator(), - text(loading_filename) | center, + text(fname) | center, text(progress) | center, }) | borderDouble | size(WIDTH, GREATER_THAN, 40); }); diff --git a/src/frontends/tui/tui.hpp b/src/frontends/tui/tui.hpp index 8744b4a..ee51ecf 100644 --- a/src/frontends/tui/tui.hpp +++ b/src/frontends/tui/tui.hpp @@ -99,11 +99,16 @@ class Tui : public Frontend { // ---- Source-file loading (event-driven, one line per tick) ---- std::atomic loading; ///< true while a script is being processed; read by tick thread. std::atomic tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits. - std::string loading_filename; - std::vector loading_lines; - size_t loading_idx; - int loading_executed; - int loading_lineno; + // One script being processed. Nested `source` pushes a frame so the + // caller resumes where it left off — the stack IS the call chain. + struct SourceFrame { + std::string filename; + std::vector lines; + size_t idx = 0; ///< next line to process + int executed = 0; ///< effective (non-blank, non-comment) lines run + int lineno = 0; ///< current 1-based line, for messages + }; + std::vector source_stack; bool loading_prev_in_source; int source_origin_screen = 0; ///< screen a `source` started from; a sourced line that navigates away (opens an interactive screen) aborts it. bool computing_open = false; ///< drives the global "Computing…" progress modal while a script loads. diff --git a/tests/tui/test_source.cpp b/tests/tui/test_source.cpp new file mode 100644 index 0000000..2dbfce0 --- /dev/null +++ b/tests/tui/test_source.cpp @@ -0,0 +1,71 @@ +#include + +#include "frontends/tui/tui.hpp" + +#include +#include +#include +#include + +// Tui::Source nesting — regression for the bug where a nested `source` +// overwrote the single loading state, so the CALLING script's remaining +// lines never ran. Headless path (no screen): BootDispatch drains +// synchronously, exactly like `essim --batch --source`. + +namespace { + +std::string run_boot(const std::string &cmd) { + Tui t; + t.BootDispatch(cmd); + std::ostringstream oss; + t.DumpOutput(oss); + return oss.str(); +} + +} // namespace + +TEST_CASE("source: lines after a nested source still run") { + const char *inner = "test_src_inner.essim"; + const char *outer = "test_src_outer.essim"; + { + std::ofstream f(inner); + f << "new\n"; + } + { + std::ofstream f(outer); + f << "source " << inner << "\n" + "verify\n"; + } + + std::string out = run_boot(std::string("source ") + outer); + + // The inner script ran and was summarised… + CHECK(out.find("system created.") != std::string::npos); + CHECK(out.find(std::string("source: ") + inner) != std::string::npos); + // …and the OUTER script kept going after it: verify executed… + CHECK(out.find("verify: 0 local mismatch(es)") != std::string::npos); + // …and the outer summary counts its 2 effective lines. + CHECK(out.find(std::string("source: ") + outer + " (2 line(s))") + != std::string::npos); + + std::remove(inner); + std::remove(outer); +} + +TEST_CASE("source: self-recursion stops at the depth guard") { + const char *loop = "test_src_loop.essim"; + { + std::ofstream f(loop); + f << "source " << loop << "\n"; + } + + std::string out = run_boot(std::string("source ") + loop); + + CHECK(out.find("source: nesting too deep, skipping") + != std::string::npos); + // Every frame still closes with its own summary. + CHECK(out.find(std::string("source: ") + loop + " (1 line(s))") + != std::string::npos); + + std::remove(loop); +}