14. November 2019
Exceptions in v8
How do exceptions work in v8? Let’s see.
I’ll use a little program to motivate our discussion:
function foo(a) {
if (a === true) throw new Error("Oh no.");
}
function bar(a) {
try {
foo(a);
} catch(e) {
print(e);
}
}
bar(true);
The bytecode for foo
looks like this (I cleaned up the output from --print-bytecode
slightly):
Generated bytecode for function: foo
Parameter count 2
Register count 2
Frame size 8
0 StackCheck
1 LdaTrue
2 TestEqualStrict a0, [0]
5 JumpIfFalse [19] (@ 24)
7 LdaGlobal [0], [1]
10 Star r0
12 LdaConstant [1]
14 Star r1
16 Ldar r0
18 Construct r0, r1-r1, [3]
23 Throw
24 LdaUndefined
25 Return
Constant pool (size = 2)
0: <String[#5]: Error>
1: <String[#6]: Oh no.>
Handler Table (size = 0)
The important thing here is that if the JumpIfFalse
isn’t taken, then we create an Error object, leaving it in the accumulator
for the Throw
bytecode. We don’t expect to ever return from that bytecode.
How strange and interesting…
We’ll get right into that, but first let me show you the bytecode for bar
as
well, because it has a catch handler that’ll be a point of interest for us:
Generated bytecode for function: bar
Parameter count 2
Register count 4
Frame size 16
0 StackCheck
1 Mov <context>, r0
4 LdaGlobal [0], [0]
7 Star r1
9 CallUndefinedReceiver1 r1, a0, [2]
13 Jump [30] (@ 43)
15 Star r1
17 CreateCatchContext r1, [1]
20 Star r0
22 LdaTheHole
23 SetPendingMessage
24 Ldar r0
26 PushContext r1
28 LdaGlobal [2], [4]
31 Star r2
33 LdaImmutableCurrentContextSlot [2]
35 Star r3
37 CallUndefinedReceiver1 r2, r3, [6]
41 PopContext r1
43 LdaUndefined
44 Return
Constant pool (size = 3)
0: <String[#3]: foo>
1: <ScopeInfo CATCH_SCOPE [5]>
2: <String[#5]: print>
Handler Table (size = 16)
from to hdlr (prediction, data)
( 4, 13) -> 15 (prediction=1, data=0)
Here you can see the call to foo
at offset 9, then a jump down to offset 43
where we return undefined (how boring). Offsets 15 to 41 are the catch handler.
The Handler Table
at the bottom lays this out. It shows the try region goes
from offsets 4 to 13, and the handler that corresponds to it starts at offset 15.
Very nice.
So what does the Throw
bytecode do? Like all bytecodes, the implementation
is provided in file interpreter-generator.cc:
// Throws the exception in the accumulator.
IGNITION_HANDLER(Throw, InterpreterAssembler) {
TNode<Object> exception = GetAccumulator();
TNode<Context> context = GetContext();
CallRuntime(Runtime::kThrow, context, exception);
// We shouldn't ever return from a throw.
Abort(AbortReason::kUnexpectedReturnFromThrow);
Unreachable();
}
Okay, that’s easy, we just call a runtime function. It’s nice to see the abort in here that documents our expectations. The runtime function (in runtime-internal.cc) is also simple, just returning the result of Isolate::Throw() back from C++ to the world of generated code:
RUNTIME_FUNCTION(Runtime_Throw) {
HandleScope scope(isolate);
DCHECK_EQ(1, args.length());
return isolate->Throw(args[0]);
}
You’d never suspect that we just skipped over something absolutely breathtaking,
but we did. Let’s return to it after a look at the end of the road, Isolate::Throw
:
Object Isolate::Throw(Object raw_exception, MessageLocation* location) {
DCHECK(!has_pending_exception());
HandleScope scope(this);
Handle<Object> exception(raw_exception, this);
...
... skipping over some interesting stuff to focus on the "bones" of
... the function.
...
// Set the exception being thrown.
set_pending_exception(*exception);
return ReadOnlyRoots(heap()).exception();
}
The main thing that happens here is that we save the exception object in
the isolate, and return a curious sentinel value in the global root set,
exception()
. We only indicate that there is an exception pending in the
system by saving this value, and don’t do anything exotic. The crazy stuff
is yet to come, awakened into hideous life by that innocuous sentinel value.
Here’s what happens now. When the runtime function returns this value to
generated code, it comes into some tricky platform dependent code we call
CEntryStub
. This code is responsible for building a frame and calling
into C++. It also recognizes pending exceptions and (gasp!) drops frames
from the stack in order to call the topmost handler. Let’s have a look, now in
builtins-ia32.cc,
method Builtins::Generate_CEntry
:
...
__ call(kRuntimeCallFunctionRegister);
// Result is in eax or edx:eax - do not destroy these registers!
// Check result for exception sentinel.
Label exception_returned;
__ CompareRoot(eax, RootIndex::kException);
__ j(equal, &exception_returned);
...
// Exit the JavaScript to C++ exit frame.
__ LeaveExitFrame(save_doubles == kSaveFPRegs, argv_mode == kArgvOnStack);
__ ret(0);
...
// Handling of exception.
__ bind(&exception_returned);
The code above is dutifully calling the runtime function requested. In our
case, it was Runtime_Throw
, but this body of code is used for the many
dozens of runtime functions we have. After the call, you can see we are
comparing the return value with that magic exception
sentinel (here it
has a different name, RootIndex::kException
. We’ve often got a few ways
to refer to something, depending on what kind of code you’re in. All part
of the fun…). If we don’t see it, we can return to the bytecode handler,
builtin or stub, getting somewhat closer to user code. Otherwise, we do
interesting things:
// Ask the runtime for help to determine the handler. This will set eax to
// contain the current pending exception, don't clobber it.
ExternalReference find_handler =
ExternalReference::Create(Runtime::kUnwindAndFindExceptionHandler);
{
FrameScope scope(masm, StackFrame::MANUAL);
__ PrepareCallCFunction(3, eax);
__ mov(Operand(esp, 0 * kSystemPointerSize), Immediate(0)); // argc.
__ mov(Operand(esp, 1 * kSystemPointerSize), Immediate(0)); // argv.
__ Move(esi,
Immediate(ExternalReference::isolate_address(masm->isolate())));
__ mov(Operand(esp, 2 * kSystemPointerSize), esi);
__ CallCFunction(find_handler, 3);
}
// Retrieve the handler context, SP and FP.
__ mov(esp, __ ExternalReferenceAsOperand(pending_handler_sp_address, esi));
__ mov(ebp, __ ExternalReferenceAsOperand(pending_handler_fp_address, esi));
__ mov(esi,
__ ExternalReferenceAsOperand(pending_handler_context_address, esi));
First we call back into C++, searching for an exception handler. We’ll
definitely have one. I skipped over a section of code in Isolate::Throw
that
would abort execution if there is no handler. I really enjoy what’s next:
we simply set the stack pointer and the frame pointer to the appropriate values
for a handler somewhere below us on the stack. What about dutifully
returning from all the functions we may have between here and there?
Nope!
A few lines later we begin executing that handler like this:
// Compute the handler entry address and jump to it.
__ mov(edi, __ ExternalReferenceAsOperand(pending_handler_entrypoint_address,
edi));
__ jmp(edi);
}
That final curly brace is just showing that these are the last lines of the CEntry stub. What a way to go, am I right?
Now it’s a good time to remember that we have a catch handler. It’s in
function bar()
, and it’s address is at offset 15 in the bytecode. So
whatever Runtime_UnwindAndFindExceptionHandler
does, it better return
precisely that information to the CEntryStub so that the stack, frame and
instruction pointer can be set appropriately.