Version: deno_core 0.412.0 (crates.io) / denoland/deno main at b157cd2 (libs/core)
Summary
If a JsRuntimeForSnapshot is created with startup_snapshot (chained snapshot) and any extension whose JavaScript was compiled into that snapshot, snapshot() on it produces a blob that cannot be loaded: the next JsRuntime::new / JsRuntimeForSnapshot::new with that blob crashes with SIGSEGV in v8::internal::PostProcessExternalString during Isolate::Init. No Rust-level error is reported.
Chaining works without extensions (runtime/tests/snapshot.rs::will_snapshot2) and deno_core's own cold → warm-up build is fine, so the gap is specific to a restored runtime that snapshots again while extension sources are referenced from the heap. An embedder chaining snapshots (S0 → S1 → S2 …, e.g. a persistent REPL) hits it on the second generation.
Reproduction
Self-contained crate depending only on deno_core = "0.412.0": one extension with one lazy_loaded_js file, loaded at generation 0; snapshot; restore into a JsRuntimeForSnapshot with the same extension list; snapshot again; restore.
Cargo.toml
[package]
name = "chained_snapshot_repro"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
deno_core = "0.412.0"
tokio = { version = "1", features = ["rt"] }
lazy.js
// Copyright 2018-2026 the Deno authors. MIT license.
(function () {
const foo = "foo";
const bar = 123;
function blah(a) {
Deno.core.print(a);
}
return { foo, bar, blah };
})();
src/main.rs
//! Repro: snapshotting a runtime that was itself restored from a snapshot
//! loses the externalized extension sources; loading the result segfaults.
//!
//! S0 (fresh, extension loads one lazy script) --snapshot--> S1
//! restore S1 into JsRuntimeForSnapshot, run a cell --snapshot--> S2
//! restore S2 --> SIGSEGV in
//! v8::internal::PostProcessExternalString (Deserializer)
use deno_core::{JsRuntime, JsRuntimeForSnapshot, RuntimeOptions};
deno_core::extension!(repro_ext, lazy_loaded_js = ["lazy.js"]);
fn opts(snapshot: Option<&'static [u8]>) -> RuntimeOptions {
RuntimeOptions {
startup_snapshot: snapshot,
extensions: vec![repro_ext::init()],
..Default::default()
}
}
fn main() {
let tokio = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let _guard = tokio.enter();
// generation 0 -> S1: consume the lazy script so its externalized source
// is referenced from the heap
let s1 = {
let mut rt = JsRuntimeForSnapshot::new(opts(None));
rt.execute_script("a.js", "globalThis.a = Deno.core.loadExtScript('ext:repro_ext/lazy.js')").unwrap();
rt.snapshot()
};
let s1: &'static [u8] = Box::leak(s1);
eprintln!("S1: {} bytes", s1.len());
// restore S1 into a snapshot-capable runtime and snapshot again -> S2
let s2 = {
let mut rt = JsRuntimeForSnapshot::new(opts(Some(s1)));
rt.execute_script("b.js", "globalThis.b = typeof a").unwrap();
rt.snapshot()
};
let s2: &'static [u8] = Box::leak(s2);
eprintln!("S2: {} bytes", s2.len());
// restore S2: crashes on the unpatched crate
let mut rt = JsRuntime::new(opts(Some(s2)));
let v = rt.execute_script("c.js", "`${b}:${typeof Deno.core.loadExtScript('ext:repro_ext/lazy.js')}`").unwrap();
let text = {
deno_core::scope!(scope, rt);
deno_core::v8::Local::new(scope, &v).to_rust_string_lossy(scope)
};
println!("restored S2 OK: {text}");
}
$ cargo run -q
S1: 653515 bytes
S2: 674442 bytes
Segmentation fault (core dumped) # exit 139
Expected: restored S2 OK: object:object.
Backtrace (gdb, debug build, Linux x86_64, v8 crate 150.4.0 / V8 15.0.245.2):
#0 v8::internal::(anonymous namespace)::PostProcessExternalString(...)
#1 v8::internal::Deserializer<v8::internal::Isolate>::PostProcessNewObject(...)
...
#10 v8::internal::StartupDeserializer::DeserializeIntoIsolate()
#11 v8::internal::Isolate::Init(...)
#15 v8::SnapshotCreator::SnapshotCreator(v8::Isolate::CreateParams const&)
#20 deno_core::runtime::snapshot::create_snapshot_creator
#21 deno_core::runtime::setup::create_isolate
#22 deno_core::runtime::jsruntime::JsRuntime::new_inner
Analysis
When a snapshot is built, extension sources are externalized (bindings::externalize_sources): V8 stores them as external strings by external-reference index, and JsRuntimeForSnapshot::snapshot() writes their bytes into the sidecar (SnapshottedData::external_strings, counted by source_count). On load, new_inner places those inherited strings first in the external-reference table (snapshot_sources), followed by the runtime's own sources.
A runtime restored from such a snapshot loads no extension sources of its own (they already live in the blob), so IsolateAllocations::original_sources is empty and inner.source_count == 0. snapshot() builds the new sidecar from original_sources only, i.e. it writes an empty external_strings table and source_count: 0, while the heap it serializes still references every inherited string by index. Loading that blob resolves those references against an empty table.
Instrumenting snapshot() for a runtime with deno_web + deno_fs loaded shows it directly:
gen 0: source_count=29 original_sources=29 external_strings lens=[0, 0, 46298, 9897, ...]
gen 1: 29 inherited snapshot sources ... source_count=0 original_sources=0 external_strings lens=[]
The cold → warm-up path in runtime/snapshot.rs::create_snapshot does not hit this because the final consumer of the warm snapshot passes the full extension sources again at the same table positions and never snapshots a restored runtime.
Fix
Keep the inherited strings on IsolateAllocations and have snapshot() write them back ahead of the runtime's own sources, with source_count covering both. Table positions are unchanged, so any number of generations can be chained. PR to follow (failing test in one commit, fix in the next). 👉🏼 #36911
Caveat
With that change, the warm-up snapshot produced by create_snapshot also carries the inherited strings (real bytes for consumed lazy sources, empty for the rest). It still loads, since the final consumer's sources are appended after them, but it makes shipped snapshots larger by the consumed extension sources. If that is unwanted, the carry-over could be gated by a RuntimeOptions flag, or snapshot() could at least refuse (with a message) when source_count == 0 and inherited strings exist, instead of producing a blob that segfaults two steps later.
Version: deno_core 0.412.0 (crates.io) / denoland/deno
mainat b157cd2 (libs/core)Summary
If a
JsRuntimeForSnapshotis created withstartup_snapshot(chained snapshot) and any extension whose JavaScript was compiled into that snapshot,snapshot()on it produces a blob that cannot be loaded: the nextJsRuntime::new/JsRuntimeForSnapshot::newwith that blob crashes withSIGSEGVinv8::internal::PostProcessExternalStringduringIsolate::Init. No Rust-level error is reported.Chaining works without extensions (
runtime/tests/snapshot.rs::will_snapshot2) and deno_core's own cold → warm-up build is fine, so the gap is specific to a restored runtime that snapshots again while extension sources are referenced from the heap. An embedder chaining snapshots (S0 → S1 → S2 …, e.g. a persistent REPL) hits it on the second generation.Reproduction
Self-contained crate depending only on
deno_core = "0.412.0": one extension with onelazy_loaded_jsfile, loaded at generation 0; snapshot; restore into aJsRuntimeForSnapshotwith the same extension list; snapshot again; restore.Cargo.toml
lazy.js
src/main.rs
Expected:
restored S2 OK: object:object.Backtrace (gdb, debug build, Linux x86_64, v8 crate 150.4.0 / V8 15.0.245.2):
Analysis
When a snapshot is built, extension sources are externalized (
bindings::externalize_sources): V8 stores them as external strings by external-reference index, andJsRuntimeForSnapshot::snapshot()writes their bytes into the sidecar (SnapshottedData::external_strings, counted bysource_count). On load,new_innerplaces those inherited strings first in the external-reference table (snapshot_sources), followed by the runtime's own sources.A runtime restored from such a snapshot loads no extension sources of its own (they already live in the blob), so
IsolateAllocations::original_sourcesis empty andinner.source_count == 0.snapshot()builds the new sidecar fromoriginal_sourcesonly, i.e. it writes an emptyexternal_stringstable andsource_count: 0, while the heap it serializes still references every inherited string by index. Loading that blob resolves those references against an empty table.Instrumenting
snapshot()for a runtime withdeno_web+deno_fsloaded shows it directly:The cold → warm-up path in
runtime/snapshot.rs::create_snapshotdoes not hit this because the final consumer of the warm snapshot passes the full extension sources again at the same table positions and never snapshots a restored runtime.Fix
Keep the inherited strings on
IsolateAllocationsand havesnapshot()write them back ahead of the runtime's own sources, withsource_countcovering both. Table positions are unchanged, so any number of generations can be chained. PR to follow (failing test in one commit, fix in the next). 👉🏼 #36911Caveat
With that change, the warm-up snapshot produced by
create_snapshotalso carries the inherited strings (real bytes for consumed lazy sources, empty for the rest). It still loads, since the final consumer's sources are appended after them, but it makes shipped snapshots larger by the consumed extension sources. If that is unwanted, the carry-over could be gated by aRuntimeOptionsflag, orsnapshot()could at least refuse (with a message) whensource_count == 0and inherited strings exist, instead of producing a blob that segfaults two steps later.