Skip to content

fix: libexpr: Don't silently terminate on failure in mkFailed#16143

Open
ncaq wants to merge 2 commits into
NixOS:masterfrom
ncaq:catch-in-noexcept
Open

fix: libexpr: Don't silently terminate on failure in mkFailed#16143
ncaq wants to merge 2 commits into
NixOS:masterfrom
ncaq:catch-in-noexcept

Conversation

@ncaq

@ncaq ncaq commented Jul 14, 2026

Copy link
Copy Markdown

mkFailed is noexcept,
but the Value::Failed allocation inside it can actually throw:
it allocates on the Boehm GC heap,
and our OOM handler (GC_set_oom_fn) converts allocation failure into std::bad_alloc,
contrary to the old comment in .clang-tidy,
which claimed Boehm aborts rather than throws.
When that happened,
the exception hit the noexcept boundary and the process died in std::terminate,
with no indication of what went wrong or which evaluation error was being recorded,
which made the actual cause of such failures very hard to diagnose.

Instead,
catch everything inside mkFailed,
print a diagnostic to stderr and abort.
The error path uses only fputs with static strings,
since the heap is exactly what just failed.
Printing the original evaluation error is best-effort,
because formatting it with functions like BaseError::what may itself allocate;
the static message is printed first so that at least the context is never lost.

Since mkFailed no longer lets an exception escape a new expression,
this also fixes the only bugprone-unhandled-exception-at-new warning in the codebase,
so re-enable that check in .clang-tidy.

ref #16023

Assisted-by: claude-fable-5

`mkFailed` is `noexcept`,
but the `Value::Failed` allocation inside it can actually throw:
it allocates on the Boehm GC heap,
and our OOM handler (`GC_set_oom_fn`) converts allocation failure into `std::bad_alloc`,
contrary to the old comment in `.clang-tidy`,
which claimed Boehm aborts rather than throws.
When that happened,
the exception hit the `noexcept` boundary and the process died in `std::terminate`,
with no indication of what went wrong or which evaluation error was being recorded,
which made the actual cause of such failures very hard to diagnose.

Instead,
catch everything inside `mkFailed`,
print a diagnostic to stderr and abort.
The error path uses only `fputs` with static strings,
since the heap is exactly what just failed.
Printing the original evaluation error is best-effort,
because formatting it with functions like `BaseError::what` may itself allocate;
the static message is printed first so that at least the context is never lost.

Since `mkFailed` no longer lets an exception escape a `new` expression,
this also fixes the only `bugprone-unhandled-exception-at-new` warning in the codebase,
so re-enable that check in `.clang-tidy`.

ref NixOS#16023

Assisted-by: claude-fable-5
@ncaq
ncaq requested a review from edolstra as a code owner July 14, 2026 06:19
@edolstra

Copy link
Copy Markdown
Member

This seems pretty ad hoc to me: it's a lot of complexity for just one function, while we have numerous noexcept functions that do allocation. A more general solution would be to replace our throw std::bad_alloc() calls by panics (which gives a more useful stack trace than throwing bad_alloc). That at least covers the Boehm GC case. Alternatively, in Determinate Nix we have some code to convert throws of std::logic_error into aborts. That could be adapted to intercept throws of std::bad_alloc as well.

@edolstra

Copy link
Copy Markdown
Member

Presumably the reason why this happens in mkFailed() is because we get a std::bad_alloc somewhere else, but then handleEvalExceptionForThunk() / handleEvalExceptionForApp() catches it and calls mkFailed(), which then gets another std::bad_alloc. Might be better to have handleEvalException* catch std::bad_alloc and abort right away.

@xokdvium

Copy link
Copy Markdown
Contributor

Yeah, bad_alloc realistically is quite impossible to recover from when we need to allocate more stuff to report the error. Terminating / panic-ing would make much more sense

Comment thread src/libexpr/include/nix/expr/value.hh Outdated
Comment on lines +1435 to +1464
/* Most likely a std::bad_alloc from the GC heap.
This is called while handling an evaluation error,
and recording that failure is itself what just failed,
so there is no way to recover.
Report and bail out, using only static strings to avoid touching the heap again. */
std::fputs("error: failed to record an evaluation error", stderr);
try {
throw;
} catch (const std::exception & allocEx) {
std::fputs(": ", stderr);
std::fputs(allocEx.what(), stderr);
} catch (...) {
}
std::fputc('\n', stderr);

/* Best effort: show the evaluation error that was being recorded.
Formatting it (e.g. BaseError::what) may itself allocate and terminate on failure,
hence printing the message above first. */
try {
std::rethrow_exception(e);
} catch (const std::exception & evalEx) {
std::fputs("the evaluation error was: ", stderr);
std::fputs(evalEx.what(), stderr);
std::fputc('\n', stderr);
} catch (...) {
std::fputs("the evaluation error was of an unknown type\n", stderr);
}

// Abort not exception but function.
std::abort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seeems... very excessively bad. Can't we just call nix::panic? It doesn't try to allocate memory do it should be safe from the perspective.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm newbie in nix repo.
I don't know nix:panic.
I will research it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I use nix:panic.

@ncaq

ncaq commented Jul 14, 2026

Copy link
Copy Markdown
Author

it's a lot of complexity for just one function

At least judging by the clang-tidy configuration comments, these appear to be the only instances triggering warnings for the use of new.

The failure path of `mkFailed` printed the allocation exception by hand with `fputs`,
and then called `std::abort` directly,
which bypassed the crash handler and lost the stack trace.

Call `panic()` instead.
Its message is written with plain `write(2)` without touching the heap,
and the `std::terminate()` it invokes runs the crash handler,
which reports the allocation exception still active in the catch block,
with its demangled type and message and a stack trace.
This makes the manual printing of the allocation exception redundant,
so drop it.

The guarantees from the previous commit are preserved:
a static message is printed first,
so the context is never lost even if formatting the evaluation error terminates,
and showing the evaluation error itself remains best-effort since `BaseError::what` may allocate.

Assisted-by: claude-fable-5
@ncaq

ncaq commented Jul 15, 2026

Copy link
Copy Markdown
Author

I certainly wouldn't deny that this borders on an ad-hoc fix,
but if noexcept in C++ provides a guarantee, then preventing exceptions from escaping via try-catch strikes me as the "correct" way to write it.
I actually mistook that for a bug myself at first.

@ncaq
ncaq requested a review from xokdvium July 15, 2026 00:06
@xokdvium

xokdvium commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Tbh I think that is the wrong approach. As @edolstra mentions handleEvalExceptionForThunk / handleEvalExceptionForApp is the better place to handle this. If we run into a bad_alloc we might not want to allocate an ExceptionRef at all but just continue unwinding and let the caller handle that. Though as always, exception handling in a low memory situation is bound to be very fragile (and c++ stdlib has statically pre allocated memory for use with exceptions specifically as a hack for this).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants