Skip to content

deno_core: snapshotting a JsRuntimeForSnapshot restored from a snapshot drops inherited extension sources; the next restore segfaults #36910

Description

@nullsauce

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions