Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Towards structured error classes #9834

Merged
merged 6 commits into from
Feb 8, 2024

Conversation

9999years
Copy link
Contributor

@9999years 9999years commented Jan 23, 2024

Motivation

While preparing PRs like #9753, I've had to change error messages in dozens of code paths. It would be nice if instead of

EvalError("expected 'boolean' but found '%1%'", showType(v))

we could write

TypeError(v, "boolean")

or similar. Then, changing the error message could be a mechanical refactor with the compiler pointing out places the constructor needs to be changed, rather than the error-prone process of grepping through the codebase. Structured errors would also help prevent the "same" error from having multiple slightly different messages, and could be a first step towards error codes / an error index.

This PR reworks the exception infrastructure in libexpr to support exception types with different constructor signatures than BaseError. Actually refactoring the exceptions to use structured data will come in a future PR (this one is big enough already, as it has to touch every exception in libexpr).

Notes for reviewers

The core design is in eval-error.hh. Generally, errors like this:

state.error("'%s' is not a string", getAttrPathStr())
  .debugThrow<TypeError>()

are transformed like this:

EvalErrorBuilder<TypeError>(state, "'%s' is not a string", getAttrPathStr())
  .debugThrow()

Priorities and Process

Add 👍 to pull requests you find important.

The Nix maintainer team uses a GitHub project board to schedule and track reviews.

@github-actions github-actions bot added new-cli Relating to the "nix" command repl The Read Eval Print Loop, "nix repl" command and debugger labels Jan 23, 2024
@github-actions github-actions bot added the with-tests Issues related to testing. PRs with tests have some priority label Jan 23, 2024
@9999years 9999years force-pushed the structured-errors branch 4 times, most recently from 8d0c4e2 to bb7b162 Compare January 24, 2024 02:14
@@ -0,0 +1,99 @@
#include "eval-error.hh"
Copy link
Member

Choose a reason for hiding this comment

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

question about why not just write the implementation in header file, maybe eval-error.hh?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reduces compile times, at least to an extent. I can put the implementation in the header file if that's what's preferred, though.

Copy link
Member

@inclyc inclyc Jan 29, 2024

Choose a reason for hiding this comment

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

Reduces compile times, at least to an extent

If you only have implememtations in .cc files(formally translation unit, TUs), other libexpr users may COPY the source code again and do same instantiation.

Reduces compile times

IMHO, I think it is not a good option to do any 'optimization' without dedicated profiling. This may reduce compile time, but do make things hard somehow(as I commented above).

Copy link
Member

Choose a reason for hiding this comment

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

I don't mind this so much. If it becomes annoying it's easy to change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I went to refactor this and remembered why I had it like this -- the PosIdx type is forward-declared (it comes from nixexpr.hh, which itself depends on eval-error.hh).

Copy link
Member

Choose a reason for hiding this comment

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

I went to refactor this and remembered why I had it like this -- the PosIdx type is forward-declared (it comes from nixexpr.hh, which itself depends on eval-error.hh).

Hmm, so how about split PosIdx definition from nixexpr.hh , to resolve this circular #includes?

