Context
Investigation in denoland/v8#21 traced the snapshot-serialization regression Deno hit upgrading to V8 14.9. The Deno workaround in denoland/deno#34226 ships a runtime upgrade pass to re-attach fast calls after deserialization.
V8 main has since reverted the storage shape in crrev.com/c/7828135 ([fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo, merged 2026-05-12 at refs/heads/main@{#107265}). It will land in denoland/v8 with the V8 15.x roll. Once it does, the deno_core workaround can be deleted — but only if rusty_v8 first satisfies the new V8-side embedder invariant.
The new V8 invariant
Quoting the crrev.com/c/7828135 commit message:
This change relies on the assumption that the CFunction object passed to FunctionTemplate::New outlives the FunctionTemplate itself. In practice, all embedders — including Chrome — already ensure this by holding the CFunction object in a static variable.
In V8 14.9 each v8::CFunction was deep-copied into a heap-allocated CFunctionWithSignature wrapped in a Managed, so the caller's CFunction could be a temporary. In 15.x, V8 stores a raw v8::CFunction* directly in FunctionTemplateInfo. If that pointer goes dangling the JIT will dereference freed memory on the fast path.
The V8 CL converts every d8-test.cc declaration to static CFunction … to illustrate what embedders are expected to do.
What rusty_v8 currently does
FunctionTemplateBuilder::build_fast:
pub fn build_fast<'i>(
self,
scope: &PinScope<'s, 'i>,
overloads: &[CFunction], // borrowed slice
) -> Local<'s, FunctionTemplate>
forwards overloads.as_ptr() straight into v8__FunctionTemplate__New. The signature accepts any &[CFunction], including a stack-local &[fast_function] — which is exactly what deno_core's op_ctx_template passes:
let template = builder.build_fast(scope, &[fast_function]);
In 14.9 this is fine (V8 copies). After the 15.x roll it would silently UAF.
What we need rusty_v8 to do
Tighten the API so the lifetime contract is enforceable at the type level before V8 15.x lands:
- Option A (preferred): change
build_fast's overload-list parameter to &'static [CFunction] (or a newtype that internally requires 'static). Forces callers to put the CFunctions in a static, matching how Chrome does it.
- Option B: have rusty_v8 internally own the
CFunction storage — e.g. allocate it on an isolate-scoped arena keyed by the resulting FunctionTemplate. More expensive and more API to maintain, but transparent to callers.
Either way the change is API-breaking, so it should land before denoland/v8 picks up V8 15.x.
v8::FunctionTemplate::SetCallHandler (used via Isolate-level FunctionTemplate::new with overloads) and any other entry points that take MemorySpan<const v8::CFunction> need the same treatment.
Out of scope
- The
deno_core op-binding workaround (will_snapshot / upgrade_snapshotted_ops_with_fast_calls) — that gets removed in deno_core after rusty_v8 lands the lifetime tightening and denoland/v8 rolls 15.x with crrev.com/c/7828135.
- Backporting the V8 fix to
14.9-lkgr-denoland — not worth it; the workaround is in production and the API churn would be bigger than the benefit.
Pointers
Context
Investigation in denoland/v8#21 traced the snapshot-serialization regression Deno hit upgrading to V8 14.9. The Deno workaround in denoland/deno#34226 ships a runtime upgrade pass to re-attach fast calls after deserialization.
V8 main has since reverted the storage shape in crrev.com/c/7828135 (
[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo, merged 2026-05-12 atrefs/heads/main@{#107265}). It will land indenoland/v8with the V8 15.x roll. Once it does, thedeno_coreworkaround can be deleted — but only if rusty_v8 first satisfies the new V8-side embedder invariant.The new V8 invariant
Quoting the crrev.com/c/7828135 commit message:
In V8 14.9 each
v8::CFunctionwas deep-copied into a heap-allocatedCFunctionWithSignaturewrapped in aManaged, so the caller'sCFunctioncould be a temporary. In 15.x, V8 stores a rawv8::CFunction*directly inFunctionTemplateInfo. If that pointer goes dangling the JIT will dereference freed memory on the fast path.The V8 CL converts every
d8-test.ccdeclaration tostatic CFunction …to illustrate what embedders are expected to do.What rusty_v8 currently does
FunctionTemplateBuilder::build_fast:forwards
overloads.as_ptr()straight intov8__FunctionTemplate__New. The signature accepts any&[CFunction], including a stack-local&[fast_function]— which is exactly whatdeno_core'sop_ctx_templatepasses:In 14.9 this is fine (V8 copies). After the 15.x roll it would silently UAF.
What we need rusty_v8 to do
Tighten the API so the lifetime contract is enforceable at the type level before V8 15.x lands:
build_fast's overload-list parameter to&'static [CFunction](or a newtype that internally requires'static). Forces callers to put theCFunctions in astatic, matching how Chrome does it.CFunctionstorage — e.g. allocate it on an isolate-scoped arena keyed by the resultingFunctionTemplate. More expensive and more API to maintain, but transparent to callers.Either way the change is API-breaking, so it should land before
denoland/v8picks up V8 15.x.v8::FunctionTemplate::SetCallHandler(used viaIsolate-levelFunctionTemplate::newwith overloads) and any other entry points that takeMemorySpan<const v8::CFunction>need the same treatment.Out of scope
deno_coreop-binding workaround (will_snapshot/upgrade_snapshotted_ops_with_fast_calls) — that gets removed indeno_coreafter rusty_v8 lands the lifetime tightening anddenoland/v8rolls 15.x with crrev.com/c/7828135.14.9-lkgr-denoland— not worth it; the workaround is in production and the API churn would be bigger than the benefit.Pointers
crbug.com/492077213docs/fast-call-snapshot-investigation.md