Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions doc/manual/rl-next/stack-overflow-segfaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
synopsis: Some stack overflow segfaults are fixed
issues: 9616
prs: 9617
---

The number of nested function calls has been restricted, to detect and report
infinite function call recursions. The default maximum call depth is 10,000 and
can be set with [the `max-call-depth`
option](@docroot@/command-ref/conf-file.md#conf-max-call-depth).

This fixes segfaults or the following unhelpful error message in many cases:

error: stack overflow (possible infinite recursion)

Before:

```
$ nix-instantiate --eval --expr '(x: x x) (x: x x)'
Segmentation fault: 11
```

After:

```
$ nix-instantiate --eval --expr '(x: x x) (x: x x)'
error: stack overflow

at «string»:1:14:
1| (x: x x) (x: x x)
| ^
```
3 changes: 3 additions & 0 deletions src/libexpr/eval-settings.hh
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ struct EvalSettings : Config

Setting<bool> traceVerbose{this, false, "trace-verbose",
"Whether `builtins.traceVerbose` should trace its first argument when evaluated."};

Setting<unsigned int> maxCallDepth{this, 10000, "max-call-depth",
"The maximum function call depth to allow before erroring."};
};

extern EvalSettings evalSettings;
Expand Down
18 changes: 18 additions & 0 deletions src/libexpr/eval.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1505,9 +1505,27 @@ void ExprLambda::eval(EvalState & state, Env & env, Value & v)
v.mkLambda(&env, this);
}

namespace {
/** Increments a count on construction and decrements on destruction.
*/
class CallDepth {
size_t & count;
public:
CallDepth(size_t & count) : count(count) {
++count;
}
~CallDepth() {
--count;
}
};
};

void EvalState::callFunction(Value & fun, size_t nrArgs, Value * * args, Value & vRes, const PosIdx pos)
{
if (callDepth > evalSettings.maxCallDepth)
error("stack overflow; max-call-depth exceeded").atPos(pos).template debugThrow<EvalError>();
CallDepth _level(callDepth);

auto trace = evalSettings.traceFunctionCalls
? std::make_unique<FunctionCallTrace>(positions[pos])
: nullptr;
Expand Down
5 changes: 5 additions & 0 deletions src/libexpr/eval.hh
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,11 @@ private:
const SourcePath & basePath,
std::shared_ptr<StaticEnv> & staticEnv);

/**
* Current Nix call stack depth, used with `max-call-depth` setting to throw stack overflow hopefully before we run out of system stack.
*/
size_t callDepth = 0;

public:

/**
Expand Down
111 changes: 107 additions & 4 deletions src/libutil/error.cc
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ std::ostream & operator <<(std::ostream & str, const AbstractPos & pos)
return str;
}

/**
* An arbitrarily defined value comparison for the purpose of using traces in the key of a sorted container.
*/
inline bool operator<(const Trace& lhs, const Trace& rhs)
Comment thread
9999years marked this conversation as resolved.
{
// `std::shared_ptr` does not have value semantics for its comparison
// functions, so we need to check for nulls and compare the dereferenced
// values here.
if (lhs.pos != rhs.pos) {
if (!lhs.pos)
return true;
if (!rhs.pos)
return false;
if (*lhs.pos != *rhs.pos)
return *lhs.pos < *rhs.pos;
}
// This formats a freshly formatted hint string and then throws it away, which
// shouldn't be much of a problem because it only runs when pos is equal, and this function is
// used for trace printing, which is infrequent.
return std::forward_as_tuple(lhs.hint.str(), lhs.frame)
< std::forward_as_tuple(rhs.hint.str(), rhs.frame);
}
inline bool operator> (const Trace& lhs, const Trace& rhs) { return rhs < lhs; }
inline bool operator<=(const Trace& lhs, const Trace& rhs) { return !(lhs > rhs); }
inline bool operator>=(const Trace& lhs, const Trace& rhs) { return !(lhs < rhs); }
Comment thread
9999years marked this conversation as resolved.

