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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -99,11 +99,16 @@ class Tui : public Frontend {
|
||||
// ---- 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> tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits.
|
||||
std::string loading_filename;
|
||||
std::vector<std::string> 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<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;
|
||||
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.
|
||||
|
||||
71
tests/tui/test_source.cpp
Normal file
71
tests/tui/test_source.cpp
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user