Repository navigation
Check for a pending exception in every Rust JSG shim that enters V8 - #8009
piscisaureus wants to merge 4 commits into
Conversation
9870093 to
53c2d46
Compare
|
Follow-up idea, not for this PR: make the rule part of the types instead of a runtime check in each shim. Today a call that can run JavaScript returns
Calling into V8 again after a failure would then be a compile error, not something reviewers have to catch. The guard from this PR would stay as a backstop. A termination requested from another thread only takes effect at V8's next stack check, so it can start inside any call, and a pending exception can also come from a C++ caller. This fits best in the rusty_v8-based bindings, where the borrow checker already tracks scopes. For comparison, rusty_v8 and Deno have neither a guard nor a type like this. Deno checks the |
A V8 API that takes a v8::Local<v8::Context> must not be entered while an exception or a termination is pending. Some of these APIs (those that open a PrepareForExecutionScope, in src/api/api-inl.h) clear the pending exception on entry, a termination included, so it is lost and JavaScript runs on as if nothing had happened. Others (EnterV8Scope) fail a V8 debug check, and in a release build run on with the exception still pending. Creating strings, numbers, objects, templates and error objects takes no context and is fine at any time, also while execution is terminating. Every C++ shim that calls such an API now first checks exceptionPending(), which the promise shims already used, and fails with an empty result or false, leaving the exception pending. Rust turns that into a pending jsg::Error as before. This covers Function::Call, property get and has, the coercions, Array::Iterate, Date::New, Function::New, the opaque wrapper, FunctionTemplate::GetFunction and String::Utf8Value. In wrap_resource, the check replaces one that looked only for a termination. Utf8Value::new can't report the failure yet and gives an empty value; local_object_set_property, local_array_get and local_array_set have no result in which to report it, and say so. The next commit changes both. The check replaces the isolate_is_execution_terminating pre-check that call_into_js() made before every such call. That cost an extra FFI call per operation and missed an ordinary pending exception. The pre-check in Function::new goes the same way. exceptionPending() moves to ffi.h as an inline function. Operations that would not run JavaScript for a given value now also fail while an ordinary exception is pending, not only while execution is terminating: converting a string or number primitive, and wrapping a resource that already has a wrapper. They fail the same way whatever the value, which is simpler to reason about than a check per value. "Pending" includes an exception that the innermost v8::TryCatch has caught and not yet reset, as v8::Isolate::HasPendingException() reports it. C++ code that catches with its own v8::TryCatch must reset it before it calls into Rust JSG; jsg::Lock::tryCatch() and JSG_CATCH leave the TryCatch's scope first. API changes: function_template_of and Global<FunctionTemplate>::as_local_function now return a Result, so that an empty result from GetFunction becomes a pending error rather than a panic through jsg::check. Creating the data of a function from a Rust closure passes on a C++ exception, such as a failed KJ_REQUIRE, instead of reporting it as a pending exception when none is pending. The docs of ToJS, resource::wrap, Date::new and the conversions say that they also fail while an ordinary exception is pending. Comments that said creating an error object while terminating fails a V8 debug check are corrected: it does not. Tests: getting a constructor and Utf8Value while terminating keep the termination and run no JavaScript. With an ordinary exception pending, a call, a coercion, a property read, creating a Date, wrapping a new or an existing resource, getting a constructor and Utf8Value run nothing, and the exception reaches JavaScript unchanged.
53c2d46 to
33d83e0
Compare
Rust JSG treats an exception that the innermost `v8::TryCatch` has caught and not yet reset as pending, as `v8::Isolate::HasPendingException()` reports it, and its shims fail while one is. Two places caught an exception and then ran JavaScript that can call into Rust JSG: * `handleLog()` stringifies `console.log()` arguments for the trace under one `TryCatch`. After one argument threw, the `toJSON()` or `toString()` of the next ran with that exception still caught. * `IoContext::runImpl()` logged and tunneled an exception while its `TryCatch` still held it. Both read the exception's properties, which can run getters. A call into `node:url` from that JavaScript returned undefined without throwing. Both now reset the `TryCatch` first. Its destructor resets it anyway, so nothing else changes.
Object::set, Array::set, ArrayBuffer::detach and Utf8Value::new now check for a pending exception before they enter V8, as the other shims do, and return a Result. That removes the last exceptions to the rule that every shim passing a context to V8 checks first. * Object::set and Array::set leave what a setter or Proxy trap throws pending and fail with a pending error, as Object::get does, rather than turning it into a panic through jsg::check. With that, ffi.c++ uses jsg::check no more, and its allowlist entry goes. * ToJS for Vec<T> builds the array with v8::Array::New(isolate, elements, length), as C++ JSG's wrap() does. That defines the elements, so no setter on Array.prototype runs, and it takes no context. A #[jsg_struct]'s ToJS sets its fields, as C++ JSG's slow path does, and passes on what a setter throws. * ArrayBuffer::detach runs no JavaScript, but V8 enters it with the current context and does not allow that while execution is terminating. * Utf8Value::new converts with ToString itself and leaves what it throws pending. v8::String::Utf8Value would catch and discard it, and read as an empty string. A Symbol now fails with V8's TypeError rather than giving an empty value. * The node exception helpers pass on what setting `code`, `syscall`, `path` or `dest` throws. Their C++ callers already turn an empty result into JsExceptionThrown. * local_array_get, which nothing used, is removed. API changes: Object::set, Array::set, ArrayBuffer::detach and Utf8Value::new return a Result; callers pass the error on with `?`. Array::set takes a u32 index, as V8 does, rather than a usize that it truncated. The README's "Errors from JavaScript" now lists Utf8Value::new, Object::set and Array::set among the operations that leave an exception pending, instead of saying that set panics. Tests: an exception that a setter throws reaches JavaScript unchanged, for Object::set and Array::set; setting while terminating or with an exception pending runs nothing; a returned Vec runs no setter on Array.prototype; a struct conversion passes on what a setter on Object.prototype throws; detaching while terminating or with an exception pending detaches nothing; Utf8Value of a Symbol or of a value whose toString throws fails with the exception pending.
Rule 1 was "Never drop or replace a pending termination": don't throw while execution is terminating, and don't discard an error that may be the termination. It didn't say which V8 APIs may be entered while an exception or a termination is pending, which is what the shims now check. A V8 API that takes a context must not be entered then: some clear the exception on entry, so it is lost and JavaScript runs on, and others fail a debug check and in a release build run on with it pending. Creating strings, numbers, objects, templates and error objects takes no context and is fine at any time. Rule 1 in the README and the AGENTS files, the review checklist and the review prompts now state that rule. They say that every shim that passes a context to V8 first checks exceptionPending(), or says why it needn't, that the check also sees an exception a v8::TryCatch has caught, so C++ code resets its TryCatch before calling into Rust JSG, and that a pending error from Object::set or ToJS::to_js must be passed on like any other. They tell reviewers not to flag creating values while execution is terminating.
33d83e0 to
726ff5f
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8009 +/- ##
==========================================
- Coverage 41.95% 41.94% -0.02%
==========================================
Files 1064 1065 +1
Lines 302162 302266 +104
Branches 29082 29099 +17
==========================================
+ Hits 126775 126781 +6
- Misses 158350 158446 +96
- Partials 17037 17039 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This builds on #7997, which made Rust JSG's
ToJSfallible and gavewrap_resourcea check for a termination. This change applies thatcheck to every shim that enters V8 with a context, and to any pending
exception, not only a termination.
What V8 does: an API that takes a
v8::Local<v8::Context>must not beentered while an exception or a termination is pending. How it goes
wrong depends on how the API enters V8 (
src/api/api-inl.h):PrepareForExecutionScopeclears the pending exception on entry, atermination included. The exception is lost and JavaScript runs on
as if nothing had happened.
Object::Get,ToStringof anon-string,
ObjectTemplate::NewInstance,FunctionTemplate::GetFunction,Function::New,Date::Newand thepromise APIs enter this way.
EnterV8ScopeandEnterV8NoScriptScopedon't clear it. They faila debug check, and in a release build run on with the exception
still pending. Through
EnterV8Scopethat can run user JavaScriptwhile an exception is in flight.
Function::Call,Object::Has,Object::SetandNumberValueenter this way, andArrayBuffer::DetachthroughEnterV8NoScriptScope.arrays, typed arrays, error objects and templates takes no context
and is fine at any time, also while execution is terminating.
Before this change,
call_into_js()checkedisolate_is_execution_terminatingfrom Rust before each shim. Thatcost an extra FFI call per operation, missed an ordinary pending
exception, and didn't cover the shims that don't go through
call_into_js(). A few operations (Object::set,Array::set,ArrayBuffer::detach,Utf8Value::new) could not report a pendingexception at all.
exceptionPending(isolate)(a termination orHasPendingException(), which the promise shims already used) andfails with an empty result or
falsewithout entering V8. Rustturns that into a pending
jsg::Error, as it does for an exceptionthe call itself throws. A shim that skips the check says why in a
comment (
local_type_of). The Rust pre-check incall_into_js()and in
Function::newis removed.Object::set,Array::set,ArrayBuffer::detachandUtf8Value::newnow return aResult. Set leaves what a setter orProxy trap throws pending, rather than panicking through
jsg::check, soffi.c++no longer usesjsg::check.Array::settakes a
u32index, as V8 does.Utf8Value::newconverts withToStringitself, becausev8::String::Utf8Valuecatches anddiscards what
toStringthrows; a Symbol now fails with V8'sTypeErrorinstead of reading as an empty string.function_template_ofandas_local_functionreturn aResulttoo.
ToJSforVec<T>builds the array withv8::Array::New(isolate, elements, length), as C++ JSG does. Thattakes no context and runs no setter on
Array.prototype. A#[jsg_struct]'sToJSand the node exception helpers pass on whatsetting a property throws.
prompts state the rule as V8 requires it, and tell reviewers not to
flag creating values while execution is terminating. Comments that
said creating an error object while terminating fails a debug check
are corrected.
pending_exception.rsand intermination.rs,coercion_safety.rs,resource_callback.rs,string.rs,arrays.rsandjsg_struct.rs: with an exception or a terminationpending, each guarded operation runs no JavaScript and the exception
reaches JavaScript unchanged; what a setter or
toStringthrows ispassed on. Removing any guard except
function_new's fails a test.function_newis called only after the opaque wrapper, whose shimalready checks.
Design choice: the check lives in each C++ shim, next to the V8 call
it protects, and it fails whatever the value. Converting a string
primitive or wrapping a resource that already has a wrapper would run
no JavaScript, but these fail too while an exception is pending. One
rule per shim is easier to review than a check that depends on the
value, and these calls have no reason to run while an exception is
propagating.
"Pending" includes an exception that the innermost
v8::TryCatchhascaught and not yet reset, because that is what
HasPendingException()reports. So C++ code that catches with its own
v8::TryCatchmustreset it before it calls into Rust JSG, or runs JavaScript that might.
jsg::Lock::tryCatch()andJSG_TRY/JSG_CATCHalready leave theTryCatch's scope first. Two places did not, and now reset first:handleLog(), which kept stringifyingconsole.log()arguments forthe trace after one threw, and
IoContext::runImpl(), which logged andtunneled an exception while its
TryCatchstill held it. Reading theexception's properties can run getters. A test in
caught-exception-reset-testcallsnode:urlfrom both.Alternatives considered:
call_into_js(), widened to any pendingexception. It costs an FFI call per operation, and the shims that
don't go through
call_into_js()stay unguarded.wrap_resource.That still lets JavaScript run with an ordinary exception pending,
which V8 allows in release builds and which can replace or hide the
first exception.
v8::TryCatch. That catches what the callthrows, but does nothing about an exception that is already
pending, and would hide exceptions that must keep propagating.
#[jsg_struct]'sToJSdefine its fields, as C++ JSG'sfastJsgStructpath does with aDictionaryTemplate, so that nosetter runs. Rust JSG's structs follow C++ JSG's slow path, which
sets fields and omits absent optional ones. Changing that is a
separate behaviour change.