Skip to content

Instantly share code, notes, and snippets.

@ast-hugger
Last active August 22, 2025 17:33
Show Gist options
  • Save ast-hugger/761bd47132ace4b7d1a2980de021655c to your computer and use it in GitHub Desktop.
Save ast-hugger/761bd47132ace4b7d1a2980de021655c to your computer and use it in GitHub Desktop.
How to work with ThrowScopes

ThrowScope is a mechanism for throwing JS exceptions and for ensuring their correct handling in C++ code.

If a function can throw a JS exception, whether directly or indirectly by calling other functions that throw exceptions, it should declare a ThrowScope at the top of the function using the DECLARE_THROW_SCOPE macro. The scope supports throwing exceptions and in debug builds verifies that the required exception checks have been missed. Its presence is also an indication that the function can throw, and its callers should check for exceptions as explained below.

Let's consider this example of a function that calls two other functions, both of which may throw JS exceptions. We give it a throw scope and initially write the code as follows:

int functionA(VM& vm) {
	auto scope = DECLARE_THROW_SCOPE(vm);
	int x = functionB(vm);
	int y = functionC(vm);
	return x + y;
}

As written, this is wrong and will fail scope validation.

The problem here is that control flows incorrectly when exceptions are thrown by the called functions. Suppose functionB throws a JS exception. Throwing in JSC simply means changing the state of the VM object. It does not by itself alter the control flow: functionB returns normally after throwing. At this point the correct implementation should examine the VM for a thrown exception and do two things if it finds one:

  • Avoid using the returned value (stored in x) because this was an abnormal return. The return value is meaningless and only there because the function had to return something.
  • Avoid any observable computation until returning from functionA. Often that means we should return immediately.

Here is a correct implementation using the helper RETURN_IF_EXCEPTION macro:

int functionA(VM& vm) {
	auto scope = DECLARE_THROW_SCOPE(vm);
	int x = functionB(vm);
	RETURN_IF_EXCEPTION(scope, { });
	int y = functionC(vm);
	RETURN_IF_EXCEPTION(scope, { });
	return x + y;
}

To put it another way, a correct implementation manually implements the normal exception propagation behavior. Any call that may throw is followed by an exception check that will immediately return to the caller function if an exception is "in flight".

To ensure the code checks for potential exceptions everywhere it should, in a debug build the scopes and the VM work together to verify exception checking. Here is what they expect.

Verification happens on two events: entering a new scope (i.e. constructing a new ThrowScope instance) and leaving a scope (i.e. destroying a function's ThrowScope before returning). On these two events, the scope being constructed or destroyed examines the state of the VM. Conceptually we can think of the relevant state as a boolean flag that is always set immediately after leaving a throw scope. The flag is cleared when the exception() getter function is called on a VM. In effect, the flag means that the VM might be holding an exception that hasn't been looked at yet.

A validation error is signaled in two situations:

  • The flag is set when entering a new scope.
  • The flag is set when leaving a scope.

The first situation is what would happen if the first of the two RETURN_IF_EXCEPTIONs were missing in our example. The flag would be set when leaving the ThrowScope of functionB, and would still be set when entering the new scope in C. This indicates that an exception that might have been thrown by B has not been checked, which is a problem because if B had actually thrown we should have returned from A instead of entering C.

The second situation is what happens if the second of the two RETURN_IF_EXCEPTIONs were missing. The flag would be set when leaving the ThrowScope of functionC, and would be still set when leaving the scope of A. This indicates that an exception potentially thrown by C has not been checked between leaving C and leaving A . That is a problem because in that space we might have used the potentially invalid result of C or performed observable computation.

To be fair, it could be argued that in our particular example we don't really need to check for an exception thrown by functionC. Adding x and y is not an observable computation, and computing x + y is harmless even if C has thrown and y it returned is meaningless. In the end, we would still return from A with an exception in the VM that was placed there by C, and the x + y value we return doesn't matter because it should be ignored by the caller.

In such situations, a ThrowScope can be "released" before returning from a function. Releasing a scope tells it not to do the unchecked exception verification upon exit. In effect, by releasing we are saying "there maybe an unchecked exception when we exit the function, and that is okay because whatever we do between the point it was thrown and the exit does not result in observable behavior different from the expected."

In some code in JSC scopes are released explicitly. Rewritten in that style our example would be:

int functionA(VM& vm) {
	auto scope = DECLARE_THROW_SCOPE(vm);
	int x = functionB(vm);
	RETURN_IF_EXCEPTION(scope, { });
	scope.release();
	return x + functionC(vm);
}

Often it is more convenient to use the RELEASE_AND_RETURN macro:

int functionA(VM& vm) {
	auto scope = DECLARE_THROW_SCOPE(vm);
	int x = functionB(vm);
	RETURN_IF_EXCEPTION(scope, { });
	RELEASE_AND_RETURN(scope, x + functionC(vm));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment