diff --git a/src/libcmd/installable-attr-path.cc b/src/libcmd/installable-attr-path.cc index aa5da2e56646..425ffb3c7a27 100644 --- a/src/libcmd/installable-attr-path.cc +++ b/src/libcmd/installable-attr-path.cc @@ -1,4 +1,5 @@ #include "nix/cmd/installable-attr-path.hh" +#include "nix/util/eval-context.hh" #include "nix/store/outputs-spec.hh" #include "nix/util/util.hh" #include "nix/cmd/command.hh" @@ -36,6 +37,7 @@ std::pair InstallableAttrPath::toValue(EvalState & state) DerivedPathsWithInfo InstallableAttrPath::toDerivedPaths() { + EvalContextGuard ctx(fmt("during evaluation of attribute '%s'", attrPath)); auto [v, pos] = toValue(*state); if (std::optional derivedPathWithInfo = diff --git a/src/libcmd/installable-flake.cc b/src/libcmd/installable-flake.cc index 1ef34a1ce014..25f6f90bec0d 100644 --- a/src/libcmd/installable-flake.cc +++ b/src/libcmd/installable-flake.cc @@ -1,4 +1,5 @@ #include "nix/cmd/installable-flake.hh" +#include "nix/util/eval-context.hh" #include "nix/store/outputs-spec.hh" #include "nix/util/util.hh" #include "nix/cmd/command.hh" @@ -66,6 +67,7 @@ InstallableFlake::InstallableFlake( DerivedPathsWithInfo InstallableFlake::toDerivedPaths() { + EvalContextGuard ctx(fmt("during evaluation of installable '%s'", what())); Activity act(*logger, lvlTalkative, actUnknown, fmt("evaluating derivation '%s'", what())); auto attr = getCursor(*state); diff --git a/src/libcmd/installables.cc b/src/libcmd/installables.cc index a25971ad7f89..95707a974fba 100644 --- a/src/libcmd/installables.cc +++ b/src/libcmd/installables.cc @@ -1,5 +1,6 @@ #include "nix/store/globals.hh" #include "nix/cmd/installables.hh" +#include "nix/util/eval-context.hh" #include "nix/cmd/installable-derived-path.hh" #include "nix/cmd/installable-attr-path.hh" #include "nix/cmd/installable-flake.hh" @@ -461,6 +462,10 @@ Installables SourceExprCommand::parseInstallables(ref store, std::vector< auto state = getEvalState(); auto vFile = state->allocValue(); + EvalContextGuard ctx( + expr ? fmt("during evaluation of expression '%s'", *expr) + : fmt("during evaluation of file '%s'", file->string())); + if (file == "-") { auto e = state->parseStdin(); state->eval(e, *vFile); diff --git a/src/libcmd/repl.cc b/src/libcmd/repl.cc index f7833ea3a478..ea621f3eee45 100644 --- a/src/libcmd/repl.cc +++ b/src/libcmd/repl.cc @@ -4,6 +4,7 @@ #include #include "nix/util/error.hh" +#include "nix/util/eval-context.hh" #include "nix/cmd/repl-interacter.hh" #include "nix/cmd/repl.hh" @@ -688,6 +689,7 @@ ProcessLineResult NixRepl::processLine(std::string line) void NixRepl::loadFile(const std::filesystem::path & path) { + EvalContextGuard ctx(fmt("during loading of '%s'", path.string())); Value v, v2; state->evalFile(lookupFileArg(*state, path.string()), v); state->autoCallFunction(*autoArgs, v, v2); @@ -905,6 +907,7 @@ ExprAttrs * NixRepl::parseReplBindings(std::string s) void NixRepl::evalString(std::string s, Value & v) { + EvalContextGuard ctx(fmt("during evaluation of REPL expression '%s'", s)); Expr * e = parseString(s); e->eval(*state, *env, v); state->forceValue(v, v.determinePos(noPos)); diff --git a/src/libexpr-tests/eval-traces.cc b/src/libexpr-tests/eval-traces.cc new file mode 100644 index 000000000000..0f0e80b6d522 --- /dev/null +++ b/src/libexpr-tests/eval-traces.cc @@ -0,0 +1,444 @@ +#include +#include + +#include "nix/expr/tests/libexpr.hh" +#include "nix/util/error.hh" +#include "nix/util/position.hh" +#include "nix/util/terminal.hh" + +#include + +namespace nix { + +using namespace ::testing; + +/** + * Test fixture for evaluating Nix expressions and structurally inspecting + * the resulting error traces. + * + * The traces on a BaseError represent the evaluation spine — a single path + * in the dependency graph between expressions. This fixture provides helpers + * to assert the presence, absence, and ordering of trace frames without + * relying on full string matching of rendered output. + */ +class EvalTraceTest : public LibExprTest +{ +protected: + /** + * A simplified view of one trace frame, for easy assertion. + * Strips ANSI escapes so tests are rendering-independent. + */ + struct Frame + { + std::string hint; + bool hasPos; + TracePrint print; + }; + + /** + * Evaluate a Nix expression that is expected to fail, + * and return the structured trace frames from the caught error. + */ + std::vector evalTraces(const std::string & expr) + { + try { + eval(expr); + ADD_FAILURE() << "Expected evaluation of `" << expr << "` to throw"; + return {}; + } catch (BaseError & e) { + return extractFrames(e); + } + } + + /** + * Evaluate a Nix expression that is expected to fail, + * and return the ErrorInfo for direct inspection. + */ + ErrorInfo evalErrorInfo(const std::string & expr) + { + try { + eval(expr); + ADD_FAILURE() << "Expected evaluation of `" << expr << "` to throw"; + return ErrorInfo{.level = lvlError, .msg = HintFmt("no error")}; + } catch (BaseError & e) { + return e.info(); + } + } + + /** + * Extract Frame structs from a caught error. + */ + static std::vector extractFrames(const BaseError & e) + { + std::vector frames; + for (const auto & t : e.info().traces) { + frames.push_back(Frame{ + .hint = filterANSIEscapes(t.hint.str(), true), + .hasPos = t.pos && *t.pos, + .print = t.print, + }); + } + return frames; + } + + /** + * Extract just the hint strings from frames, for concise assertions. + */ + static std::vector hints(const std::vector & frames) + { + std::vector out; + for (auto & f : frames) + out.push_back(f.hint); + return out; + } + + /** + * Check if any frame's hint contains the given substring. + */ + static bool hasTraceContaining(const std::vector & frames, const std::string & substr) + { + for (auto & f : frames) { + if (f.hint.find(substr) != std::string::npos) + return true; + } + return false; + } + + /** + * Return the index of the first frame whose hint contains `substr`, + * or -1 if not found. + */ + static int findTrace(const std::vector & frames, const std::string & substr) + { + for (size_t i = 0; i < frames.size(); i++) { + if (frames[i].hint.find(substr) != std::string::npos) + return static_cast(i); + } + return -1; + } + + /** + * Render an ErrorInfo to a plain string (ANSI stripped), + * for verifying the final output layout. + */ + static std::string renderError(const ErrorInfo & einfo, bool showTrace) + { + std::ostringstream oss; + showErrorInfo(oss, einfo, showTrace); + return filterANSIEscapes(oss.str(), true); + } + + /** + * Find the position of a substring in rendered output, or std::string::npos. + */ + static size_t findInOutput(const std::string & output, const std::string & substr) + { + return output.find(substr); + } +}; + +// --- Basic trace presence/absence --- + +TEST_F(EvalTraceTest, throwProducesBuiltinTrace) +{ + auto frames = evalTraces("throw \"oops\""); + // `throw` is a builtin, so it adds a "while calling the 'throw' builtin" trace + EXPECT_TRUE(hasTraceContaining(frames, "throw")); +} + +TEST_F(EvalTraceTest, addErrorContextAppearsInTrace) +{ + auto frames = evalTraces(R"( + builtins.addErrorContext "my context" (throw "inner") + )"); + EXPECT_TRUE(hasTraceContaining(frames, "my context")); + + // addErrorContext traces should have TracePrint::Always + for (auto & f : frames) { + if (f.hint.find("my context") != std::string::npos) { + EXPECT_EQ(f.print, TracePrint::Always); + } + } +} + +TEST_F(EvalTraceTest, nestedAddErrorContextOrder) +{ + auto frames = evalTraces(R"( + builtins.addErrorContext "outer context" + (builtins.addErrorContext "inner context" + (throw "deep")) + )"); + + int outerIdx = findTrace(frames, "outer context"); + int innerIdx = findTrace(frames, "inner context"); + ASSERT_NE(outerIdx, -1) << "outer context not found in traces"; + ASSERT_NE(innerIdx, -1) << "inner context not found in traces"; + + // Record the actual order — this is what we want to track. + // addTrace uses push_front, and addErrorContext catches & rethrows, + // so the outermost context ends up first in the list. + EXPECT_LT(outerIdx, innerIdx) + << "outer context (idx=" << outerIdx << ") should appear before inner context (idx=" << innerIdx << ")"; +} + +// --- Function call traces --- + +TEST_F(EvalTraceTest, functionCallTypeError) +{ + auto frames = evalTraces(R"( + let f = x: x + "not a number"; in f 42 + )"); + // This produces a type error from the + operator + // The error message is in ErrorInfo.msg; traces may or may not be present + // depending on the evaluation path +} + +// --- Traces through let bindings --- + +TEST_F(EvalTraceTest, letBindingTrace) +{ + auto frames = evalTraces(R"( + let x = throw "in let"; in x + )"); + // A throw inside a let binding produces a trace when the binding is forced + EXPECT_TRUE(hasTraceContaining(frames, "throw")); +} + +// --- computeTraceDisplay integration --- + +TEST_F(EvalTraceTest, displayEventsFromRealError) +{ + // Evaluate something that produces traces, then feed to computeTraceDisplay + auto info = evalErrorInfo(R"( + builtins.addErrorContext "ctx1" + (builtins.addErrorContext "ctx2" + (1 + "a")) + )"); + + // Without --show-trace: should include TracePrint::Always frames + auto eventsNoTrace = computeTraceDisplay(info.traces, false); + bool hasCtx1 = false, hasCtx2 = false; + for (auto & ev : eventsNoTrace) { + if (ev.kind == TraceEvent::Print) { + auto hint = filterANSIEscapes(ev.trace->hint.str(), true); + if (hint.find("ctx1") != std::string::npos) hasCtx1 = true; + if (hint.find("ctx2") != std::string::npos) hasCtx2 = true; + } + } + EXPECT_TRUE(hasCtx1) << "addErrorContext 'ctx1' should appear even without --show-trace"; + EXPECT_TRUE(hasCtx2) << "addErrorContext 'ctx2' should appear even without --show-trace"; + + // With --show-trace: should include everything + auto eventsWithTrace = computeTraceDisplay(info.traces, true); + EXPECT_GE(eventsWithTrace.size(), eventsNoTrace.size()) + << "--show-trace should show at least as many events"; +} + +TEST_F(EvalTraceTest, displayTruncationOnDeepTrace) +{ + // A deeply nested expression should produce enough traces to trigger truncation + // Build: f (f (f (... (throw "deep") ...))) + std::string expr = "throw \"deep\""; + for (int i = 0; i < 20; i++) { + expr = "builtins.addErrorContext \"level " + std::to_string(i) + "\" (" + expr + ")"; + } + + auto info = evalErrorInfo(expr); + + auto eventsNoTrace = computeTraceDisplay(info.traces, false); + auto eventsWithTrace = computeTraceDisplay(info.traces, true); + + // All 20 context levels should appear with --show-trace + for (int i = 0; i < 20; i++) { + bool found = false; + for (auto & ev : eventsWithTrace) { + if (ev.kind == TraceEvent::Print) { + auto hint = filterANSIEscapes(ev.trace->hint.str(), true); + if (hint.find("level " + std::to_string(i)) != std::string::npos) { + found = true; + break; + } + } + } + EXPECT_TRUE(found) << "level " << i << " should appear with --show-trace"; + } +} + +// --- Trace ordering with real evaluation --- + +TEST_F(EvalTraceTest, traceOrderReflectsEvalSpine) +{ + auto frames = evalTraces(R"( + builtins.addErrorContext "A" + (builtins.addErrorContext "B" + (builtins.addErrorContext "C" + (throw "end"))) + )"); + + int a = findTrace(frames, "A"); + int b = findTrace(frames, "B"); + int c = findTrace(frames, "C"); + + // All three should be present + ASSERT_NE(a, -1) << "context A not found"; + ASSERT_NE(b, -1) << "context B not found"; + ASSERT_NE(c, -1) << "context C not found"; + + // Traces from addErrorContext use push_front on catch/rethrow, + // so the outermost context appears first in the list. + EXPECT_LT(a, b) << "A (outermost) should appear before B"; + EXPECT_LT(b, c) << "B should appear before C (innermost)"; +} + +// ---- Rendered output layout: "read from the end" ---- +// +// The rendered error output should be readable from the bottom: +// error: +// … outer trace +// … inner trace +// error: actual error message +// +// This means: +// 1. The error message appears AFTER all traces +// 2. Outer traces appear BEFORE inner traces +// 3. Truncation message (if any) appears between traces and message + +TEST_F(EvalTraceTest, renderedOutputMessageAfterTraces) +{ + auto info = evalErrorInfo(R"( + builtins.addErrorContext "my context" (throw "the real error") + )"); + + auto output = renderError(info, true); + + auto posCtx = findInOutput(output, "my context"); + auto posMsg = findInOutput(output, "the real error"); + + ASSERT_NE(posCtx, std::string::npos) << "context not found in output:\n" << output; + ASSERT_NE(posMsg, std::string::npos) << "error message not found in output:\n" << output; + + EXPECT_LT(posCtx, posMsg) + << "Trace context should appear BEFORE the error message (read from end).\nOutput:\n" << output; +} + +TEST_F(EvalTraceTest, renderedOutputOuterBeforeInner) +{ + auto info = evalErrorInfo(R"( + builtins.addErrorContext "OUTER" + (builtins.addErrorContext "INNER" + (1 + "a")) + )"); + + auto output = renderError(info, true); + + auto posOuter = findInOutput(output, "OUTER"); + auto posInner = findInOutput(output, "INNER"); + auto posMsg = findInOutput(output, "cannot add"); + + ASSERT_NE(posOuter, std::string::npos) << "OUTER not found in output:\n" << output; + ASSERT_NE(posInner, std::string::npos) << "INNER not found in output:\n" << output; + ASSERT_NE(posMsg, std::string::npos) << "error msg not found in output:\n" << output; + + // Layout: OUTER ... INNER ... error message + EXPECT_LT(posOuter, posInner) + << "Outer trace should appear before inner trace.\nOutput:\n" << output; + EXPECT_LT(posInner, posMsg) + << "Inner trace should appear before error message.\nOutput:\n" << output; +} + +TEST_F(EvalTraceTest, renderedOutputTruncationBeforeMessage) +{ + // addErrorContext uses TracePrint::Always so it bypasses truncation. + // To test truncation, we need traces with TracePrint::Default. + // Build an ErrorInfo manually with many Default traces that have positions. + auto pos = std::make_shared(1, 1, Pos::String{make_ref("test.nix")}); + ErrorInfo info{ + .level = lvlError, + .msg = HintFmt("%s", "the actual error"), + .pos = {}, + .traces = {}, + }; + for (int i = 0; i < 10; i++) { + info.traces.push_back(Trace{ + .pos = pos, + .hint = HintFmt("%s", "trace " + std::to_string(i)), + .print = TracePrint::Default, + }); + } + + auto output = renderError(info, false); + + auto posTrunc = findInOutput(output, "stack trace truncated"); + auto posMsg = findInOutput(output, "the actual error"); + + ASSERT_NE(posTrunc, std::string::npos) << "truncation message not found in output:\n" << output; + ASSERT_NE(posMsg, std::string::npos) << "error msg not found in output:\n" << output; + + // Truncation should appear before the error message + EXPECT_LT(posTrunc, posMsg) + << "Truncation message should appear before the error message.\nOutput:\n" << output; + + // The first trace is promoted to the summary on the "error:" line, + // so it appears before the truncation message. The remaining kept + // traces (prefixed with "… ") should appear after truncation. + auto posEllipsisTrace = findInOutput(output, "\xe2\x80\xa6 trace"); + if (posEllipsisTrace != std::string::npos) { + EXPECT_LT(posTrunc, posEllipsisTrace) + << "Truncation message should appear before remaining traces.\nOutput:\n" << output; + } +} + +TEST_F(EvalTraceTest, renderedOutputShowTraceShowsMore) +{ + // Same as above: use TracePrint::Default traces to trigger truncation + auto pos = std::make_shared(1, 1, Pos::String{make_ref("test.nix")}); + ErrorInfo info{ + .level = lvlError, + .msg = HintFmt("%s", "the error"), + .pos = {}, + .traces = {}, + }; + for (int i = 0; i < 10; i++) { + info.traces.push_back(Trace{ + .pos = pos, + .hint = HintFmt("%s", "trace " + std::to_string(i)), + .print = TracePrint::Default, + }); + } + + auto withoutTrace = renderError(info, false); + auto withTrace = renderError(info, true); + + // --show-trace output should be longer (more trace frames) + EXPECT_GT(withTrace.size(), withoutTrace.size()) + << "--show-trace should produce more output"; + + // Without --show-trace: should have truncation message + EXPECT_NE(findInOutput(withoutTrace, "stack trace truncated"), std::string::npos); + + // With --show-trace: should NOT have truncation message + EXPECT_EQ(findInOutput(withTrace, "stack trace truncated"), std::string::npos); +} + +TEST_F(EvalTraceTest, renderedOutputErrorPrefixAtTop) +{ + auto info = evalErrorInfo(R"( + builtins.addErrorContext "ctx" (throw "boom") + )"); + + auto output = renderError(info, true); + + // The output should start with "error:" + auto firstError = findInOutput(output, "error:"); + EXPECT_EQ(firstError, 0u) + << "Output should start with 'error:' prefix.\nOutput:\n" << output; + + // The trace should appear after the first "error:" but the actual message + // should appear after the traces (at a later "error:" prefix) + auto posCtx = findInOutput(output, "ctx"); + ASSERT_NE(posCtx, std::string::npos); + EXPECT_GT(posCtx, firstError) + << "Traces should appear after the initial error: prefix"; +} + +} // namespace nix diff --git a/src/libexpr-tests/meson.build b/src/libexpr-tests/meson.build index 50d158209ba7..410636397f45 100644 --- a/src/libexpr-tests/meson.build +++ b/src/libexpr-tests/meson.build @@ -50,6 +50,7 @@ sources = files( 'derived-path.cc', 'error_traces.cc', 'eval.cc', + 'eval-traces.cc', 'json.cc', 'lazy-fetcher-attr.cc', 'main.cc', diff --git a/src/libutil-tests/error-traces.cc b/src/libutil-tests/error-traces.cc new file mode 100644 index 000000000000..d176351007ba --- /dev/null +++ b/src/libutil-tests/error-traces.cc @@ -0,0 +1,481 @@ +#include +#include + +#include "nix/util/error.hh" +#include "nix/util/position.hh" +#include "nix/util/terminal.hh" + +#include + +namespace nix { + +namespace { + +/// Helper: make a trace without a position +Trace nopos(std::string msg, TracePrint print = TracePrint::Default) +{ + return Trace{.pos = nullptr, .hint = HintFmt("%s", msg), .print = print}; +} + +/// Helper: make a trace that is considered to "have a position" +/// by the hasPos predicate we pass to computeTraceDisplay. +Trace withpos(std::string msg, TracePrint print = TracePrint::Default) +{ + // We use a tag object; the actual Pos content doesn't matter because + // we supply a custom hasPos predicate in the tests. + auto pos = std::make_shared(1, 1, Pos::String{make_ref("test")}); + return Trace{.pos = std::move(pos), .hint = HintFmt("%s", msg), .print = print}; +} + +/// Predicate matching production behavior: pos && *pos +bool prodHasPos(const Trace & t) +{ + return t.pos && *t.pos; +} + +/// Extract the printed trace hints in order +std::vector printedHints(const std::vector & events) +{ + std::vector out; + for (auto & e : events) + if (e.kind == TraceEvent::Print) + out.push_back(filterANSIEscapes(e.trace->hint.str(), true)); + return out; +} + +/// Check whether the event list contains a Truncated event +bool hasTruncated(const std::vector & events) +{ + for (auto & e : events) + if (e.kind == TraceEvent::Truncated) + return true; + return false; +} + +/// Check whether the event list contains a DuplicatesOmitted event +bool hasDuplicatesOmitted(const std::vector & events) +{ + for (auto & e : events) + if (e.kind == TraceEvent::DuplicatesOmitted) + return true; + return false; +} + +} // namespace + +// ---- Empty / trivial cases ---- + +TEST(ErrorTrace, emptyTraces) +{ + std::list traces; + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_TRUE(events.empty()); +} + +TEST(ErrorTrace, emptyHintsSkipped) +{ + std::list traces; + traces.push_back(Trace{.pos = nullptr, .hint = HintFmt("")}); + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_TRUE(events.empty()); +} + +// ---- Ordering ---- + +TEST(ErrorTrace, orderPreserved) +{ + std::list traces; + traces.push_back(nopos("first")); + traces.push_back(nopos("second")); + traces.push_back(nopos("third")); + + auto hints = printedHints(computeTraceDisplay(traces, true, prodHasPos)); + ASSERT_EQ(hints.size(), 3u); + EXPECT_EQ(hints[0], "first"); + EXPECT_EQ(hints[1], "second"); + EXPECT_EQ(hints[2], "third"); +} + +TEST(ErrorTrace, orderPreservedWithPositions) +{ + std::list traces; + traces.push_back(withpos("A")); + traces.push_back(nopos("B")); + traces.push_back(withpos("C")); + + auto hints = printedHints(computeTraceDisplay(traces, true, prodHasPos)); + ASSERT_EQ(hints.size(), 3u); + EXPECT_EQ(hints[0], "A"); + EXPECT_EQ(hints[1], "B"); + EXPECT_EQ(hints[2], "C"); +} + +// ---- Truncation ---- + +TEST(ErrorTrace, noTruncationWithShowTrace) +{ + std::list traces; + for (int i = 0; i < 20; i++) + traces.push_back(withpos("trace " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, true, prodHasPos); + EXPECT_FALSE(hasTruncated(events)); + EXPECT_EQ(printedHints(events).size(), 20u); +} + +TEST(ErrorTrace, noTruncationUnderLimit) +{ + // 3 traces with positions → count reaches 3, but truncation triggers at > 3 + std::list traces; + for (int i = 0; i < 3; i++) + traces.push_back(withpos("trace " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_FALSE(hasTruncated(events)); + EXPECT_EQ(printedHints(events).size(), 3u); +} + +TEST(ErrorTrace, truncationAtLimit) +{ + // 6 traces with positions. Truncation keeps the inner (last) 3-4 traces + // and drops the outer (first) ones. The Truncated event appears first. + std::list traces; + for (int i = 0; i < 6; i++) + traces.push_back(withpos("trace " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_TRUE(hasTruncated(events)); + auto hints = printedHints(events); + // Inner traces (near the error) should be kept + EXPECT_THAT(hints, ::testing::Contains("trace 5")); + // Outer traces should be truncated + EXPECT_THAT(hints, ::testing::Not(::testing::Contains("trace 0"))); + // Truncated event should be first + ASSERT_FALSE(events.empty()); + EXPECT_EQ(events.front().kind, TraceEvent::Truncated); +} + +TEST(ErrorTrace, tracesWithoutPositionsDontCountTowardLimit) +{ + // Many traces without positions should NOT trigger truncation + std::list traces; + for (int i = 0; i < 20; i++) + traces.push_back(nopos("trace " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_FALSE(hasTruncated(events)); + EXPECT_EQ(printedHints(events).size(), 20u); +} + +// ---- TracePrint::Always bypasses truncation ---- + +TEST(ErrorTrace, alwaysPrintBypassesTruncation) +{ + // TracePrint::Always traces in the truncated (outer) region still appear + std::list traces; + traces.push_back(nopos("always at top", TracePrint::Always)); + for (int i = 0; i < 10; i++) + traces.push_back(withpos("default " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + EXPECT_TRUE(hasTruncated(events)); + auto hints = printedHints(events); + EXPECT_THAT(hints, ::testing::Contains("always at top")); +} + +TEST(ErrorTrace, alwaysPrintPreservesOrderAmongOtherAlways) +{ + // Two Always traces in the outer (truncated) region preserve their relative order + std::list traces; + traces.push_back(nopos("ctx A", TracePrint::Always)); + traces.push_back(nopos("ctx B", TracePrint::Always)); + for (int i = 0; i < 10; i++) + traces.push_back(withpos("filler " + std::to_string(i))); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + auto hints = printedHints(events); + auto posA = std::find(hints.begin(), hints.end(), "ctx A"); + auto posB = std::find(hints.begin(), hints.end(), "ctx B"); + ASSERT_NE(posA, hints.end()); + ASSERT_NE(posB, hints.end()); + EXPECT_LT(std::distance(hints.begin(), posA), std::distance(hints.begin(), posB)); +} + +// ---- Deduplication ---- + +TEST(ErrorTrace, fewDuplicatesNotOmitted) +{ + std::list traces; + traces.push_back(nopos("unique")); + // 3 duplicates of "unique" → ≤5, so they are printed individually + for (int i = 0; i < 3; i++) + traces.push_back(nopos("unique")); + + auto events = computeTraceDisplay(traces, true, prodHasPos); + EXPECT_FALSE(hasDuplicatesOmitted(events)); + // 1 original + 3 duplicates = 4 printed + EXPECT_EQ(printedHints(events).size(), 4u); +} + +TEST(ErrorTrace, manyDuplicatesOmitted) +{ + std::list traces; + traces.push_back(nopos("recursive")); + for (int i = 0; i < 10; i++) + traces.push_back(nopos("recursive")); + + auto events = computeTraceDisplay(traces, true, prodHasPos); + EXPECT_TRUE(hasDuplicatesOmitted(events)); + // Only 1 printed (the first unique one), rest omitted + EXPECT_EQ(printedHints(events).size(), 1u); + // The omitted count should be 10 + for (auto & e : events) { + if (e.kind == TraceEvent::DuplicatesOmitted) { + EXPECT_EQ(e.count, 10u); + } + } +} + +TEST(ErrorTrace, mutualRecursionDedupResets) +{ + // Pattern: 1×A, 9×A(dup), 1×B, 9×B(dup), 1×A, 9×A(dup) + // After dedup of the first A block, tracesSeen is cleared, + // so the second A block gets its own dedup chunk. + std::list traces; + for (int i = 0; i < 10; i++) + traces.push_back(nopos("A")); + for (int i = 0; i < 10; i++) + traces.push_back(nopos("B")); + for (int i = 0; i < 10; i++) + traces.push_back(nopos("A")); + + auto events = computeTraceDisplay(traces, true, prodHasPos); + + // Should see: Print(A), DuplicatesOmitted(9), Print(B), DuplicatesOmitted(9), Print(A), DuplicatesOmitted(9) + auto hints = printedHints(events); + EXPECT_EQ(hints.size(), 3u); + EXPECT_EQ(hints[0], "A"); + EXPECT_EQ(hints[1], "B"); + EXPECT_EQ(hints[2], "A"); + + size_t dedupCount = 0; + for (auto & e : events) + if (e.kind == TraceEvent::DuplicatesOmitted) + dedupCount++; + EXPECT_EQ(dedupCount, 3u); +} + +// ---- Combined behaviors ---- + +TEST(ErrorTrace, truncationAndDeduplicationInteract) +{ + // With showTrace=false, outer traces are truncated and inner traces kept. + std::list traces; + // 5 outer traces with positions (will be truncated) + for (int i = 0; i < 5; i++) + traces.push_back(withpos("head " + std::to_string(i))); + // Then many without positions (don't count toward limit) + for (int i = 0; i < 10; i++) + traces.push_back(nopos("repeated")); + // 2 inner traces with positions (should be kept) + traces.push_back(withpos("tail A")); + traces.push_back(withpos("tail B")); + + auto events = computeTraceDisplay(traces, false, prodHasPos); + auto hints = printedHints(events); + // Inner traces should be kept + EXPECT_THAT(hints, ::testing::Contains("tail A")); + EXPECT_THAT(hints, ::testing::Contains("tail B")); +} + +// ---- Golden-string rendered output tests ---- +// +// These pin the exact rendered output (ANSI-stripped) for a few key scenarios. +// They catch any formatting change, intentional or not. + +namespace { + +std::string renderError(const ErrorInfo & einfo, bool showTrace) +{ + std::ostringstream oss; + showErrorInfo(oss, einfo, showTrace); + return filterANSIEscapes(oss.str(), true); +} + +ErrorInfo makeErrorInfo(std::string msg, std::list traces) +{ + return ErrorInfo{ + .level = lvlError, + .msg = HintFmt("%s", msg), + .pos = {}, + .traces = std::move(traces), + }; +} + +} // namespace + +TEST(ErrorTraceGolden, noTraces) +{ + auto output = renderError(makeErrorInfo("type error: expected int, got string", {}), false); + EXPECT_EQ(output, "error: type error: expected int, got string"); +} + +TEST(ErrorTraceGolden, threeTracesNoTruncation) +{ + auto pos = std::make_shared(42, 1, Pos::String{make_ref("myfile.nix")}); + std::list traces; + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "while evaluating the attribute 'x'")}); + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "while calling 'add'")}); + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "while evaluating 'add 1 \"a\"'")}); + + auto output = renderError(makeErrorInfo("cannot coerce a string to an integer", std::move(traces)), false); + EXPECT_EQ(output, + "error: while evaluating the attribute 'x', at \xc2\xabstring\xc2\xbb:42:1\n" + "\n" + " \xe2\x80\xa6 while evaluating the attribute 'x'\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 while calling 'add'\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 while evaluating 'add 1 \"a\"'\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " error: cannot coerce a string to an integer"); +} + +TEST(ErrorTraceGolden, sixTracesWithTruncation) +{ + auto pos = std::make_shared(42, 1, Pos::String{make_ref("myfile.nix")}); + std::list traces; + for (int i = 1; i <= 6; i++) + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "trace " + std::to_string(i))}); + + // Without --show-trace: outer traces truncated, inner kept + auto truncated = renderError(makeErrorInfo("the error", traces), false); + EXPECT_EQ(truncated, + "error: trace 1, at \xc2\xabstring\xc2\xbb:42:1\n" + "\n" + " (stack trace truncated; use '--show-trace' to show the full, detailed trace)\n" + "\n" + " \xe2\x80\xa6 trace 4\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 5\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 6\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " error: the error"); + + // With --show-trace: all traces shown, no truncation + auto full = renderError(makeErrorInfo("the error", traces), true); + EXPECT_EQ(full, + "error: trace 1, at \xc2\xabstring\xc2\xbb:42:1\n" + "\n" + " \xe2\x80\xa6 trace 1\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 2\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 3\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 4\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 5\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " \xe2\x80\xa6 trace 6\n" + " at \xc2\xabstring\xc2\xbb:42:1:\n" + "\n" + " error: the error"); +} + +// --- EvalContext tests --- + +TEST(ErrorTraceContext, guardSetsContext) +{ + EvalContextGuard ctx("during evaluation of installable nixpkgs#hello"); + EXPECT_EQ(currentEvalContext(), "during evaluation of installable nixpkgs#hello"); +} + +TEST(ErrorTraceContext, guardRestoresOnDestruction) +{ + { + EvalContextGuard ctx("inner context"); + EXPECT_EQ(currentEvalContext(), "inner context"); + } + EXPECT_FALSE(currentEvalContext().has_value()); +} + +TEST(ErrorTraceContext, outerGuardWins) +{ + EvalContextGuard outer("outer"); + EXPECT_EQ(*currentEvalContext(), "outer"); + { + EvalContextGuard inner("inner"); + // Outermost guard wins — inner guard is a no-op + EXPECT_EQ(*currentEvalContext(), "outer"); + } + EXPECT_EQ(*currentEvalContext(), "outer"); +} + +TEST(ErrorTraceContext, errorStampsContext) +{ + EvalContextGuard ctx("during evaluation of «test»"); + try { + throw Error("something went wrong"); + } catch (Error & e) { + EXPECT_EQ(e.info().evalContext, "during evaluation of «test»"); + } +} + +TEST(ErrorTraceContext, noGuardMeansNoContext) +{ + try { + throw Error("something went wrong"); + } catch (Error & e) { + EXPECT_FALSE(e.info().evalContext.has_value()); + } +} + +TEST(ErrorTraceContext, errorInfoPreservesExistingContext) +{ + // If ErrorInfo already has evalContext, BaseError doesn't overwrite it + ErrorInfo ei{ + .level = lvlError, + .msg = HintFmt("msg"), + .pos = {}, + .evalContext = "pre-set context", + }; + EvalContextGuard ctx("guard context"); + try { + throw Error(ei); + } catch (Error & e) { + EXPECT_EQ(e.info().evalContext, "pre-set context"); + } +} + +TEST(ErrorTraceGolden, evalContextSummaryLine) +{ + // When evalContext is set, it becomes the summary instead of the first trace + auto pos = std::make_shared(42, 1, Pos::String{make_ref("myfile.nix")}); + std::list traces; + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "while evaluating the attribute 'x'")}); + traces.push_back(Trace{.pos = pos, .hint = HintFmt("%s", "while calling 'add'")}); + + auto einfo = makeErrorInfo("type error", std::move(traces)); + einfo.evalContext = "during evaluation of installable nixpkgs#hello"; + + auto output = renderError(einfo, false); + // Summary line uses evalContext, not the first trace + EXPECT_THAT(output, ::testing::StartsWith("error: during evaluation of installable nixpkgs#hello\n")); + // The outermost trace still appears in the trace list + EXPECT_THAT(output, ::testing::HasSubstr("while evaluating the attribute 'x'")); +} + +} // namespace nix diff --git a/src/libutil-tests/meson.build b/src/libutil-tests/meson.build index 943630f46461..83db73a8bb03 100644 --- a/src/libutil-tests/meson.build +++ b/src/libutil-tests/meson.build @@ -67,6 +67,7 @@ sources = files( 'closure.cc', 'compression.cc', 'config.cc', + 'error-traces.cc', 'executable-path.cc', 'file-content-address.cc', 'file-descriptor.cc', diff --git a/src/libutil/error.cc b/src/libutil/error.cc index f43df1de5209..b3a6b43ddb9d 100644 --- a/src/libutil/error.cc +++ b/src/libutil/error.cc @@ -1,6 +1,7 @@ #include #include "nix/util/error.hh" +#include "nix/util/eval-context.hh" #include "nix/util/environment-variables.hh" #include "nix/util/exit.hh" #include "nix/util/signals.hh" @@ -49,6 +50,29 @@ bool BaseError::hasPos() const std::optional ErrorInfo::programName = std::nullopt; +static thread_local std::optional evalContextStr; + +const std::optional & currentEvalContext() +{ + return evalContextStr; +} + +EvalContextGuard::EvalContextGuard(std::string context) + : previous(evalContextStr) + , didSet(!evalContextStr.has_value()) +{ + // Only the outermost guard sets the context — inner guards are no-ops. + if (didSet) + evalContextStr = std::move(context); +} + +EvalContextGuard::~EvalContextGuard() +{ + // Always restore, so the context is unset once the outermost guard is destroyed. + if (didSet) + evalContextStr = std::move(previous); +} + std::ostream & operator<<(std::ostream & os, const HintFmt & hf) { return os << hf.str(); @@ -214,6 +238,104 @@ void printSkippedTracesMaybe( skippedTraces.clear(); } +std::vector computeTraceDisplay( + const std::list & traces, + bool showTrace, + std::function hasPos) +{ + if (!hasPos) + hasPos = [](const Trace & t) { return t.pos && *t.pos; }; + + std::vector events; + std::set tracesSeen; + std::vector skippedTraces; + bool truncate = false; + + auto flushSkipped = [&]() { + if (skippedTraces.empty()) + return; + if (skippedTraces.size() <= 5) { + for (auto * t : skippedTraces) { + events.push_back(TraceEvent{.kind = TraceEvent::Print, .trace = t}); + } + } else { + events.push_back(TraceEvent{.kind = TraceEvent::DuplicatesOmitted, .count = skippedTraces.size()}); + tracesSeen.clear(); + } + skippedTraces.clear(); + }; + + // When not showing the full trace, we truncate outer (earlier) frames + // and keep the inner (later) frames closest to the error. This way the + // output reads bottom-up: error message at the end, innermost context + // just above, and the truncation message at the top. + // + // To decide where to truncate, we walk the list from the inner end + // (back) and count how many positioned traces we'd keep. The first + // trace that would exceed the limit marks the truncation boundary. + size_t keepFrom = 0; // index in the trace list from which we start keeping + if (!showTrace) { + // Collect non-empty traces to index them + std::vector nonEmpty; + for (const auto & trace : traces) { + if (!trace.hint.str().empty()) + nonEmpty.push_back(&trace); + } + + // Walk from the inner end to find the cutoff + size_t count = 0; + size_t cutoff = 0; // how many traces from the end we keep + for (size_t i = nonEmpty.size(); i > 0; i--) { + auto * t = nonEmpty[i - 1]; + if (t->print == TracePrint::Always) { + cutoff = nonEmpty.size() - (i - 1); + continue; + } + if (hasPos(*t)) + count++; + if (count > 3) { + truncate = true; + break; + } + cutoff = nonEmpty.size() - (i - 1); + } + + if (truncate) { + keepFrom = nonEmpty.size() - cutoff; + } + } + + if (truncate) { + events.push_back(TraceEvent{.kind = TraceEvent::Truncated}); + } + + // Now emit the kept traces, handling deduplication + size_t idx = 0; + for (const auto & trace : traces) { + if (trace.hint.str().empty()) + continue; + + bool keep = !truncate || idx >= keepFrom || trace.print == TracePrint::Always; + idx++; + + if (keep) { + if (tracesSeen.count(trace)) { + skippedTraces.push_back(&trace); + continue; + } + + flushSkipped(); + tracesSeen.insert(trace); + + events.push_back(TraceEvent{.kind = TraceEvent::Print, .trace = &trace}); + } + } + + flushSkipped(); + + return events; +} + std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool showTrace) { std::string prefix; @@ -364,46 +486,44 @@ std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool s auto ellipsisIndent = " "; if (!einfo.traces.empty()) { - // Stack traces seen since we last printed a chunk of `duplicate frames - // omitted`. - std::set tracesSeen; - // A consecutive sequence of stack traces that are all in `tracesSeen`. - std::vector skippedTraces; - size_t count = 0; - bool truncate = false; - - for (const auto & trace : einfo.traces) { - if (trace.hint.str().empty()) - continue; - - if (!showTrace && count > 3) { - truncate = true; - } - - if (!truncate || trace.print == TracePrint::Always) { - - if (tracesSeen.count(trace)) { - skippedTraces.push_back(trace); - continue; + auto events = computeTraceDisplay(einfo.traces, showTrace, [](const Trace & t) { + return t.pos && *t.pos; + }); + + // Use evalContext if available, otherwise fall back to the outermost + // trace hint. This provides a high-level summary on the "error:" line, + // e.g. "error: during evaluation of installable nixpkgs#hello" + if (einfo.evalContext) { + oss << *einfo.evalContext << "\n"; + } else { + for (const auto & trace : einfo.traces) { + if (!trace.hint.str().empty()) { + oss << trace.hint.str(); + if (trace.pos && *trace.pos) + oss << ", " ANSI_BLUE "at " ANSI_WARNING << *trace.pos << ANSI_NORMAL; + oss << "\n"; + break; } - - tracesSeen.insert(trace); - - printSkippedTracesMaybe(oss, ellipsisIndent, count, skippedTraces, tracesSeen); - - count++; - - printTrace(oss, ellipsisIndent, count, trace); } } - printSkippedTracesMaybe(oss, ellipsisIndent, count, skippedTraces, tracesSeen); - - if (truncate) { - oss << "\n" - << ANSI_WARNING - "(stack trace truncated; use '--show-trace' to show the full, detailed trace)" ANSI_NORMAL - << "\n"; + size_t count = 0; + for (const auto & event : events) { + switch (event.kind) { + case TraceEvent::Print: + printTrace(oss, ellipsisIndent, count, *event.trace); + break; + case TraceEvent::DuplicatesOmitted: + oss << "\n" + << ANSI_WARNING "(" << event.count << " duplicate frames omitted)" ANSI_NORMAL << "\n"; + break; + case TraceEvent::Truncated: + oss << "\n" + << ANSI_WARNING + "(stack trace truncated; use '--show-trace' to show the full, detailed trace)" ANSI_NORMAL + << "\n\n"; + break; + } } oss << "\n" << prefix; diff --git a/src/libutil/include/nix/util/error.hh b/src/libutil/include/nix/util/error.hh index 2a846fed17cf..c9822c69b9b3 100644 --- a/src/libutil/include/nix/util/error.hh +++ b/src/libutil/include/nix/util/error.hh @@ -19,13 +19,16 @@ #include "nix/util/fmt.hh" #include "nix/util/fun.hh" #include "nix/util/config.hh" +#include "nix/util/eval-context.hh" #include #include +#include #include #include #include #include +#include #include #include @@ -98,6 +101,14 @@ struct ErrorInfo */ unsigned int status = 1; + /** + * High-level description of what evaluation was being performed, + * e.g. "evaluation of installable nixpkgs#hello" or + * "evaluation of \u00abstring\u00bb". Set automatically from the + * thread-local EvalContextGuard when a BaseError is constructed. + */ + std::optional evalContext; + Suggestions suggestions; static std::optional programName; @@ -105,6 +116,48 @@ struct ErrorInfo std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool showTrace); +/** + * Structured representation of a trace display decision. + * Separates the algorithm (which traces to show, skip, deduplicate) + * from the rendering (formatting to a stream). + */ +struct TraceEvent +{ + enum Kind + { + /** A trace frame to be printed. */ + Print, + /** A chunk of duplicate frames was omitted. */ + DuplicatesOmitted, + /** The trace was truncated (show-trace not enabled). */ + Truncated, + }; + + Kind kind; + + /** For Print: pointer to the trace to display. */ + const Trace * trace = nullptr; + + /** For DuplicatesOmitted: how many frames were omitted. */ + size_t count = 0; +}; + +/** + * Compute the structured list of trace display events from a trace list. + * + * This encodes the truncation, deduplication, and TracePrint::Always logic + * without any rendering, making it independently testable. + * + * @param traces The list of traces (innermost first). + * @param showTrace Whether --show-trace is enabled. + * @param hasPos A predicate that returns true if a trace has a displayable position. + * In production this checks `pos && *pos`, but tests can supply a custom predicate. + */ +std::vector computeTraceDisplay( + const std::list & traces, + bool showTrace, + std::function hasPos = {}); + /** * BaseError should generally not be caught, as it has Interrupted as * a subclass. Catch Error instead. @@ -132,33 +185,41 @@ public: BaseError(unsigned int status, Args &&... args) : err{.level = lvlError, .msg = HintFmt(std::forward(args)...), .pos = {}, .status = status} { + err.evalContext = currentEvalContext(); } template explicit BaseError(const std::string & fs, Args &&... args) : err{.level = lvlError, .msg = HintFmt(fs, std::forward(args)...), .pos = {}} { + err.evalContext = currentEvalContext(); } template BaseError(const Suggestions & sug, Args &&... args) : err{.level = lvlError, .msg = HintFmt(std::forward(args)...), .pos = {}, .suggestions = sug} { + err.evalContext = currentEvalContext(); } BaseError(HintFmt hint) : err{.level = lvlError, .msg = hint, .pos = {}} { + err.evalContext = currentEvalContext(); } BaseError(ErrorInfo && e) : err(std::move(e)) { + if (!err.evalContext) + err.evalContext = currentEvalContext(); } BaseError(const ErrorInfo & e) : err(e) { + if (!err.evalContext) + err.evalContext = currentEvalContext(); } /** The error message without "error: " prefixed to it. */ diff --git a/src/libutil/include/nix/util/eval-context.hh b/src/libutil/include/nix/util/eval-context.hh new file mode 100644 index 000000000000..ba74e3662794 --- /dev/null +++ b/src/libutil/include/nix/util/eval-context.hh @@ -0,0 +1,44 @@ +#pragma once +/** + * @file + * + * @brief Thread-local evaluation context for enriching error messages. + * + * Provides a mechanism to annotate errors with the high-level user action + * that triggered them (e.g. "during evaluation of installable nixpkgs#hello"). + */ + +#include +#include + +namespace nix { + +/** + * Return the current thread-local evaluation context string, if any. + */ +const std::optional & currentEvalContext(); + +/** + * RAII guard that sets a thread-local evaluation context string. + * When a BaseError is constructed while a guard is active, the context + * is automatically stamped onto ErrorInfo::evalContext. + * + * Only the outermost guard takes effect — nested guards are no-ops, + * so the context always reflects the top-level user action. + * + * Usage: + * EvalContextGuard ctx("evaluation of installable nixpkgs#hello"); + * // ... any BaseError thrown here will carry the context ... + */ +class EvalContextGuard +{ + std::optional previous; + bool didSet; +public: + explicit EvalContextGuard(std::string context); + ~EvalContextGuard(); + EvalContextGuard(const EvalContextGuard &) = delete; + EvalContextGuard & operator=(const EvalContextGuard &) = delete; +}; + +} diff --git a/src/libutil/include/nix/util/meson.build b/src/libutil/include/nix/util/meson.build index cea6d48f5920..425cad3be05a 100644 --- a/src/libutil/include/nix/util/meson.build +++ b/src/libutil/include/nix/util/meson.build @@ -38,6 +38,7 @@ headers = [ config_pub_h ] + files( 'english.hh', 'environment-variables.hh', 'error.hh', + 'eval-context.hh', 'exec.hh', 'executable-path.hh', 'exit.hh', diff --git a/src/nix/eval.cc b/src/nix/eval.cc index 6b8c0ba12234..ed920cb91653 100644 --- a/src/nix/eval.cc +++ b/src/nix/eval.cc @@ -1,4 +1,5 @@ #include "nix/cmd/command-installable-value.hh" +#include "nix/util/eval-context.hh" #include "nix/main/common-args.hh" #include "nix/main/shared.hh" #include "nix/store/store-api.hh" @@ -59,6 +60,12 @@ struct CmdEval : MixJSON, InstallableValueCommand, MixReadOnlyOption void run(ref store, ref installable) override { + auto what = installable->what(); + EvalContextGuard ctx( + expr ? fmt("during evaluation of expression '%s'", *expr) + : what.empty() ? "during evaluation" + : fmt("during evaluation of '%s'", what)); + if (raw && json) throw UsageError("--raw and --json are mutually exclusive"); diff --git a/src/nix/nix-build/nix-build.cc b/src/nix/nix-build/nix-build.cc index e68e775e024f..44cd60fe42ac 100644 --- a/src/nix/nix-build/nix-build.cc +++ b/src/nix/nix-build/nix-build.cc @@ -9,6 +9,7 @@ #include #include "nix/util/current-process.hh" +#include "nix/util/eval-context.hh" #include "nix/store/parsed-derivations.hh" #include "nix/store/derivation-options.hh" #include "nix/store/store-open.hh" @@ -415,6 +416,7 @@ static void main_nix_build(int argc, char ** argv) for (auto e : exprs) { Value vRoot; + EvalContextGuard ctx(fmt("during %s evaluation", myName)); state->eval(e, vRoot); auto takesNixShellAttr = [&](const Value & v) { diff --git a/src/nix/nix-instantiate/nix-instantiate.cc b/src/nix/nix-instantiate/nix-instantiate.cc index dee79bcbfc2c..35f6a1714de3 100644 --- a/src/nix/nix-instantiate/nix-instantiate.cc +++ b/src/nix/nix-instantiate/nix-instantiate.cc @@ -1,4 +1,5 @@ #include "nix/store/globals.hh" +#include "nix/util/eval-context.hh" #include "nix/expr/print-ambiguous.hh" #include "nix/main/shared.hh" #include "nix/expr/eval.hh" @@ -40,6 +41,7 @@ void processExpr( } Value vRoot; + EvalContextGuard ctx("during nix-instantiate evaluation"); state.eval(e, vRoot); for (auto & i : attrPaths) { diff --git a/tests/functional/eval-context-complex.exp b/tests/functional/eval-context-complex.exp new file mode 100644 index 000000000000..8e6f12f79ac8 --- /dev/null +++ b/tests/functional/eval-context-complex.exp @@ -0,0 +1,14 @@ +error: during evaluation of file '.*/eval-context-complex.nix' + + \(stack trace truncated; use '--show-trace' to show the full, detailed trace\) + + … while evaluating the attribute 'value' + at .*/eval-context-complex.nix:[0-9]+:[0-9]+: + + … while evaluating the attribute 'value' + at .*/eval-context-complex.nix:[0-9]+:[0-9]+: + + … while calling the 'throw' builtin + at .*/eval-context-complex.nix:[0-9]+:[0-9]+: + + error: computation error deep in the stack diff --git a/tests/functional/eval-context-complex.nix b/tests/functional/eval-context-complex.nix new file mode 100644 index 000000000000..af8650cb8cc7 --- /dev/null +++ b/tests/functional/eval-context-complex.nix @@ -0,0 +1,13 @@ +# Complex evaluation with nested function calls to demonstrate trace truncation +let + # Helper functions at different lines to ensure position diversity + helper1 = x: { value = x; }; + helper2 = x: (helper1 x).value + 1; + helper3 = x: helper2 (helper2 x); + helper4 = x: helper3 (helper3 x); + helper5 = x: helper4 (helper4 x); + + # This will fail deep in the evaluation + final = helper5 (throw "computation error deep in the stack"); +in +final diff --git a/tests/functional/eval-context.sh b/tests/functional/eval-context.sh new file mode 100755 index 000000000000..48c244ca3147 --- /dev/null +++ b/tests/functional/eval-context.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +source common.sh + +clearStoreIfPossible + +# Test that evalContext is shown on the error: line for various evaluation modes. + +# --expr: should show "during evaluation of expression ''" +out="$(nix eval --expr '{ x = throw "boom"; }.x' 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during evaluation of expression.*{ x = throw \"boom\"; }.x" + +out="$(nix eval --expr 'throw "simple error"' 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during evaluation of expression.*throw" + +# -f / --file: should show context with attribute name or file +cat > "$TEST_ROOT/crash.nix" << 'EOF' +{ crashingAttribute = throw "file-boom"; } +EOF +out="$(nix eval crashingAttribute -f "$TEST_ROOT/crash.nix" 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during evaluation of.*crashingAttribute" + +# nix-build: should show "during nix-build evaluation" +out="$(nix-build --expr 'throw "nb-boom"' 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during nix-build evaluation" + +# nix-instantiate: should show "during nix-instantiate evaluation" +out="$(nix-instantiate --eval --expr 'throw "ni-boom"' 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during nix-instantiate evaluation" + +# Verify that evalContext persists even with trace truncation +cat > "$TEST_ROOT/complex.nix" << 'COMPLEXEOF' +let + helper1 = x: { value = x; }; + helper2 = x: (helper1 x).value + 1; + helper3 = x: helper2 (helper2 x); + helper4 = x: helper3 (helper3 x); + helper5 = x: helper4 (helper4 x); + final = helper5 (throw "deep error"); +in +final +COMPLEXEOF +out="$(nix eval -f "$TEST_ROOT/complex.nix" 2>&1)" && fail "should have failed" +echo "$out" | grepQuiet "error: during evaluation of file.*complex.nix" +# Should have truncation marker since there are many frames +echo "$out" | grepQuiet "stack trace truncated" diff --git a/tests/functional/meson.build b/tests/functional/meson.build index ecb9ef87f663..42acce9f2b01 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -115,6 +115,7 @@ suites = [ 'impure-eval.sh', 'pure-eval.sh', 'eval.sh', + 'eval-context.sh', 'short-path-literals.sh', 'absolute-path-literals.sh', 'no-url-literals.sh',