Maybe we can do this in next patches, for not just keep this as-is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately even if you move PosIdx out of nixexpr.hh, the definition of EvalErrorBuilder<T>::withFrame depends on EvalState :(

Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately even if you move PosIdx out of nixexpr.hh, the definition of EvalErrorBuilder<T>::withFrame depends on EvalState :(

That's quite unfortunate. Ideally all headers are 'self-contained', looks like those many 'forward declaration's lead to such isssue. This cannot be trivially solved, then let's keep it as-is :(.

throw error;
}

template class EvalErrorBuilder<EvalError>;
Copy link
Member

Choose a reason for hiding this comment

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

If you do this in .cc TUs, all instantiated classes must be listed here which may lead to extra maintenance issue.

src/libexpr/eval-error.hh Show resolved Hide resolved
@9999years 9999years force-pushed the structured-errors branch 3 times, most recently from 5a14403 to 6784cf4 Compare January 26, 2024 18:14
@9999years 9999years marked this pull request as ready for review January 26, 2024 18:16
@9999years 9999years force-pushed the structured-errors branch 2 times, most recently from 4a301f3 to 3ec4cf3 Compare January 29, 2024 22:27
Comment on lines +88 to +111
template class EvalErrorBuilder<EvalError>;
template class EvalErrorBuilder<AssertionError>;
template class EvalErrorBuilder<ThrownError>;
template class EvalErrorBuilder<Abort>;
template class EvalErrorBuilder<TypeError>;
template class EvalErrorBuilder<UndefinedVarError>;
template class EvalErrorBuilder<MissingArgumentError>;
template class EvalErrorBuilder<InfiniteRecursionError>;
template class EvalErrorBuilder<CachedEvalError>;
template class EvalErrorBuilder<InvalidPathError>;
Copy link
Member

Choose a reason for hiding this comment

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

Are these all EvalError or subtypes of it? It would be nice to add some sort of static_assert to EvalErrorBuilder if so.

That would help make clear what exactly the "closed world' of instantiations that we need is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, they're all subtypes of EvalError.

These are all the error types we use EvalErrorBuilder with. If they're not declared here it becomes a link error. The methods on EvalErrorBuilder require a state member on the error, so in practice it'll become a compile error to use EvalErrorBuilder with an error type that's not a subtype of EvalError.

Copy link
Member

Choose a reason for hiding this comment

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

OK the state thing is good to hear.

Comment on lines -425 to -426
} catch (UndefinedVarError & e) {
// Quietly ignore undefined variable errors.
Copy link
Member

Choose a reason for hiding this comment

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

What's this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

UndefinedVarError used to be a subclass of Error. This PR changes it to be a subclass of EvalError instead. There's already a catch clause for EvalError here, so the UndefinedVarError clause is no longer needed.

Copy link
Member

Choose a reason for hiding this comment

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

OK sounds good.

@Ericson2314
Copy link
Member

Sometimes throw becomes debugThrow(). Is that fine?

@Ericson2314
Copy link
Member

In general, I like this :). But I would like to get the opinion of @pennae, and @layus (who wrote the original ErrorBuilder) too.

@9999years
Copy link
Contributor Author

Sometimes throw becomes debugThrow(). Is that fine?

Yes, this will make the debugger work in more places.

@layus
Copy link
Member

layus commented Jan 30, 2024 via email

@Ericson2314
Copy link
Member

Ericson2314 commented Jan 30, 2024

I will say an ultimate goal with the structured errors stuff is to not do any formatting until the exception is rendered. The exception itself should just contain the raw structured data, no hintfmt or similar.

So while we might indeed have a problem in the short term --- thanks for pointing this out @layus --- I am confident the problem is gone in the final destination.

@9999years
Copy link
Contributor Author

The exception itself should just contain the raw structured data, no hintfmt or similar.

Hmm. I agree this is a good idea but it will be challenging to implement. Currently the ErrorInfo that all errors contain includes a hintfmt.

Copy link
Member

@layus layus left a comment

Choose a reason for hiding this comment

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

Otherwise, I have nothing against this change.

src/libexpr/eval-error.hh Show resolved Hide resolved
@Ericson2314
Copy link
Member

The exception itself should just contain the raw structured data, no hintfmt or similar.

Hmm. I agree this is a good idea but it will be challenging to implement. Currently the ErrorInfo that all errors contain includes a hintfmt.

https://github.com/ericson2314/nix/tree/more-structured-errors I started fixing this here. I think it would be good to pick up that work in conjunction with this.

The basic idea was that the non-structured errors can still inherit from a type with the hintfmt like today, so except for some core infra things, the conversion should be pay-as-you-go.

Copy link
Contributor

@pennae pennae left a comment

Choose a reason for hiding this comment

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

this looks good :) would have been easier to go through if it had been split into more specific commits though.

there doesn't seem to be an eval perf impact from these changes, with or without nonlines on the builder when built with LTO enabled (haven't tried with LTO turned off).

src/libexpr/eval-error.cc Outdated Show resolved Hide resolved
src/libexpr/eval-error.hh Outdated Show resolved Hide resolved
src/libexpr/eval-error.hh Outdated Show resolved Hide resolved
state.error("infinite recursion encountered")
.debugThrow<InfiniteRecursionError>();
EvalErrorBuilder<InfiniteRecursionError>(state, "infinite recursion encountered")
.atPos(v.determinePos(noPos))
Copy link
Contributor

Choose a reason for hiding this comment

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

since this position will ne overwritten anyway, is this needed?

src/libexpr/eval.cc Outdated Show resolved Hide resolved
src/libexpr/primops.cc Outdated Show resolved Hide resolved
.msg = hintfmt("memory limit exceeded by regular expression '%s'", re),
.errPos = state.positions[pos]
}));
EvalErrorBuilder<EvalError>(state, "memory limit exceeded by regular expression '%s'", re)
Copy link
Contributor

Choose a reason for hiding this comment

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

unrelated nitpick: we're not sure it's wise to print regexes like this without escaping them as a nix string again first.

src/libexpr/primops/context.cc Outdated Show resolved Hide resolved
src/libexpr/primops.cc Outdated Show resolved Hide resolved
Copy link
Contributor

@pennae pennae left a comment

Choose a reason for hiding this comment

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

unfortunately we have to retract our performance claims due to errors in the benchmarking setup, the PR as it stands today gives a rather massive 3.5% eval perf hit. that's not good and has to be rectified :(

@9999years
Copy link
Contributor Author

9999years commented Feb 1, 2024

I've added a pre-allocated buffer to EvalState to construct errors in. This replaces the old pre-allocated pointer used for the same purpose.

Copy link
Contributor

@pennae pennae left a comment

Choose a reason for hiding this comment

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

we cannot reproduce nix search being that much faster (it's still about 2% slower for us), but other evaluations are under 0.5% from the merge-base value. instead of placement new we should probably be using regular new as the error paths aren't hot enough that avoiding malloc will be enough of a benefit to justify the correctness implications.

(we're not a huge fan of the chained builder pattern here and would instead move all the builder methods into EvalError, give them all a && ref qualifiers, and wrap the throwing/debugRepl interaction into a macro to stick the whole construction into a noinline function. but that can just as well be future work)

src/libexpr/eval.hh Outdated Show resolved Hide resolved
src/libexpr/eval.hh Outdated Show resolved Hide resolved
src/libexpr/eval-error.cc Show resolved Hide resolved
While preparing PRs like NixOS#9753, I've had to change error messages in
dozens of code paths. It would be nice if instead of

    EvalError("expected 'boolean' but found '%1%'", showType(v))

we could write

    TypeError(v, "boolean")

or similar. Then, changing the error message could be a mechanical
refactor with the compiler pointing out places the constructor needs to
be changed, rather than the error-prone process of grepping through the
codebase. Structured errors would also help prevent the "same" error
from having multiple slightly different messages, and could be a first
step towards error codes / an error index.

This PR reworks the exception infrastructure in `libexpr` to
support exception types with different constructor signatures than
`BaseError`. Actually refactoring the exceptions to use structured data
will come in a future PR (this one is big enough already, as it has to
touch every exception in `libexpr`).

The core design is in `eval-error.hh`. Generally, errors like this:

    state.error("'%s' is not a string", getAttrPathStr())
      .debugThrow<TypeError>()

are transformed like this:

    state.error<TypeError>("'%s' is not a string", getAttrPathStr())
      .debugThrow()

The type annotation has moved from `ErrorBuilder::debugThrow` to
`EvalState::error`.
We're on C++ 20 now, we don't need this
@layus
Copy link
Member

layus commented Feb 2, 2024 via email

@9999years
Copy link
Contributor Author

9999years commented Feb 2, 2024

@layus

In private correspondence, @pennae said:

[We gather performance metrics by] running nix eval --raw --impure --expr 'with import <nixpkgs/nixos> {}; system' with a vetted nixpkgs pin and NixOS config for the most part, since that tends to exercise all the bits that go into a real-world eval
hyperfine is our current favourite for gathering statistical data, and for reproducibility of results the whole things runs in a taskset -c2,3 chrt -f50 with cpu boost clocks turned off
we also run each of them on a temporary store to minimize interference with the rest of the system

@pennae
Copy link
Contributor

pennae commented Feb 3, 2024

we think we've described the method in detail but always avoided posting the exact inputs because we had never taken the time to clean our test system config up enough to post, but now we did. it's remarkably simple, and for best results run with cpu boosting disabled since that can introduce a bunch of uncertainty. the nixpkgs revision changes occasionally to keep up with the world, the only important thing for both nixpkgs and the system config is to remain stable (and recent/large enough) during a benchmark run.

https://gist.github.com/pennae/43ebf7709d5e13ece3912e5233ce44d9

Copy link
Contributor

@pennae pennae left a comment

Choose a reason for hiding this comment

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

much better now! performance impacts on system eval are down in the noise now, nix search is slightly slower but not enoguh to be bothered by it (less than 1%).

// any such instancve and must delete itself before throwing the underlying
// error.
auto error = std::move(this->error);
delete this;
Copy link
Member

Choose a reason for hiding this comment

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

OK I see this is the delete corresponding to the new. @tfc is this legal?

Copy link
Member

@Ericson2314 Ericson2314 Feb 6, 2024

Choose a reason for hiding this comment

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

https://isocpp.org/wiki/faq/freestore-mgmt#delete-this OK it appears to be.

Still, I am tempted to say we should be using std::unique and debugThrow can be a && method.

@thufschmitt thufschmitt merged commit 1ba9780 into NixOS:master Feb 8, 2024
8 checks passed
@9999years 9999years deleted the structured-errors branch February 8, 2024 19:43
@nixos-discourse
Copy link

This issue has been mentioned on NixOS Discourse. There might be relevant details there:

https://discourse.nixos.org/t/tweag-nix-dev-update-54/39990/1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new-cli Relating to the "nix" command repl The Read Eval Print Loop, "nix repl" command and debugger with-tests Issues related to testing. PRs with tests have some priority
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

7 participants