tui: fix nested source abandoning the calling script

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: <file> (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 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 20:32:36 +02:00
parent 0b10e1c1b7
commit dceb61237d
4 changed files with 123 additions and 27 deletions

View File

@@ -281,20 +281,29 @@ void Tui::Source(const std::string &filename) {
if (const char *home = std::getenv("HOME")) if (const char *home = std::getenv("HOME"))
expanded = std::string(home) + expanded.substr(1); 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); std::ifstream f(expanded);
if (!f) { Print("source failed: cannot open " + filename); return; } if (!f) { Print("source failed: cannot open " + filename); return; }
// Slurp the whole file so we can drive line-by-line processing from the // 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 // event loop (one line per posted task). This lets the screen redraw
// between lines and surface the "Computing…" modal. // between lines and surface the "Computing…" modal.
loading_lines.clear(); SourceFrame fr;
fr.filename = filename;
std::string line; 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; loading_prev_in_source = in_source;
source_origin_screen = screen_idx; // a sourced line that leaves this screen aborts source_origin_screen = screen_idx; // a sourced line that leaves this screen aborts
in_source = true; in_source = true;
@@ -332,9 +341,18 @@ void Tui::Source(const std::string &filename) {
void Tui::ProcessNextSourceLine() { void Tui::ProcessNextSourceLine() {
if (!loading.load()) return; if (!loading.load()) return;
while (loading_idx < loading_lines.size()) { while (!source_stack.empty()) {
const std::string &raw = loading_lines[loading_idx++]; if (source_stack.back().idx >= source_stack.back().lines.size()) {
++loading_lineno; // 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"); size_t start = raw.find_first_not_of(" \t");
if (start == std::string::npos) continue; if (start == std::string::npos) continue;
if (raw[start] == '#') continue; if (raw[start] == '#') continue;
@@ -343,28 +361,26 @@ void Tui::ProcessNextSourceLine() {
trimmed.pop_back(); trimmed.pop_back();
if (trimmed.empty()) continue; 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; input = trimmed;
cursor_pos = (int)input.size(); cursor_pos = (int)input.size();
Submit(); Submit();
++loading_executed;
if (screen_idx != source_origin_screen) { 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."); + " is interactive (would open a screen) — aborting.");
screen_idx = source_origin_screen; screen_idx = source_origin_screen;
loading.store(false); source_stack.clear(); // an abort cancels the whole chain
computing_open = false; break;
tick_in_flight.store(false);
in_source = loading_prev_in_source;
return;
} }
// One effective line per tick — ack so the ticker can pace the next. // One effective line per tick — ack so the ticker can pace the next.
tick_in_flight.store(false); tick_in_flight.store(false);
return; return;
} }
Print("source: " + loading_filename // Stack drained (or aborted): close up.
+ " (" + std::to_string(loading_executed) + " line(s))");
loading.store(false); loading.store(false);
computing_open = false; computing_open = false;
tick_in_flight.store(false); tick_in_flight.store(false);

View File

@@ -12,7 +12,6 @@ using namespace ftxui;
Tui::Tui() Tui::Tui()
: cursor_pos(0), history_idx(-1), scroll_offset(0), quit(false), in_source(false), : cursor_pos(0), history_idx(-1), scroll_offset(0), quit(false), in_source(false),
loading(false), tick_in_flight(false), loading(false), tick_in_flight(false),
loading_idx(0), loading_executed(0), loading_lineno(0),
loading_prev_in_source(false), screen_ptr(nullptr), loading_prev_in_source(false), screen_ptr(nullptr),
screen_idx(4), // boot to the dashboard; console (screen 0) is now a sub-screen screen_idx(4), // boot to the dashboard; console (screen 0) is now a sub-screen
connect_m1_idx(0), connect_m2_idx(0), 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 // script is opened from the dashboard. The Renderer re-reads the live
// progress every frame. // progress every frame.
auto computing_modal = Renderer([this] { auto computing_modal = Renderer([this] {
std::string progress = std::to_string(loading_executed) + " / " std::string fname, progress;
+ std::to_string((int)loading_lines.size()) + " lines"; 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({ return vbox({
text(" Computing… ") | bold | center, text(" Computing… ") | bold | center,
separator(), separator(),
text(loading_filename) | center, text(fname) | center,
text(progress) | center, text(progress) | center,
}) | borderDouble | size(WIDTH, GREATER_THAN, 40); }) | borderDouble | size(WIDTH, GREATER_THAN, 40);
}); });

View File

@@ -99,11 +99,16 @@ class Tui : public Frontend {
// ---- Source-file loading (event-driven, one line per tick) ---- // ---- Source-file loading (event-driven, one line per tick) ----
std::atomic<bool> loading; ///< true while a script is being processed; read by tick thread. std::atomic<bool> loading; ///< true while a script is being processed; read by tick thread.
std::atomic<bool> tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits. std::atomic<bool> tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits.
std::string loading_filename; // One script being processed. Nested `source` pushes a frame so the
std::vector<std::string> loading_lines; // caller resumes where it left off — the stack IS the call chain.
size_t loading_idx; struct SourceFrame {
int loading_executed; std::string filename;
int loading_lineno; std::vector<std::string> 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<SourceFrame> source_stack;
bool loading_prev_in_source; 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. 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. bool computing_open = false; ///< drives the global "Computing…" progress modal while a script loads.

71
tests/tui/test_source.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include <doctest/doctest.h>
#include "frontends/tui/tui.hpp"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
// 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);
}