std::optional<LinesOfCode> AbstractPos::getCodeLines() const
{
if (line == 0)
Expand Down Expand Up @@ -185,6 +211,69 @@ static bool printPosMaybe(std::ostream & oss, std::string_view indent, const std
return hasPos;
}

void printTrace(
std::ostream & output,
const std::string_view & indent,
size_t & count,
const Trace & trace)
{
output << "\n" << "… " << trace.hint.str() << "\n";

if (printPosMaybe(output, indent, trace.pos))
count++;
}

void printSkippedTracesMaybe(
std::ostream & output,
const std::string_view & indent,
size_t & count,
std::vector<Trace> & skippedTraces,
std::set<Trace> tracesSeen)
{
if (skippedTraces.size() > 0) {
// If we only skipped a few frames, print them out normally;
// messages like "1 duplicate frames omitted" aren't helpful.
if (skippedTraces.size() <= 5) {
for (auto & trace : skippedTraces) {
printTrace(output, indent, count, trace);
}
} else {
output << "\n" << ANSI_WARNING "(" << skippedTraces.size() << " duplicate frames omitted)" ANSI_NORMAL << "\n";
// Clear the set of "seen" traces after printing a chunk of
// `duplicate frames omitted`.
//
// Consider a mutually recursive stack trace with:
// - 10 entries of A
// - 10 entries of B
// - 10 entries of A
//
// If we don't clear `tracesSeen` here, we would print output like this:
// - 1 entry of A
// - (9 duplicate frames omitted)
// - 1 entry of B
// - (19 duplicate frames omitted)
//
// This would obscure the control flow, which went from A,
// to B, and back to A again.
//
// In contrast, if we do clear `tracesSeen`, the output looks like this:
// - 1 entry of A
// - (9 duplicate frames omitted)
// - 1 entry of B
// - (9 duplicate frames omitted)
// - 1 entry of A
// - (9 duplicate frames omitted)
//
// See: `tests/functional/lang/eval-fail-mutual-recursion.nix`
tracesSeen.clear();
}
}
// We've either printed each trace in `skippedTraces` normally, or
// printed a chunk of `duplicate frames omitted`. Either way, we've
// processed these traces and can clear them.
skippedTraces.clear();
}

std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool showTrace)
{
std::string prefix;
Expand Down Expand Up @@ -333,7 +422,13 @@ std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool s

bool frameOnly = false;
if (!einfo.traces.empty()) {
// Stack traces seen since we last printed a chunk of `duplicate frames
// omitted`.
std::set<Trace> tracesSeen;
// A consecutive sequence of stack traces that are all in `tracesSeen`.
std::vector<Trace> skippedTraces;
size_t count = 0;

for (const auto & trace : einfo.traces) {
if (trace.hint.str().empty()) continue;
if (frameOnly && !trace.frame) continue;
Expand All @@ -343,14 +438,21 @@ std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool s
break;
}

if (tracesSeen.count(trace)) {
skippedTraces.push_back(trace);
continue;
}
tracesSeen.insert(trace);

printSkippedTracesMaybe(oss, ellipsisIndent, count, skippedTraces, tracesSeen);

count++;
frameOnly = trace.frame;

oss << "\n" << "… " << trace.hint.str() << "\n";

if (printPosMaybe(oss, ellipsisIndent, trace.pos))
count++;
printTrace(oss, ellipsisIndent, count, trace);
}

printSkippedTracesMaybe(oss, ellipsisIndent, count, skippedTraces, tracesSeen);
oss << "\n" << prefix;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: If the algorithm could be factored out, it'd be easier to understand and improve.

thought: One possible improvement is to make it understand that clusters of repetition can themselves repeat. This happens when you're doing some iterative thing for each node in a tree for example. Probably there's an easier improvement that I'm not thinking of.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factored it out, although it should maybe be a class to avoid passing around so much context.

One possible improvement is to make it understand that clusters of repetition can themselves repeat.

Yeah, I considered this but also wasn't sure of an elegant implementation. I'll keep turning it over.


Expand All @@ -369,4 +471,5 @@ std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool s

return out;
}

}
8 changes: 8 additions & 0 deletions src/libutil/error.hh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#include <memory>
#include <map>
#include <optional>
#include <compare>

#include <sys/types.h>
#include <sys/stat.h>
Expand Down Expand Up @@ -88,6 +89,8 @@ struct AbstractPos
std::optional<LinesOfCode> getCodeLines() const;

virtual ~AbstractPos() = default;

inline auto operator<=>(const AbstractPos& rhs) const = default;
};

std::ostream & operator << (std::ostream & str, const AbstractPos & pos);
Expand All @@ -103,6 +106,11 @@ struct Trace {
bool frame;
};

inline bool operator<(const Trace& lhs, const Trace& rhs);
inline bool operator> (const Trace& lhs, const Trace& rhs);
inline bool operator<=(const Trace& lhs, const Trace& rhs);
inline bool operator>=(const Trace& lhs, const Trace& rhs);

struct ErrorInfo {
Verbosity level;
hintformat msg;
Expand Down
44 changes: 44 additions & 0 deletions tests/functional/lang/eval-fail-duplicate-traces.err.exp
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
error:
… from call site
at /pwd/lang/eval-fail-duplicate-traces.nix:9:3:
8| in
9| throwAfter 2
| ^
10|

… while calling 'throwAfter'
at /pwd/lang/eval-fail-duplicate-traces.nix:4:16:
3| let
4| throwAfter = n:
| ^
5| if n > 0

… from call site
at /pwd/lang/eval-fail-duplicate-traces.nix:6:10:
5| if n > 0
6| then throwAfter (n - 1)
| ^
7| else throw "Uh oh!";

… while calling 'throwAfter'
at /pwd/lang/eval-fail-duplicate-traces.nix:4:16:
3| let
4| throwAfter = n:
| ^
5| if n > 0

… from call site
at /pwd/lang/eval-fail-duplicate-traces.nix:6:10:
5| if n > 0
6| then throwAfter (n - 1)
| ^
7| else throw "Uh oh!";

… while calling 'throwAfter'
at /pwd/lang/eval-fail-duplicate-traces.nix:4:16:
3| let
4| throwAfter = n:
| ^
5| if n > 0

error: Uh oh!
9 changes: 9 additions & 0 deletions tests/functional/lang/eval-fail-duplicate-traces.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Check that we only omit duplicate stack traces when there's a bunch of them.
# Here, there's only a couple duplicate entries, so we output them all.
let
throwAfter = n:
if n > 0
then throwAfter (n - 1)
else throw "Uh oh!";
in
throwAfter 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
error:
… from call site
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:1:
1| (x: x x) (x: x x)
| ^
2|

… while calling anonymous lambda
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:2:
1| (x: x x) (x: x x)
| ^
2|

… from call site
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:5:
1| (x: x x) (x: x x)
| ^
2|

… while calling anonymous lambda
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:11:
1| (x: x x) (x: x x)
| ^
2|

… from call site
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:14:
1| (x: x x) (x: x x)
| ^
2|

(19997 duplicate frames omitted)

error: stack overflow; max-call-depth exceeded
at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:14:
1| (x: x x) (x: x x)
| ^
2|
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
(x: x x) (x: x x)
Loading