Repository navigation
Add realm-aware trusted scripts and native execution-state queries - #106
Merged
Merged
Conversation
…uting_js Embedders implementing web platform semantics need two things N-API does not offer. Trusted Types' TrustedScript objects must be valid eval/Function sources: direct eval has to keep the caller's lexical scope, and author code must not change the evaluated source through toString, Symbol.toPrimitive, a prototype swap or a forged copy. unofficial_napi_create_trusted_script returns a V8 code-like object (ObjectTemplate::SetCodeLike) that keeps the source under a private symbol. A ModifyCodeGenerationFromStrings callback hands V8 that source for branded objects and changes nothing for other values, so unbranded objects are still returned by eval unchanged and a context with string code generation disabled still throws EvalError. The default prototype's native toString makes Function(trusted) work until an embedder installs its own prototype. HTML algorithms branch on whether the JavaScript execution context stack is empty. unofficial_napi_is_executing_js reports true when V8 has a JavaScript frame (across security origins) or a microtask checkpoint is running, including a contextify context's own queue. It runs no JavaScript and keeps any pending exception. QuickJS and the host-JS provider return napi_generic_failure for both (unsupported). Guest imports take u32 ids and result pointers and check the output pointer before calling the provider. Tests: a new test_66_unofficial_trusted_script runner (seven V8 cases, one QuickJS case) and a test_trusted_script manifest program that runs natively and as WASIX through napi_wasmer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_create_trusted_script built a new FunctionTemplate and instance template on every call. V8 caches each template instantiation in the native context, so the first calls stayed reachable after their trusted scripts were gone: used heap after GC grew by about 750 KB over 20k calls before the cache stopped growing. Keep one code-like ObjectTemplate per environment (napi_env__::trusted_script_template) and reuse it. The same 20k calls now retain about 1.6 KB. Behavior is unchanged: each trusted script still gets its own prototype with the native toString. CreationDoesNotRetainMemory creates 20k trusted scripts and checks used heap after GC grows by less than 128 KB; it fails without the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header now states that unofficial_napi_create_trusted_script installs an isolate-wide ModifyCodeGenerationFromStrings callback on every call, replacing any callback the embedder installed. V8 only exposes a setter for this callback, so the provider cannot read the previous one to chain it. For other values the hook keeps V8's behavior. It also states exactly what unofficial_napi_is_executing_js reports: a JavaScript frame on the VM stack (any context or security origin) or a running microtask checkpoint. A native function that C calls through napi_call_function with no JavaScript frame below it reports false; IsExecutingJsReportsFramesAndMicrotasks now pins that case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The brand was a v8::Private::ForApi symbol with a fixed name. ForApi symbols live in an isolate-wide registry, and unofficial_napi_create_private_symbol returns ForApi(desc) for any description, so code holding a private symbol made with that name could build a plain object that eval() treated as a trusted script, or rewrite a real trusted script's source. The brand is now a v8::Private::New symbol created once per isolate and kept in a provider map (dropped when the environment is destroyed), so nothing else can name it. The hook also requires the object to be code-like, which only the provider's template produces. BrandCannotBeForgedWithPrivateSymbol creates private symbols with the old and new descriptions, and checks that a forged object is returned unchanged by eval and that a real trusted script keeps its source; both fail without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_create_trusted_script reinstalled its isolate-wide ModifyCodeGenerationFromStrings callback on every call, so a callback the embedder installed later was silently overwritten by the next trusted script. Install it only when the isolate's brand key is first created. The header now says the first call replaces an existing embedder hook (V8 offers no getter, so it cannot be chained) and that a hook installed afterwards is kept. KeepsEmbedderHookInstalledLater installs an embedder hook after the first trusted script and checks that a later trusted script follows it; it fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_is_executing_js took the process-wide g_context_mu and scanned every environment's contextify records on each call, so worker isolates contended on it, and a call from a context token callback (which runs with that mutex held) deadlocked. A contextify context's own microtask queue is private to the provider and drained only by unofficial_napi_contextify_run_script. That drain now bumps a per-environment depth (napi_env__::contextify_microtask_checkpoints) on the owning thread, and is_executing_js reads it instead of scanning queues. NapiV8IsAnyContextifyMicrotaskQueueRunning is gone. IsExecutingJsFromContextTokenCallback calls is_executing_js from a context token assign callback; it deadlocks without the fix. Removing the depth update fails IsExecutingJsSeesOtherContexts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move-only header change. The declarations sat directly above the unofficial_napi_enqueue_microtask comment, which the context-activation PR (feat/contextify-enter-leave) also edits; with this move the two PRs merge without conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member
Author
|
Note: I'm not sure we should merge this |
Member
Author
|
Part of this PR can be simplified once #101 lands |
This was referenced Nov 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem and behavior
Embedders need direct eval to accept a native branded source without losing its caller's lexical scope, and need a reliable query for JavaScript execution across realms. This adds:
Both follow the merged realm model: one env serves the engine instance, creation uses the current N-API realm, and execution-state queries cover the agent's realms. The full contract is in
docs/trusted-scripts.md.Implementation
napi_agent__, without global maps or locks. The template does not retain its first creation realm; cleanup hooks can still use the feature while the agent is live.eval(trusted)reads the stored primitive string, preserves caller scope, and bypasses authortoString/Symbol.toPrimitive. Brands cannot be copied, inherited, forged by name, or forwarded through proxies. Eval uses the receiving realm's code-generation policy.JS_IsExecutingJS(JSRuntime*), recognizes ordinary/generator/async/async-generator bytecode frames, and tracks nested job execution through cleanup. C-only calls, suspended coroutines and queued jobs report false. Native-only executing jobs report true; errors and termination unwind the state. The query allocates nothing and does not inspect/clear exceptions.napi_okfor the query and preserve pending exceptions, including during termination. Entering a realm alone reports false. Calls stay on the owning thread; the bridge invokes neither operation underg_mu.Engine dependency and limits
The QuickJS pin advances from
870aa7bto883881a, published in wasmerio/quickjs#11. That dependency is merged into QuickJS master as78817fd; the pinned883881ais included in that merge. It contains the small native query and runs its focused C regression through the existingmake testtarget.create_trusted_scriptis_executing_jsnapi_generic_failure, NULL output)QuickJS trusted scripts require native eval unwrapping, an unforgeable GC-traced brand and receiving-realm code-generation policy; a provider shim cannot preserve direct-eval scope. Its existing runtime job queue is unchanged; this does not add V8-style per-realm queues.
Function(trusted)uses ordinary argument coercion, so author overrides can change its source. Only direct eval has the hardened conversion guarantee. The first V8 creation replaces an earlier embedder code-generation hook (V8 has no getter for chaining); a later hook is preserved. The hook cannot enforce a trusted-only policy while ordinary string code generation is allowed. The V8 query still captures a stack trace per call.Validation
macOS arm64, based on main
18209d2:The broader QuickJS Debug ASan + UBSan suite has 309 passes and four failures, all reproduced on the unchanged
317ed89PR head with QuickJS870aa7b: three invalid-enum UBSan failures (error metadata and module-state validation) andRecursionThroughRealmshitting the Debug sanitizer stack limit. These are documented existing failures, not a claimed clean full sanitizer run. ASan usesdetect_leaks=0; UBSan useshalt_on_error=1.The preceding realm-adaptation commit
317ed89also passed the full V8 ASan suite (375 passes, one existing sanitizer-specific skip), Rust library (63), concurrency/store/guest-callback integration (12 + one ignored / 2 / 4), host-JS node (49), host-JS wasm-bindgen (30), fmt and clippy. Its extended trusted-script program passes natively and through WASIX. This follow-up changes no Rust or host-JS implementation. No full manifest sweep, application build or performance improvement is claimed.