-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
stage2: Pop error trace frames for handled errors (#1923) #12837
stage2: Pop error trace frames for handled errors (#1923) #12837
Conversation
73e5235
to
09842f9
Compare
@topolarity could you rebase this against master branch please? My auto-rebase script failed due to conflicts. |
09842f9
to
8427412
Compare
With the latest push, examples in the style of #11593 work as expected: const expectError = @import("std").testing.expectError;
fn alwaysErrors() !void { return error.BUG_ThisErrorShouldNotAppearInAnyTrace; }
fn foo() !void { return error.Foo; }
test "test expected error" {
try expectError(error.BUG_ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
try expectError(error.BUG_ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
try expectError(error.Bar, foo());
} Output with this PR is: Test [1/1] test.test expected error... expected error.Bar, found error.Foo
Test [1/1] test.test expected error... FAIL (TestExpectedError)
./testme.zig:4:18: 0x211d08 in foo (test)
fn foo() !void { return error.Foo; }
^
/home/topolarity/repos/zig/build/stage3/lib/zig/std/testing.zig:37:13: 0x211dcf in expectError__anon_1111 (test)
return error.TestExpectedError;
^
./testme.zig:9:5: 0x212052 in test.test expected error (test)
try expectError(error.Bar, foo());
^
0 passed; 0 skipped; 1 failed. Before we had entries from each expectError call. Now, the entries are cleaned up if |
Was able to lift the |
675169d
to
3fa635b
Compare
1511488
to
bb8465a
Compare
Looking forward to this change. After the latest rebase, all CI checks passed. However due to merging of other PRs there are now several conflicts. Would you mind rebasing? (If a rebase is too problematic, a merge of master branch is fine) |
f39f0fc
to
bf9ecd0
Compare
Alright, looks like the rebase went well The rebase did expose an existing bug #13175 which together with this change causes 1 behavior test regression. I've skipped that test for now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for doing this incredible work.
I'm inclined to merge this, however, the additional complications to the compiler internals, as well as the additional runtime performance overhead are giving me pause.
Before merging, can you share some details on these topics?
- How might this integrate with async functions? In stage1 we combine error return traces when doing
await
. - Can you provide any benchmark data about how runtime performance is affected?
- Can you share how this affects the compilation speed of the self-hosted compiler?
Very good questions. The same things gave me concern - Let's see if I can get a read on the situation for you.
I think the strategy here can stay largely the same. Merging the traces means that it's as if the error were generated "at" the await. For the save/restore logic here, if we treat an errorable
This is the most significant difference I'd expect. This does add a significant number of new ZIR instructions:
That's a ~6.9% increase in instructions, and a 5.7% increase in ZIR bytes (sorry to eat up your Here are the build timings:
Each run was done from a completely fresh cache, so that this includes AstGen and
The self-compilation timings above should include the runtime performance hit, in addition to the delta from code changes in this PR. I did try to make some more adversarial benchmarks that would do a lot of save/restore, but my attempts didn't find a delta that rose above the measurement noise yet. In general there are two places with extra runtime code:
|
60db2f9
to
1fe1f31
Compare
Thanks for taking these measurements. I'm a little confused by "ReleaseSafe" here - did you hack up the build.zig script? Because it only exposes the Also, which one of those timings is "wall clock"? |
Yeah, that's exactly what I did here I did a quick sanity check against the usual ReleaseFast too, which clocked in about 2-3 seconds faster iirc. Delta was similar between master and PR, about 1 second elapsed time.
"Total" should be the wall clock. These are reported from zsh's "time" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, this is good to be merged!
This implement trace "popping" for correctly handled errors within `catch { ... }` and `else { ... }` blocks. When breaking from these blocks with any non-error, we pop the error trace frames corresponding to the operand. When breaking with an error, we preserve the frames so that error traces "chain" together as usual. ```zig fn foo(cond1: bool, cond2: bool) !void { bar() catch { if (cond1) { // If baz() result is a non-error, pop the error trace frames from bar() // If baz() result is an error, leave the bar() frames on the error trace return baz(); } else if (cond2) { // If we break/return an error, then leave the error frames from bar() on the error trace return error.Foo; } }; // An error returned from here does not include bar()'s error frames in the trace return error.Bar; } ``` Notice that if foo() does not return an error it, it leaves no extra frames on the error trace. This is piece (1/3) of ziglang#1923 (comment)
This allows for errors to be "re-thrown" by yielding any error as the result of a catch block. For example: ```zig fn errorable() !void { return error.FallingOutOfPlane; } fn foo(have_parachute: bool) !void { return errorable() catch |err| b: { if (have_parachute) { // error trace will include the call to errorable() break :b error.NoParachute; } else { return; } }; } pub fn main() !void { // Anything that returns a non-error does not pollute the error trace. try foo(true); // This error trace will still include errorable(), whose error was "re-thrown" by foo() try foo(false); } ``` This is piece (2/3) of ziglang#1923 (comment)
In order to enforce a strict stack discipline for error return traces, we cannot track error return traces that are stored in variables: ```zig const x = errorable(); // errorable()'s error return trace is killed here // v-- error trace starts here instead return x catch error.UnknownError; ``` In order to propagate error return traces, function calls need to be passed directly to an error-handling expression (`if`, `catch`, `try` or `return`): ```zig // When passed directly to `catch`, the return trace is propagated return errorable() catch error.UnknownError; // Using a break also works return blk: { // code here break :blk errorable(); } catch error.UnknownError; ``` Why do we need this restriction? Without it, multiple errors can co-exist with their own error traces. Handling that situation correctly means either: a. Dynamically allocating trace memory and tracking lifetimes, OR b. Allowing the production of one error to interfere with the trace of another (which is the current status quo) This is piece (3/3) of ziglang#1923 (comment)
This is encoded as a primitive AIR instruction to resolve one corner case: A function may include a `catch { ... }` or `else |err| { ... }` block but not call any errorable fn. In that case, there is no error return trace to save the index of and codegen needs to avoid interacting with the non-existing error trace. By using a primitive AIR op, we can depend on Liveness to mark this unused in this corner case.
This re-factor is intended to make it easier to track what kind of operator/expression consumes a result location, without overloading the ResultLoc union for this purpose. This is used in the following commit to keep track of initializer expressions of `const` variables to avoid popping error traces pre-maturely. Hopefully this will also be useful for implementing RLS temporaries in the future.
This change extends the "lifetime" of the error return trace associated with an error to include the duration of a function call it is passed to. This means that if a function returns an error, its return trace will include the error return trace for any error inputs. This is needed to support `testing.expectError` and similar functions. If a function returns a non-error, we have to clean up any error return traces created by error-able call arguments.
Despite the old doc-comment, this function cannot be valid for all types since it operates with only a value and Error (Union) types have overlapping Value representations with other Types.
This change extends the "lifetime" of the error return trace associated with an error to continue throughout the block of a `const` variable that it is assigned to. This is necessary to support patterns like this one in test_runner.zig: ```zig const result = foo(); if (result) |_| { // ... success logic } else |err| { // `foo()` should be included in the error trace here return error.TestFailed; } ``` To make this happen, the majority of the error return trace popping logic needed to move into Sema, since `const x = foo();` cannot be examined syntactically to determine whether it modifies the error return trace. We also have to make sure not to delete pertinent block information before it makes it to Sema, so that Sema can pop/restore around blocks correctly. * Why do this only for `const` and not `var`? * There is room to relax things for `var`, but only a little bit. We could do the same thing we do for const and keep the error trace alive for the remainder of the block where the *assignment* happens. Any wider scope would violate the stack discipline for traces, so it's not viable. In the end, I decided the most consistent behavior for the user is just to kill all error return traces assigned to a mutable `var`.
Previously, we'd overwrite the errors in a circular buffer. Now that error return traces are intended to follow a stack discipline, we no longer have to support the index rolling over. By treating the trace like a saturating stack, any pop/restore code still behaves correctly past-the-end of the trace. As a bonus, this adds a small blurb to let the user know when the trace saturated and x number of frames were dropped.
This PR (ziglang#12873) in combination with this particular test exposed a pre-existing bug (ziglang#13175). This means that the test for ziglang#13038 has regressed
Instead of adding 3 fields to every `Block`, this adds just one. The function-level information is saved in the `Sema` struct instead, which is created/copied more rarely.
1fe1f31
to
c36a2c2
Compare
Alright, final rebase (gods willing 🙏 ) I also added one small final commit that reduces the size impact to Let's let CI give us the green light and then bring this in! |
Congrats on the big merge, and thanks for your patience with all the rebases! Would you be willing to type me up some release notes for this change to demonstrate it? |
Thank you for taking this on @topolarity (and @Vexu for starting it). I think this being addressed will hugely benefit Zig overall, as needing to know that (a) some frames of error return traces needed to be ignored and (b) how to determine which frames should be ignored was both confusing for new Zig programmers and frustrating for experienced Zig programmers. Also, props on recognizing the need for and implementing a more complete solution than the original proposal in #1923. |
@andrewrk Sure thing! Can I get your take on #12990? I'm OK whether it lands or not but I wanted to ask since it affects the example
Thank you for the kind words @squeek502 - Always great to know when things are having an impact downstream 🙂 |
PR ziglang#12837 handled control flow for break and return, but I forgot about `continue`. This is effectively another break, so we just need another `.restore_err_ret_index` ZIR instruction. Resolves ziglang#13618.
This implements the (unapproved) error handling behavior described in #1923 (comment).
The result is a basic ownership-style algorithm for errors. Barring any bugs (🤞), it should guarantee that error return frames follow a strict stack discipline, which we need to correctly accumulate them in a single stack-per-thread.
Error return traces "live" when:
const
variablecatch { ... }
orelse |err| { ... }
blockreturn
ortry
Error return traces are killed when:
var
catch
orelse |err|
block with a non-error valueconst
variable associated with an error trace falls out of scopeHere's an example that tries to show most of this together:
The main caveat is that traces are lost when storing errors to a mutable
var
. For example:Note: There is room to relax this last condition for
var
, but only a little bit. We could do the same thing we do forconst
and keep the error trace alive for the remainder of the block where the assignment happens. Any wider scope will violate the strict stack discipline for error return traces, so it won't work. In the end, I decided the most consistent behavior for the user is just to kill error return traces when assigning tovar
.Supersedes #12825. Closes #1923.
(Many thanks @Vexu for that PR btw -- it was very helpful to tighten up some of my handling in AstGen)