-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
stack traces for errors #651
Comments
I had once implemented stack trace support for Win32 C applications.
C pseudocode how it could be implemented:
Disadvantages of this mechanism:
This stack traces mechanism would allow to implement builtin |
Consider this example: fn onMessage() -> %void {
handleRequest() %% |err1| switch (err1) {
error.NoMem => return err1, // example 1
error.InvalidRequest => {
logError(err) %% |err2| switch (err2) {
error.IOError => return err2, // example 2
};
return err1; // example 3
},
};
} Say Say Say we get to |
The return-point stack trace, when we come back from If we return at example 1, then the return-point stack trace gets a new address appended which is the address of the return instruction at example 1. It gets truncated in the sense that a ring buffer truncates data, if and only if there is not enough pre-allocated items in the stack trace to handle the new entry. Why do you say this make the least amount of sense? This is exactly what a return-point trace is. It answers the question, "how did I get back to this location?" When we call Example 3 is exactly the same as example 1, because modifications to the stack trace data only happen in these two places:
|
Ok, I was thinking about "appending" backwards. This makes a little more sense. Here's my interpretation of what we'd get from example 1:
It's a little confusing that we get a trace of something inside
I suppose that's not in the trace, because it's not a stack trace but rather a return trace. Ok, let's do example 2:
Here we can see the trace jumping all over the place. Let's say in example 3 that logError() caught and ignored some error:
It's pretty mysterious how Maybe this is ok? It's more like a log of where in the source code errors happened rather than any kind of stack trace. Whenever a function returns an error, it adds an entry to the log. The log won't necessarily be a coherent history of control flow, but you get some clues about errors that happen. Is that the idea? |
All of these examples accurately describe the proposal, and yes I agree with the conclusion that this is the idea. In a lot of cases you have a single return error.Something followed by a chain of %returns. For this case it will be perfectly clear how the error originated. This will make it more attractive to use %return (or the proposed |
fixed with #684 |
This proposal depends on having failable functions instead of error union types (see #632).
We would like to get rid of the
%%x
prefix operator in favor of%return x
orx %% unreachable
, but one reason that is problematic is that, at least in debug and release-safe modes, if you do%%x
on an error instead of%return x
, you get a nice stack trace.Well, kind of. You get the stack trace of where you tried to unwrap the error, but not the stack trace of where the error was created and how it bubbled up the call stack.
This proposal describes a way to get access to this stack trace.
Consider this code. I will use the new syntax from proposal #632, where
%return x
is replaced withtry x
anda %% b
is replaced witha !! b
.Here, when we run the program, we're going to see either a successful exit code, or "ItBroke" and a failure exit code.
The problem is that if you get "ItBroke" that doesn't tell you where exactly the error originated from.
The printed stack trace will have only the main() function in it and nothing else.
Here's what I propose:
A function that can fail has a secret parameter passed to it which is a pointer to a stack trace node, which looks something like this:
When a failable function returns, it sets the
instruction_pointer_address
to the current instruction pointer.A function that calls a failable function has a
StackTraceNode
in its stack frame, and passes a pointer to it to the failable function that it calls. If the caller itself is failable, it setsnext
from the node that it was passed from its caller, to the node that it passes to its callee.As a result, at every callsite of a failable function, we have access to a full callstack of where the
error originated from and how it was passed up the stack. The data remains valid until a function call, at which point the data is obliterated. This strategy is incompatible with tail calls, which would have to be disabled for failable functions. This is a pretty big flaw.
We need language support to enable capturing the stack trace data in a way that can't accidentally lead to calling a function and accessing invalid memory.
Consider the example above again, but with a different
main
implementation with this proposal:Alright, this proposal can't work as is. Because what are we going to do with a linked list of instruction pointers that becomes invalid memory if we do so much as call
memcpy
? The only thing we would be able to do is iterate over it and copy it to a buffer whose size was known before the function call was made. At which point, why bother with using dead stack memory? We could just create this data structure beforehand.So here's how I'll alter the proposal to make it still work. We have a fixed size number of stack frames,
such as 31. In a non-failable function that calls a failable function, we allocate this structure on the stack and initialize it to all 0:
Once again we pass a pointer to this struct as a secret parameter to failable functions, and just before returning, we do:
This assumes that the deepest stack entries are the most significant. It is compatible with tail calls. Now our example code can look like:
The
printStackTrace
function can iterate over theStackTrace
object, and use DWARF or PDB info to turn instruction pointer addresses into a human readable stack trace.31 was chosen because 31 * 8 + 8 = 256 bytes on a 64 bit system, or 128 bytes on a 32 bit system, is a pretty reasonable amount of bytes to put on a stack. Note that it's not for every call, but 256 bytes in the stack frame of the first non-failable function to call a failable function.
We could potentially analyze the call graph, in a similar way that we want to do for #157, to find out the maximum number of function calls - tail calls included - that contain consecutive failable functions. This would give us our global value to use for the
instruction_pointer_addresses
array.Another possible modification to this proposal, is that we could enable this for every function. Then at any given point, you can use
@getReturnTrace()
to obtain aStackTrace
explaining not the stack of how you got to the current instruction, but the "return trace" of how you got back to the current instruction.This would prevent a lot of optimizations and should probably only be allowed in Debug mode. Functions with safety disabled should probably not contain the code to add themselves to this return trace. They would be mysteriously missing.
Doing this for failable functions, however, should probably be allowed in at least Debug and ReleaseSafe mode. For ReleaseFast mode, maybe this is still OK. But in any mode, we should omit the code if the stack trace object is never captured.
Examples
I realized that all of these have only a maximum stack depth of 2, but hopefully it still illustrates the point.
The text was updated successfully, but these errors were encountered: