Skip to main content

agentos_v8_runtime/
snapshot.rs

1// V8 startup snapshots: fast isolate creation from pre-compiled bridge code
2
3use std::collections::HashMap;
4use std::io::{Read, Write};
5use std::process::{Command, Stdio};
6use std::sync::{Arc, Condvar, Mutex};
7
8use sha2::{Digest, Sha256};
9
10use crate::bridge::{external_refs, register_stub_bridge_fns};
11use crate::isolate::init_v8_platform;
12use crate::session::{async_bridge_fns, sync_bridge_fns};
13
14/// Maximum allowed snapshot blob size (50MB).
15/// Prevents resource exhaustion from degenerate bridge code.
16const MAX_SNAPSHOT_BLOB_BYTES: usize = 50 * 1024 * 1024;
17const MAX_V8_BRIDGE_CODE_BYTES: usize = 16 * 1024 * 1024;
18/// Userland (agent-SDK) bundles are whole dependency graphs flattened into one
19/// IIFE, so they are larger than the bridge. Bounded so a degenerate bundle cannot
20/// exhaust memory, but generous enough for a real SDK (the pi bundle is ~7.6 MB).
21const MAX_V8_USERLAND_CODE_BYTES: usize = 32 * 1024 * 1024;
22pub(crate) const V8_BRIDGE_CODE_LIMIT_ERROR_CODE: &str = "ERR_V8_BRIDGE_CODE_LIMIT";
23pub(crate) const V8_USERLAND_CODE_LIMIT_ERROR_CODE: &str = "ERR_V8_USERLAND_CODE_LIMIT";
24
25const SNAPSHOT_HELPER_ENV: &str = "AGENTOS_V8_SNAPSHOT_HELPER";
26const SNAPSHOT_HELPER_MAGIC: &[u8; 8] = b"SEV8SNP1";
27const SNAPSHOT_HELPER_OK: &[u8; 2] = b"OK";
28const SNAPSHOT_HELPER_ERR: &[u8; 3] = b"ERR";
29const SNAPSHOT_HELPER_NONE_LEN: u64 = u64::MAX;
30
31#[cfg(any(target_os = "linux", target_os = "android"))]
32#[used]
33#[link_section = ".init_array"]
34static SNAPSHOT_HELPER_CTOR: extern "C" fn() = snapshot_helper_ctor;
35
36// Same pre-main hook on macOS: the darwin sidecar build ships this crate, and
37// without the constructor the helper child would fall through to the normal
38// main and the parent's snapshot request would fail.
39#[cfg(target_os = "macos")]
40#[used]
41#[link_section = "__DATA,__mod_init_func"]
42static SNAPSHOT_HELPER_CTOR: extern "C" fn() = snapshot_helper_ctor;
43
44#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
45extern "C" fn snapshot_helper_ctor() {
46    if std::env::var_os(SNAPSHOT_HELPER_ENV).is_some() {
47        let code = run_snapshot_helper_from_stdio();
48        std::process::exit(code);
49    }
50}
51
52pub(crate) fn validate_bridge_code_size(bridge_code: &str) -> Result<(), String> {
53    if bridge_code.len() > MAX_V8_BRIDGE_CODE_BYTES {
54        return Err(format!(
55            "{V8_BRIDGE_CODE_LIMIT_ERROR_CODE}: bridge code too large for V8 bridge setup: {} bytes (max {})",
56            bridge_code.len(),
57            MAX_V8_BRIDGE_CODE_BYTES
58        ));
59    }
60
61    Ok(())
62}
63
64pub(crate) fn validate_userland_code_size(userland_code: &str) -> Result<(), String> {
65    if userland_code.len() > MAX_V8_USERLAND_CODE_BYTES {
66        return Err(format!(
67            "{V8_USERLAND_CODE_LIMIT_ERROR_CODE}: userland snapshot code too large: {} bytes (max {})",
68            userland_code.len(),
69            MAX_V8_USERLAND_CODE_BYTES
70        ));
71    }
72
73    Ok(())
74}
75
76/// Runs after the bridge but before the userland (agent-SDK) IIFE during snapshot
77/// creation. Replaces bridge-backed lazy getters that would dispatch a host call
78/// (unavailable when bridge fns are stubs) with static, snapshot-safe values. These
79/// are environment-identity values (not per-session config), so baking them is
80/// correct; per-session config is still injected post-restore.
81const SNAPSHOT_USERLAND_PREP: &str = r#"
82(function () {
83    // Agent-SDK bundles are esbuild IIFEs that expect a global CJS `require` for
84    // their node-builtin imports, but the bridge exposes require only via module
85    // wrappers. Bind one from the bridge's namespaced createRequire so the bundle's
86    // __require resolves builtins (via the in-context loadBuiltinModule) during
87    // snapshot eval; it also works post-restore (resolution flows through the real
88    // bridge fns swapped in after restore).
89    if (typeof globalThis.require === "undefined" &&
90        typeof globalThis.__secureExecGuestCreateRequire === "function") {
91        try {
92            globalThis.require = globalThis.__secureExecGuestCreateRequire("/root/index.js");
93        } catch (e) {}
94    }
95    // `process.versions` is a bridge-backed lazy getter: it derives `.node` from the
96    // per-session `config2.version` and merges a host bridge call. During snapshot
97    // creation the bridge fns are stubs AND the default `_processConfig` has no
98    // `version`, so the live getter THROWS — an agent SDK that reads it at
99    // module-init would fail. Rather than REPLACE the getter with a static value
100    // (which would permanently shadow the real per-session version for every
101    // restored session — they'd all read a frozen, fabricated identity), WRAP it:
102    // defer to the live getter so that post-restore — once the real bridge fns and
103    // per-session config are injected — sessions read accurate per-session versions,
104    // and fall back to the static, snapshot-safe identity only while the live getter
105    // throws (i.e. during snapshot creation, before any real config exists).
106    if (typeof process !== "undefined" && process) {
107        var staticVersions = { node: "20.0.0", v8: "12.0.0", uv: "1.0.0", modules: "115" };
108        try {
109            var __verDesc = Object.getOwnPropertyDescriptor(process, "versions");
110            var __liveVersions = __verDesc && __verDesc.get;
111            Object.defineProperty(process, "versions", {
112                configurable: true,
113                enumerable: true,
114                get: function () {
115                    if (__liveVersions) {
116                        try {
117                            var v = __liveVersions.call(this);
118                            // A real per-session result has a node version; during
119                            // snapshot creation the live getter throws before here.
120                            if (v && typeof v === "object" && v.node) return v;
121                        } catch (e) {}
122                    }
123                    return staticVersions;
124                },
125            });
126        } catch (e) {}
127    }
128})();
129"#;
130
131/// Compile and run a Script in the snapshot-creation context, returning a
132/// descriptive error (with the V8 exception message) on failure. `label`
133/// identifies the phase (e.g. "bridge code" / "userland code") in error text.
134fn run_snapshot_script(
135    scope: &mut v8::HandleScope,
136    code: &str,
137    label: &str,
138    eager_compile: bool,
139) -> Result<(), String> {
140    let try_catch = &mut v8::TryCatch::new(scope);
141    let source = match v8::String::new(try_catch, code) {
142        Some(source) => source,
143        None => return Err(format!("failed to create V8 string for {label}")),
144    };
145    // NOTE(perf, measured 2026-07-02): EagerCompile moves cost into the snapshot
146    // blob: user_code_execute dropped 8.9ms -> 7.9ms on the wasm-runner floor,
147    // but isolate_new rose 4.0ms -> 7.4ms when isolate creation was still
148    // per-exec. Parked warm workers now prepay snapshot deserialization off-path,
149    // so only the userland script opts into eager compilation; bridge code and
150    // userland prep stay lazy.
151    let script = if eager_compile {
152        let resource_name = v8::String::new(try_catch, label).unwrap();
153        let origin = v8::ScriptOrigin::new(
154            try_catch,
155            resource_name.into(),
156            0,
157            0,
158            false,
159            0,
160            None,
161            false,
162            false,
163            false,
164            None,
165        );
166        let mut source = v8::script_compiler::Source::new(source, Some(&origin));
167        v8::script_compiler::compile(
168            try_catch,
169            &mut source,
170            v8::script_compiler::CompileOptions::EagerCompile,
171            v8::script_compiler::NoCacheReason::NoReason,
172        )
173    } else {
174        v8::Script::compile(try_catch, source, None)
175    };
176    let Some(script) = script else {
177        let message = try_catch
178            .exception()
179            .map(|exception| exception.to_rust_string_lossy(try_catch))
180            .unwrap_or_else(|| format!("{label} compilation failed during snapshot creation"));
181        return Err(format!(
182            "{label} compilation failed during snapshot creation: {message}"
183        ));
184    };
185    if script.run(try_catch).is_none() {
186        let message = try_catch
187            .exception()
188            .map(|exception| exception.to_rust_string_lossy(try_catch))
189            .unwrap_or_else(|| format!("{label} execution failed during snapshot creation"));
190        return Err(format!(
191            "{label} execution failed during snapshot creation: {message}"
192        ));
193    }
194    Ok(())
195}
196
197/// Create a V8 startup snapshot with a fully-initialized bridge context.
198///
199/// Registers stub bridge functions on the global, injects default config
200/// globals, then compiles and executes the bridge IIFE. The resulting
201/// context — with all bridge infrastructure set up — is snapshotted.
202///
203/// After restore, stub bridge functions are replaced with real session-local
204/// ones, and per-session config is injected via a post-restore script.
205///
206/// Returns an error if the bridge code fails to compile or the resulting
207/// snapshot exceeds MAX_SNAPSHOT_BLOB_BYTES.
208pub fn create_snapshot(bridge_code: &str) -> Result<Vec<u8>, String> {
209    create_snapshot_inner(bridge_code, None)
210}
211
212/// Create a V8 startup snapshot whose default context has BOTH the bridge
213/// infrastructure AND an evaluated userland module graph (e.g. a bundled agent
214/// SDK) captured into it.
215///
216/// `userland_code` is an IIFE (esbuild `format:'iife'`) that runs after the bridge
217/// in the same context and publishes its evaluated exports on `globalThis`. Because
218/// the bridge has already installed node-builtin polyfills and stub bridge fns at
219/// this point, the userland code can `require`/reference them while it evaluates.
220/// The whole post-evaluation heap is frozen into the blob, so restoring a fresh
221/// isolate skips re-evaluating the SDK entirely — collapsing the per-session
222/// module-load/eval tax. Per-session bridge fns and config are still swapped/injected
223/// post-restore exactly as for the bridge-only snapshot.
224pub fn create_snapshot_with_userland(
225    bridge_code: &str,
226    userland_code: &str,
227) -> Result<Vec<u8>, String> {
228    create_snapshot_inner(bridge_code, Some(userland_code))
229}
230
231fn create_snapshot_inner(
232    bridge_code: &str,
233    userland_code: Option<&str>,
234) -> Result<Vec<u8>, String> {
235    validate_bridge_code_size(bridge_code)?;
236    if let Some(userland_code) = userland_code {
237        validate_userland_code_size(userland_code)?;
238    }
239
240    create_snapshot_in_subprocess(bridge_code, userland_code)
241}
242
243fn create_snapshot_inner_in_process(
244    bridge_code: &str,
245    userland_code: Option<&str>,
246) -> Result<Vec<u8>, String> {
247    validate_bridge_code_size(bridge_code)?;
248    if let Some(userland_code) = userland_code {
249        validate_userland_code_size(userland_code)?;
250    }
251
252    init_v8_platform();
253
254    crate::isolate::with_isolate_lifecycle_lock(|| {
255        let mut isolate = v8::Isolate::snapshot_creator(Some(external_refs()), None);
256        let bridge_result = {
257            let scope = &mut v8::HandleScope::new(&mut isolate);
258            let context = v8::Context::new(scope, Default::default());
259            let scope = &mut v8::ContextScope::new(scope, context);
260
261            // Register stub bridge functions so the IIFE can reference them.
262            let sync_bridge_fns = sync_bridge_fns();
263            let async_bridge_fns = async_bridge_fns();
264            register_stub_bridge_fns(scope, sync_bridge_fns, async_bridge_fns);
265
266            // Inject default config globals for bridge IIFE setup
267            inject_snapshot_defaults(scope);
268
269            // Compile and run bridge code — context captures fully-initialized state.
270            // Then, if present, run the userland (agent-SDK) IIFE in the SAME context so
271            // its evaluated graph is captured alongside the bridge.
272            let result = (|| -> Result<(), String> {
273                run_snapshot_script(scope, bridge_code, "bridge code", false)?;
274                if let Some(userland_code) = userland_code {
275                    // Some bridge-backed globals (e.g. `process.versions`) are lazy
276                    // getters that dispatch a host bridge call on first access. During
277                    // snapshot creation the bridge fns are stubs, so an agent SDK that
278                    // reads them at module-init would fail. Freeze them to static values
279                    // first; per-session config is still injected post-restore.
280                    run_snapshot_script(scope, SNAPSHOT_USERLAND_PREP, "userland prep", false)?;
281                    run_snapshot_script(scope, userland_code, "userland code", true)?;
282                }
283                Ok(())
284            })();
285
286            scope.set_default_context(context);
287            result
288        };
289        let blob = isolate
290            .create_blob(v8::FunctionCodeHandling::Keep)
291            .ok_or_else(|| "V8 snapshot creation failed".to_string())?;
292        bridge_result?;
293
294        // Reject oversized snapshots
295        if blob.len() > MAX_SNAPSHOT_BLOB_BYTES {
296            return Err(format!(
297                "snapshot blob too large: {} bytes (max {})",
298                blob.len(),
299                MAX_SNAPSHOT_BLOB_BYTES
300            ));
301        }
302
303        Ok(blob.to_vec())
304    })
305}
306
307fn create_snapshot_in_subprocess(
308    bridge_code: &str,
309    userland_code: Option<&str>,
310) -> Result<Vec<u8>, String> {
311    let current_exe =
312        std::env::current_exe().map_err(|error| format!("snapshot helper path: {error}"))?;
313    let mut child = Command::new(current_exe)
314        .env(SNAPSHOT_HELPER_ENV, "1")
315        .stdin(Stdio::piped())
316        .stdout(Stdio::piped())
317        .stderr(Stdio::piped())
318        .spawn()
319        .map_err(|error| format!("spawn snapshot helper: {error}"))?;
320
321    {
322        let mut stdin = child
323            .stdin
324            .take()
325            .ok_or_else(|| String::from("snapshot helper stdin unavailable"))?;
326        write_snapshot_helper_request(&mut stdin, bridge_code, userland_code)?;
327    }
328
329    let output = child
330        .wait_with_output()
331        .map_err(|error| format!("wait for snapshot helper: {error}"))?;
332    if !output.status.success() {
333        let stderr = String::from_utf8_lossy(&output.stderr);
334        return Err(format!(
335            "snapshot helper exited with status {}: {}",
336            output.status, stderr
337        ));
338    }
339
340    parse_snapshot_helper_response(&output.stdout, &output.stderr)
341}
342
343fn write_snapshot_helper_request(
344    writer: &mut impl Write,
345    bridge_code: &str,
346    userland_code: Option<&str>,
347) -> Result<(), String> {
348    writer
349        .write_all(SNAPSHOT_HELPER_MAGIC)
350        .map_err(|error| format!("write snapshot helper magic: {error}"))?;
351    write_u64(writer, bridge_code.len() as u64)?;
352    write_u64(
353        writer,
354        userland_code
355            .map(|code| code.len() as u64)
356            .unwrap_or(SNAPSHOT_HELPER_NONE_LEN),
357    )?;
358    writer
359        .write_all(bridge_code.as_bytes())
360        .map_err(|error| format!("write snapshot helper bridge code: {error}"))?;
361    if let Some(userland_code) = userland_code {
362        writer
363            .write_all(userland_code.as_bytes())
364            .map_err(|error| format!("write snapshot helper userland code: {error}"))?;
365    }
366    Ok(())
367}
368
369fn parse_snapshot_helper_response(stdout: &[u8], stderr: &[u8]) -> Result<Vec<u8>, String> {
370    if stdout.starts_with(SNAPSHOT_HELPER_OK) {
371        let payload = &stdout[SNAPSHOT_HELPER_OK.len()..];
372        let (len, rest) = read_u64_from_slice(payload)?;
373        let len = usize::try_from(len)
374            .map_err(|_| String::from("snapshot helper OK payload length overflows usize"))?;
375        if rest.len() != len {
376            return Err(format!(
377                "snapshot helper OK payload length mismatch: declared {}, got {}",
378                len,
379                rest.len()
380            ));
381        }
382        return Ok(rest.to_vec());
383    }
384
385    if stdout.starts_with(SNAPSHOT_HELPER_ERR) {
386        let payload = &stdout[SNAPSHOT_HELPER_ERR.len()..];
387        let (len, rest) = read_u64_from_slice(payload)?;
388        let len = usize::try_from(len)
389            .map_err(|_| String::from("snapshot helper error length overflows usize"))?;
390        if rest.len() != len {
391            return Err(format!(
392                "snapshot helper error length mismatch: declared {}, got {}",
393                len,
394                rest.len()
395            ));
396        }
397        let message = String::from_utf8_lossy(rest);
398        return Err(message.into_owned());
399    }
400
401    Err(format!(
402        "snapshot helper returned invalid response (stdout {} bytes, stderr: {})",
403        stdout.len(),
404        String::from_utf8_lossy(stderr)
405    ))
406}
407
408fn run_snapshot_helper_from_stdio() -> i32 {
409    let mut input = Vec::new();
410    if let Err(error) = std::io::stdin().read_to_end(&mut input) {
411        let _ = write_snapshot_helper_error(format!("read snapshot helper request: {error}"));
412        return 2;
413    }
414
415    let result = (|| -> Result<Vec<u8>, String> {
416        let (bridge_code, userland_code) = parse_snapshot_helper_request(&input)?;
417        create_snapshot_inner_in_process(&bridge_code, userland_code.as_deref())
418    })();
419
420    match result {
421        Ok(blob) => match write_snapshot_helper_ok(&blob) {
422            Ok(()) => 0,
423            Err(error) => {
424                eprintln!("write snapshot helper response: {error}");
425                2
426            }
427        },
428        Err(error) => match write_snapshot_helper_error(error) {
429            Ok(()) => 0,
430            Err(error) => {
431                eprintln!("write snapshot helper error response: {error}");
432                2
433            }
434        },
435    }
436}
437
438fn parse_snapshot_helper_request(input: &[u8]) -> Result<(String, Option<String>), String> {
439    let Some(rest) = input.strip_prefix(SNAPSHOT_HELPER_MAGIC) else {
440        return Err(String::from("snapshot helper request missing magic"));
441    };
442    let (bridge_len, rest) = read_u64_from_slice(rest)?;
443    let (userland_len, rest) = read_u64_from_slice(rest)?;
444    let bridge_len = usize::try_from(bridge_len)
445        .map_err(|_| String::from("snapshot helper bridge length overflows usize"))?;
446    if rest.len() < bridge_len {
447        return Err(format!(
448            "snapshot helper bridge length mismatch: declared {}, got {}",
449            bridge_len,
450            rest.len()
451        ));
452    }
453    let (bridge_bytes, rest) = rest.split_at(bridge_len);
454    let userland_code = if userland_len == SNAPSHOT_HELPER_NONE_LEN {
455        if !rest.is_empty() {
456            return Err(format!(
457                "snapshot helper request has {} trailing bytes after bridge-only payload",
458                rest.len()
459            ));
460        }
461        None
462    } else {
463        let userland_len = usize::try_from(userland_len)
464            .map_err(|_| String::from("snapshot helper userland length overflows usize"))?;
465        if rest.len() != userland_len {
466            return Err(format!(
467                "snapshot helper userland length mismatch: declared {}, got {}",
468                userland_len,
469                rest.len()
470            ));
471        }
472        Some(
473            String::from_utf8(rest.to_vec())
474                .map_err(|error| format!("snapshot helper userland is not UTF-8: {error}"))?,
475        )
476    };
477    let bridge_code = String::from_utf8(bridge_bytes.to_vec())
478        .map_err(|error| format!("snapshot helper bridge is not UTF-8: {error}"))?;
479    Ok((bridge_code, userland_code))
480}
481
482fn write_snapshot_helper_ok(blob: &[u8]) -> std::io::Result<()> {
483    let mut stdout = std::io::stdout().lock();
484    stdout.write_all(SNAPSHOT_HELPER_OK)?;
485    write_u64_io(&mut stdout, blob.len() as u64)?;
486    stdout.write_all(blob)?;
487    stdout.flush()
488}
489
490fn write_snapshot_helper_error(message: String) -> std::io::Result<()> {
491    let mut stdout = std::io::stdout().lock();
492    stdout.write_all(SNAPSHOT_HELPER_ERR)?;
493    write_u64_io(&mut stdout, message.len() as u64)?;
494    stdout.write_all(message.as_bytes())?;
495    stdout.flush()
496}
497
498fn write_u64(writer: &mut impl Write, value: u64) -> Result<(), String> {
499    write_u64_io(writer, value).map_err(|error| format!("write snapshot helper length: {error}"))
500}
501
502fn write_u64_io(writer: &mut impl Write, value: u64) -> std::io::Result<()> {
503    writer.write_all(&value.to_le_bytes())
504}
505
506fn read_u64_from_slice(input: &[u8]) -> Result<(u64, &[u8]), String> {
507    let bytes = input
508        .get(..8)
509        .ok_or_else(|| String::from("snapshot helper payload ended before u64"))?;
510    let mut value = [0_u8; 8];
511    value.copy_from_slice(bytes);
512    Ok((u64::from_le_bytes(value), &input[8..]))
513}
514
515/// Inject default config globals needed by the bridge IIFE during snapshot creation.
516///
517/// These are placeholder values so bridge code that reads _processConfig or
518/// _osConfig at setup time doesn't fail. They're overwritten per-session
519/// after snapshot restore via inject_globals_from_payload.
520///
521/// Properties are set as READ_ONLY (not DONT_DELETE) so they remain
522/// configurable — inject_globals_from_payload can redefine them with
523/// READ_ONLY | DONT_DELETE after restore.
524fn inject_snapshot_defaults(scope: &mut v8::HandleScope) {
525    let context = scope.get_current_context();
526    let global = context.global(scope);
527
528    // _processConfig: default placeholder (overwritten per-session)
529    let pc_code = r#"({
530        cwd: "/",
531        env: {},
532        timing_mitigation: "off",
533        frozen_time_ms: null,
534        high_resolution_time: false
535    })"#;
536    let pc_source = v8::String::new(scope, pc_code).unwrap();
537    let pc_script = v8::Script::compile(scope, pc_source, None).unwrap();
538    let pc_val = pc_script.run(scope).unwrap();
539    if let Some(pc_obj) = pc_val.to_object(scope) {
540        pc_obj.set_integrity_level(scope, v8::IntegrityLevel::Frozen);
541    }
542    let pc_key = v8::String::new(scope, "_processConfig").unwrap();
543    // READ_ONLY only — no DONT_DELETE so the property remains configurable
544    // for override after snapshot restore
545    let attr = v8::PropertyAttribute::READ_ONLY;
546    global.define_own_property(scope, pc_key.into(), pc_val, attr);
547
548    // _osConfig: default placeholder (overwritten per-session)
549    let oc_code = r#"({
550        homedir: "/root",
551        tmpdir: "/tmp",
552        platform: "linux",
553        arch: "x64"
554    })"#;
555    let oc_source = v8::String::new(scope, oc_code).unwrap();
556    let oc_script = v8::Script::compile(scope, oc_source, None).unwrap();
557    let oc_val = oc_script.run(scope).unwrap();
558    if let Some(oc_obj) = oc_val.to_object(scope) {
559        oc_obj.set_integrity_level(scope, v8::IntegrityLevel::Frozen);
560    }
561    let oc_key = v8::String::new(scope, "_osConfig").unwrap();
562    // READ_ONLY only — no DONT_DELETE so the property remains configurable
563    let attr2 = v8::PropertyAttribute::READ_ONLY;
564    global.define_own_property(scope, oc_key.into(), oc_val, attr2);
565}
566
567/// Create a V8 isolate restored from a snapshot blob.
568///
569/// The external references must match those used during snapshot creation
570/// (provided by bridge::external_refs()).
571///
572/// `blob` must be owned or 'static data — `Vec<u8>`, `Box<[u8]>`, or
573/// `v8::StartupData` all work. The data is copied into the isolate during
574/// creation; V8 does not retain a reference after `Isolate::new()` returns.
575pub fn create_isolate_from_snapshot<B>(blob: B, heap_limit_mb: Option<u32>) -> v8::OwnedIsolate
576where
577    B: std::ops::Deref<Target = [u8]> + std::borrow::Borrow<[u8]> + 'static,
578{
579    init_v8_platform();
580
581    // `None` applies the bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`), same as
582    // the fresh-isolate path — a snapshot-restored isolate is never unbounded.
583    let limit = heap_limit_mb.unwrap_or(crate::isolate::DEFAULT_HEAP_LIMIT_MB);
584    let limit_bytes = (limit as usize) * 1024 * 1024;
585    let params = v8::CreateParams::default()
586        .snapshot_blob(blob)
587        .external_references(&**external_refs())
588        .heap_limits(0, limit_bytes);
589    let mut isolate = crate::isolate::with_isolate_lifecycle_lock(|| v8::Isolate::new(params));
590    crate::isolate::configure_isolate(&mut isolate);
591    // Same OOM guard as the fresh-isolate path: terminate this isolate on heap
592    // exhaustion instead of fatal-aborting the shared process (F-003).
593    crate::isolate::install_heap_limit_guard(&mut isolate);
594    isolate
595}
596
597pub type SnapshotCacheKey = [u8; 32];
598
599/// Thread-safe snapshot cache keyed by bridge code digest.
600///
601/// Uses two-phase locking with per-key in-flight tracking so concurrent
602/// callers requesting different bridge code variants are not blocked by
603/// each other. Callers requesting the same variant wait on a condvar
604/// instead of creating duplicate snapshots.
605pub struct SnapshotCache {
606    inner: Mutex<CacheInner>,
607    max_entries: usize,
608}
609
610struct CacheInner {
611    entries: Vec<CacheEntry>,
612    /// Per-key in-flight tracking: callers for the same digest wait on the
613    /// condvar instead of creating duplicate snapshots.
614    in_flight: HashMap<SnapshotCacheKey, Arc<InFlightEntry>>,
615}
616
617struct CacheEntry {
618    key: SnapshotCacheKey,
619    /// Snapshot blob bytes (copied from v8::StartupData).
620    /// Stored as Vec<u8> rather than StartupData because StartupData
621    /// contains raw pointers that are not Send/Sync.
622    blob: Arc<Vec<u8>>,
623}
624
625/// Shared state for an in-flight snapshot creation. The creator thread
626/// populates `result` and notifies all waiters via `done`.
627struct InFlightEntry {
628    result: Mutex<Option<Result<Arc<Vec<u8>>, String>>>,
629    done: Condvar,
630}
631
632impl SnapshotCache {
633    pub fn new(max_entries: usize) -> Self {
634        SnapshotCache {
635            inner: Mutex::new(CacheInner {
636                entries: Vec::new(),
637                in_flight: HashMap::new(),
638            }),
639            max_entries,
640        }
641    }
642
643    /// Get or create a snapshot for the given bridge code.
644    ///
645    /// Two-phase locking: the cache mutex is held only for lookups and
646    /// inserts, never during snapshot creation. Per-key in-flight tracking
647    /// prevents duplicate snapshot creation for the same bridge code.
648    pub fn get_or_create(&self, bridge_code: &str) -> Result<Arc<Vec<u8>>, String> {
649        self.get_or_create_with_userland(bridge_code, None)
650    }
651
652    /// Return a cached snapshot if present. This never creates a snapshot or
653    /// waits on in-flight creation.
654    pub fn try_get_with_userland(
655        &self,
656        bridge_code: &str,
657        userland_code: Option<&str>,
658    ) -> Option<Arc<Vec<u8>>> {
659        let key = snapshot_cache_key(bridge_code, userland_code);
660        let mut inner = self.inner.lock().unwrap();
661        if let Some(pos) = inner.entries.iter().position(|e| e.key == key) {
662            let entry = inner.entries.remove(pos);
663            let blob = Arc::clone(&entry.blob);
664            inner.entries.push(entry);
665            Some(blob)
666        } else {
667            None
668        }
669    }
670
671    /// Like [`get_or_create`], but the snapshot also captures an evaluated userland
672    /// (agent-SDK) graph. The cache key is the digest of BOTH `bridge_code` and
673    /// `userland_code`, so a change to either — i.e. any change in the bundled
674    /// dependency graph — invalidates the entry and triggers exactly one rebuild.
675    pub fn get_or_create_with_userland(
676        &self,
677        bridge_code: &str,
678        userland_code: Option<&str>,
679    ) -> Result<Arc<Vec<u8>>, String> {
680        let key = snapshot_cache_key(bridge_code, userland_code);
681
682        // Phase 1: short lock — check cache, check in-flight, or claim creation
683        let in_flight = {
684            let mut inner = self.inner.lock().unwrap();
685
686            // Cache hit — move to end (most recently used)
687            if let Some(pos) = inner.entries.iter().position(|e| e.key == key) {
688                let entry = inner.entries.remove(pos);
689                let blob = Arc::clone(&entry.blob);
690                inner.entries.push(entry);
691                return Ok(blob);
692            }
693
694            // Another thread is already creating this snapshot — wait on it
695            if let Some(entry) = inner.in_flight.get(&key) {
696                Some(Arc::clone(entry))
697            } else {
698                // We're the creator — register in-flight and release the lock
699                let entry = Arc::new(InFlightEntry {
700                    result: Mutex::new(None),
701                    done: Condvar::new(),
702                });
703                inner.in_flight.insert(key, Arc::clone(&entry));
704                None
705            }
706        };
707
708        // Wait path: another thread is creating this snapshot
709        if let Some(entry) = in_flight {
710            let mut result = entry.result.lock().unwrap();
711            while result.is_none() {
712                result = entry.done.wait(result).unwrap();
713            }
714            return result.as_ref().unwrap().clone();
715        }
716
717        // Phase 2: create snapshot without holding the cache lock
718        let creation_result = create_snapshot_inner(bridge_code, userland_code).map(Arc::new);
719
720        // Phase 3: short lock — insert result, notify waiters, clean up
721        {
722            let mut inner = self.inner.lock().unwrap();
723
724            if let Ok(ref arc) = creation_result {
725                // LRU eviction: remove oldest (front) entry when at capacity
726                if inner.entries.len() >= self.max_entries {
727                    inner.entries.remove(0);
728                }
729                inner.entries.push(CacheEntry {
730                    key,
731                    blob: Arc::clone(arc),
732                });
733            }
734
735            // Publish result to waiters and remove in-flight entry
736            if let Some(entry) = inner.in_flight.remove(&key) {
737                let mut result = entry.result.lock().unwrap();
738                *result = Some(creation_result.clone());
739                entry.done.notify_all();
740            }
741        }
742
743        creation_result
744    }
745}
746
747/// Cache key over bridge + optional userland code. With no userland this is just
748/// the sha256 of the bridge code (a NUL separator is only added when userland is
749/// present), so existing bridge-only entries keep their historical keys.
750pub fn snapshot_cache_key(bridge_code: &str, userland_code: Option<&str>) -> SnapshotCacheKey {
751    match userland_code {
752        None => {
753            let mut hasher = Sha256::new();
754            hasher.update(bridge_code.as_bytes());
755            hasher.finalize().into()
756        }
757        Some(userland_code) => {
758            let mut buf = Vec::with_capacity(bridge_code.len() + 1 + userland_code.len());
759            buf.extend_from_slice(bridge_code.as_bytes());
760            buf.push(0);
761            buf.extend_from_slice(userland_code.as_bytes());
762            let mut hasher = Sha256::new();
763            hasher.update(&buf);
764            hasher.finalize().into()
765        }
766    }
767}
768
769#[doc(hidden)]
770pub fn run_snapshot_consolidated_checks() {
771    fn eval(isolate: &mut v8::OwnedIsolate, code: &str) -> String {
772        let scope = &mut v8::HandleScope::new(isolate);
773        let context = v8::Context::new(scope, Default::default());
774        let scope = &mut v8::ContextScope::new(scope, context);
775        let source = v8::String::new(scope, code).unwrap();
776        let script = v8::Script::compile(scope, source, None).unwrap();
777        let result = script.run(scope).unwrap();
778        result.to_rust_string_lossy(scope)
779    }
780
781    // Keep snapshot coverage in a dedicated integration-test process.
782    // Running it in the shared unit-test binary still triggers a V8 teardown
783    // SIGSEGV after the test completes.
784    init_v8_platform();
785    let _ = external_refs();
786
787    // --- Part 1: Snapshot creation returns non-empty blob ---
788    {
789        let bridge_code = "(function() { globalThis.__bridge_init = true; })();";
790        let blob = create_snapshot(bridge_code).expect("snapshot creation should succeed");
791        assert!(!blob.is_empty(), "snapshot blob should be non-empty");
792    }
793
794    // --- Part 2: Restored isolate executes JS correctly ---
795    {
796        let bridge_code = "(function() { globalThis.__testValue = 42; })();";
797        let blob = create_snapshot(bridge_code).expect("snapshot creation should succeed");
798        let mut isolate = create_isolate_from_snapshot(blob, None);
799        // Fresh context on restored isolate — bridge globals are in snapshot's
800        // default context, not in a new context. Verify isolate is functional.
801        assert_eq!(eval(&mut isolate, "1 + 1"), "2");
802    }
803
804    // --- Part 3: Restored isolate respects heap_limit_mb ---
805    {
806        let bridge_code = "/* empty bridge */";
807        let blob = create_snapshot(bridge_code).expect("snapshot creation should succeed");
808        let mut isolate = create_isolate_from_snapshot(blob, Some(8));
809        assert_eq!(eval(&mut isolate, "'heap ok'"), "heap ok");
810    }
811
812    // --- Part 4: Normal blob is under 50MB limit ---
813    {
814        let bridge_code = "(function() { globalThis.x = 1; })();";
815        let blob = create_snapshot(bridge_code).expect("snapshot creation should succeed");
816        assert!(
817            blob.len() < MAX_SNAPSHOT_BLOB_BYTES,
818            "normal bridge code should produce blob under 50MB limit"
819        );
820    }
821
822    // --- Part 5: Three sequential restores from same snapshot data ---
823    {
824        let bridge_code = "(function() { globalThis.__counter = 0; })();";
825        let blob = create_snapshot(bridge_code).expect("snapshot creation should succeed");
826        let blob_bytes: Vec<u8> = blob.to_vec();
827
828        for i in 0..3 {
829            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
830            let result = eval(&mut isolate, &format!("{} + 1", i));
831            assert_eq!(result, format!("{}", i + 1));
832        }
833    }
834
835    // --- Part 6: Cache hit returns same Arc ---
836    {
837        let cache = SnapshotCache::new(4);
838        let bridge_code = "(function() { globalThis.__cached = 1; })();";
839
840        let arc1 = cache
841            .get_or_create(bridge_code)
842            .expect("first get_or_create");
843        let arc2 = cache
844            .get_or_create(bridge_code)
845            .expect("second get_or_create");
846
847        // Same Arc (same pointer) — cache hit, not a new snapshot
848        assert!(
849            Arc::ptr_eq(&arc1, &arc2),
850            "cache hit should return same Arc"
851        );
852    }
853
854    // --- Part 7: Cache miss creates new snapshot ---
855    {
856        let cache = SnapshotCache::new(4);
857        let code_a = "(function() { globalThis.__a = 1; })();";
858        let code_b = "(function() { globalThis.__b = 2; })();";
859
860        let arc_a = cache.get_or_create(code_a).expect("create A");
861        let arc_b = cache.get_or_create(code_b).expect("create B");
862
863        // Different bridge code → different Arc
864        assert!(
865            !Arc::ptr_eq(&arc_a, &arc_b),
866            "different code should produce different Arc"
867        );
868
869        // Verify both are usable
870        let mut iso_a = create_isolate_from_snapshot((*arc_a).clone(), None);
871        assert_eq!(eval(&mut iso_a, "1 + 1"), "2");
872
873        let mut iso_b = create_isolate_from_snapshot((*arc_b).clone(), None);
874        assert_eq!(eval(&mut iso_b, "2 + 2"), "4");
875    }
876
877    // --- Part 8: LRU eviction removes oldest entry ---
878    {
879        let cache = SnapshotCache::new(2);
880        let code_1 = "(function() { globalThis.__v1 = 1; })();";
881        let code_2 = "(function() { globalThis.__v2 = 2; })();";
882        let code_3 = "(function() { globalThis.__v3 = 3; })();";
883
884        let arc_1 = cache.get_or_create(code_1).expect("create 1");
885        let _arc_2 = cache.get_or_create(code_2).expect("create 2");
886
887        // Cache is full (2 entries). Adding a third should evict code_1.
888        let _arc_3 = cache.get_or_create(code_3).expect("create 3");
889
890        // code_1 should be evicted — re-requesting it should return a new Arc
891        let arc_1_new = cache.get_or_create(code_1).expect("re-create 1");
892        assert!(
893            !Arc::ptr_eq(&arc_1, &arc_1_new),
894            "evicted entry should produce a new Arc on re-creation"
895        );
896
897        // code_2 should still be cached (it was accessed before code_3 but not evicted)
898        // After eviction of code_1, cache had [code_2, code_3], then adding code_1 evicts code_2
899        // Actually: after inserting code_3, cache was [code_2, code_3] (code_1 evicted).
900        // Then inserting code_1 again: cache is full (2), evicts code_2 → cache is [code_3, code_1].
901    }
902
903    // --- Part 9: Concurrent get_or_create creates only one snapshot ---
904    {
905        use std::sync::atomic::{AtomicUsize, Ordering};
906
907        let cache = Arc::new(SnapshotCache::new(4));
908        let bridge_code = "(function() { globalThis.__concurrent = 1; })();";
909
910        // Pre-warm — to avoid measuring snapshot creation races, verify
911        // that after one creation, N threads all get the same Arc
912        let first = cache.get_or_create(bridge_code).expect("pre-warm");
913
914        let num_threads = 4;
915        let barrier = Arc::new(std::sync::Barrier::new(num_threads));
916        let same_count = Arc::new(AtomicUsize::new(0));
917
918        let mut handles = vec![];
919        for _ in 0..num_threads {
920            let cache = Arc::clone(&cache);
921            let barrier = Arc::clone(&barrier);
922            let first = Arc::clone(&first);
923            let same_count = Arc::clone(&same_count);
924            let code = bridge_code.to_string();
925
926            handles.push(std::thread::spawn(move || {
927                barrier.wait();
928                let arc = cache.get_or_create(&code).expect("concurrent get");
929                if Arc::ptr_eq(&arc, &first) {
930                    same_count.fetch_add(1, Ordering::Relaxed);
931                }
932            }));
933        }
934
935        for h in handles {
936            h.join().expect("thread join");
937        }
938
939        assert_eq!(
940            same_count.load(Ordering::Relaxed),
941            num_threads,
942            "all concurrent callers should get the same cached Arc"
943        );
944    }
945
946    // --- Part 10: Guest WebAssembly remains available after snapshot restore ---
947    {
948        let bridge_code = "(function() { globalThis.__wasm_test = true; })();";
949        let blob = create_snapshot(bridge_code).expect("snapshot creation");
950        let mut isolate = create_isolate_from_snapshot(blob, None);
951
952        let scope = &mut v8::HandleScope::new(&mut isolate);
953        let context = v8::Context::new(scope, Default::default());
954        let scope = &mut v8::ContextScope::new(scope, context);
955
956        let wasm_test_code = r#"
957                (function() {
958                    var bytes = new Uint8Array([
959                        0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
960                        0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,
961                        0x03, 0x02, 0x01, 0x00,
962                        0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,
963                        0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b,
964                    ]);
965                    var module = new WebAssembly.Module(bytes);
966                    var instance = new WebAssembly.Instance(module, {});
967                    return String(instance.exports.add(2, 3));
968                })()
969            "#;
970        let source = v8::String::new(scope, wasm_test_code).unwrap();
971        let script = v8::Script::compile(scope, source, None).unwrap();
972        let result = script.run(scope).unwrap();
973        let result_str = result.to_rust_string_lossy(scope);
974
975        assert_eq!(
976            result_str, "5",
977            "WASM should remain enabled after snapshot restore"
978        );
979    }
980
981    // --- Part 11: Session isolation — fresh contexts from same snapshot ---
982    // Verifies that state set in one session's context does not leak
983    // to another session's context (fresh context per session).
984    {
985        let bridge_code = "(function() { globalThis.__shared_bridge = 'ok'; })();";
986        let blob = create_snapshot(bridge_code).expect("snapshot creation");
987        let blob_bytes: Vec<u8> = blob.to_vec();
988
989        // "Session A": set a global variable
990        {
991            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
992            let scope = &mut v8::HandleScope::new(&mut isolate);
993            let context = v8::Context::new(scope, Default::default());
994            let scope = &mut v8::ContextScope::new(scope, context);
995
996            let source =
997                v8::String::new(scope, "globalThis.__session_secret = 'session-a-data';").unwrap();
998            let script = v8::Script::compile(scope, source, None).unwrap();
999            script.run(scope);
1000
1001            // Verify session A can see its own data
1002            let check = v8::String::new(scope, "globalThis.__session_secret").unwrap();
1003            let script = v8::Script::compile(scope, check, None).unwrap();
1004            let result = script.run(scope).unwrap();
1005            assert_eq!(result.to_rust_string_lossy(scope), "session-a-data");
1006        }
1007
1008        // "Session B": fresh context from same snapshot should NOT see session A's data
1009        {
1010            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1011            let scope = &mut v8::HandleScope::new(&mut isolate);
1012            let context = v8::Context::new(scope, Default::default());
1013            let scope = &mut v8::ContextScope::new(scope, context);
1014
1015            let source = v8::String::new(scope, "typeof globalThis.__session_secret").unwrap();
1016            let script = v8::Script::compile(scope, source, None).unwrap();
1017            let result = script.run(scope).unwrap();
1018            assert_eq!(
1019                result.to_rust_string_lossy(scope),
1020                "undefined",
1021                "session B should not see session A's global state"
1022            );
1023        }
1024    }
1025
1026    // --- Part 12: External references survive snapshot restore ---
1027    // Verifies that FunctionTemplates registered on a restored isolate
1028    // correctly dispatch to Rust bridge callbacks via external_refs().
1029    {
1030        use crate::bridge::{register_async_bridge_fns, register_sync_bridge_fns, PendingPromises};
1031        use crate::host_call::BridgeCallContext;
1032
1033        let bridge_code = "(function() { globalThis.__ext_ref_test = true; })();";
1034        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1035        let mut isolate = create_isolate_from_snapshot(blob, None);
1036
1037        // Create minimal BridgeCallContext (sync call will fail but we
1038        // test that the FunctionTemplate dispatches without crash)
1039        let (event_tx, _event_rx) =
1040            crossbeam_channel::unbounded::<crate::session::RuntimeEventEnvelope>();
1041        let (_cmd_tx, _cmd_rx) = crossbeam_channel::unbounded::<crate::session::SessionCommand>();
1042        let call_id_router: crate::host_call::CallIdRouter =
1043            Arc::new(Mutex::new(std::collections::HashMap::new()));
1044
1045        let receiver = crate::host_call::ReaderBridgeResponseReceiver::new(Box::new(
1046            std::io::Cursor::new(Vec::<u8>::new()),
1047        ));
1048        let sender = crate::host_call::ChannelRuntimeEventSender::new(event_tx, None);
1049        let bridge_ctx = BridgeCallContext::with_receiver(
1050            Box::new(sender),
1051            Box::new(receiver),
1052            "test-session".to_string(),
1053            call_id_router,
1054            Arc::new(std::sync::atomic::AtomicU64::new(1)),
1055        );
1056        let pending = PendingPromises::new();
1057
1058        let scope = &mut v8::HandleScope::new(&mut isolate);
1059        let context = v8::Context::new(scope, Default::default());
1060        let scope = &mut v8::ContextScope::new(scope, context);
1061
1062        // Register bridge functions on the restored isolate
1063        let _sync_store = register_sync_bridge_fns(
1064            scope,
1065            &bridge_ctx as *const BridgeCallContext,
1066            &["_testSync"],
1067        );
1068        let _async_store = register_async_bridge_fns(
1069            scope,
1070            &bridge_ctx as *const BridgeCallContext,
1071            &pending as *const PendingPromises,
1072            &["_testAsync"],
1073        );
1074
1075        // Verify the functions exist as globals
1076        let check = v8::String::new(scope, "typeof _testSync").unwrap();
1077        let script = v8::Script::compile(scope, check, None).unwrap();
1078        let result = script.run(scope).unwrap();
1079        assert_eq!(
1080            result.to_rust_string_lossy(scope),
1081            "function",
1082            "_testSync should be a function on restored isolate"
1083        );
1084
1085        let check = v8::String::new(scope, "typeof _testAsync").unwrap();
1086        let script = v8::Script::compile(scope, check, None).unwrap();
1087        let result = script.run(scope).unwrap();
1088        assert_eq!(
1089            result.to_rust_string_lossy(scope),
1090            "function",
1091            "_testAsync should be a function on restored isolate"
1092        );
1093    }
1094
1095    // --- Part 13: Register stub bridge functions on V8 global ---
1096    // Verifies that register_stub_bridge_fns places functions on the global
1097    // and that they have the correct typeof without calling them.
1098    {
1099        use crate::bridge::register_stub_bridge_fns;
1100
1101        // Use a snapshot-based isolate (consistent with other parts)
1102        let bridge_code = "/* stub test */";
1103        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1104        let mut isolate = create_isolate_from_snapshot(blob, None);
1105
1106        let scope = &mut v8::HandleScope::new(&mut isolate);
1107        let context = v8::Context::new(scope, Default::default());
1108        let scope = &mut v8::ContextScope::new(scope, context);
1109
1110        register_stub_bridge_fns(
1111            scope,
1112            &["_log", "_error", "_fsReadFile", "_loadPolyfill"],
1113            &["_scheduleTimer", "_dynamicImport"],
1114        );
1115
1116        let check = v8::String::new(
1117            scope,
1118            r#"
1119                (function() {
1120                    var names = ['_log', '_error', '_fsReadFile', '_loadPolyfill',
1121                                 '_scheduleTimer', '_dynamicImport'];
1122                    for (var i = 0; i < names.length; i++) {
1123                        if (typeof globalThis[names[i]] !== 'function') {
1124                            return 'FAIL: ' + names[i] + ' is ' + typeof globalThis[names[i]];
1125                        }
1126                    }
1127                    return 'OK';
1128                })()
1129            "#,
1130        )
1131        .unwrap();
1132        let script = v8::Script::compile(scope, check, None).unwrap();
1133        let result = script.run(scope).unwrap();
1134        assert_eq!(
1135            result.to_rust_string_lossy(scope),
1136            "OK",
1137            "all stub bridge functions should be registered as functions"
1138        );
1139    }
1140
1141    // --- Part 14: Bridge IIFE executes against stubs + snapshot creation ---
1142    // Verifies that setup-time code can reference stub functions (typeof,
1143    // closure wrapping, getter facade) without calling them, and that the
1144    // resulting context can be snapshotted.
1145    {
1146        use crate::bridge::register_stub_bridge_fns;
1147
1148        let mut snapshot_isolate = v8::Isolate::snapshot_creator(Some(external_refs()), None);
1149        {
1150            let scope = &mut v8::HandleScope::new(&mut snapshot_isolate);
1151            let context = v8::Context::new(scope, Default::default());
1152            let scope = &mut v8::ContextScope::new(scope, context);
1153
1154            // Register bridge functions as stubs (no External data).
1155            let sync_bridge_fns = sync_bridge_fns();
1156            let async_bridge_fns = async_bridge_fns();
1157            register_stub_bridge_fns(scope, sync_bridge_fns, async_bridge_fns);
1158
1159            // Simulate bridge IIFE: reference all bridge functions, set up
1160            // closures and getter facade, but never call any bridge function
1161            let iife_code = r#"
1162                    (function() {
1163                        // Verify bridge functions exist (like ivm-compat shim)
1164                        var syncKeys = ['_log', '_error', '_resolveModule', '_loadFile', '_moduleFormat',
1165                            '_cryptoRandomFill', '_fsReadFile', '_fsWriteFile',
1166                            '_childProcessSpawnStart', '_childProcessPoll', '_childProcessSpawnSync'];
1167                        var asyncKeys = ['_dynamicImport', '_scheduleTimer',
1168                            '_networkHttpServerListenRaw'];
1169
1170                        for (var i = 0; i < syncKeys.length; i++) {
1171                            if (typeof globalThis[syncKeys[i]] !== 'function') {
1172                                throw new Error('Missing sync: ' + syncKeys[i]);
1173                            }
1174                        }
1175                        for (var i = 0; i < asyncKeys.length; i++) {
1176                            if (typeof globalThis[asyncKeys[i]] !== 'function') {
1177                                throw new Error('Missing async: ' + asyncKeys[i]);
1178                            }
1179                        }
1180
1181                        // Simulate getter-based fs facade (setup only, no calls)
1182                        var _fs = {};
1183                        Object.defineProperties(_fs, {
1184                            readFile:  { get: function() { return globalThis._fsReadFile; },  enumerable: true },
1185                            writeFile: { get: function() { return globalThis._fsWriteFile; }, enumerable: true },
1186                        });
1187                        globalThis._fs = _fs;
1188
1189                        // Verify getter returns function reference without calling it
1190                        if (typeof _fs.readFile !== 'function') {
1191                            throw new Error('Getter should return function, got ' + typeof _fs.readFile);
1192                        }
1193
1194                        // Simulate closure wrapping (setup only, no calls)
1195                        globalThis.__wrappedLog = function() {
1196                            return globalThis._log.apply(null, arguments);
1197                        };
1198
1199                        globalThis.__bridge_setup_complete = true;
1200                    })();
1201                "#;
1202            let source = v8::String::new(scope, iife_code).unwrap();
1203            let script = v8::Script::compile(scope, source, None).unwrap();
1204            let result = script.run(scope);
1205            assert!(
1206                result.is_some(),
1207                "bridge IIFE should execute without error against stub functions"
1208            );
1209
1210            // Verify setup completed
1211            let check =
1212                v8::String::new(scope, "String(globalThis.__bridge_setup_complete)").unwrap();
1213            let script = v8::Script::compile(scope, check, None).unwrap();
1214            let val = script.run(scope).unwrap();
1215            assert_eq!(
1216                val.to_rust_string_lossy(scope),
1217                "true",
1218                "bridge setup should complete with stub functions"
1219            );
1220
1221            scope.set_default_context(context);
1222        }
1223
1224        let blob = snapshot_isolate.create_blob(v8::FunctionCodeHandling::Keep);
1225        assert!(
1226            blob.is_some(),
1227            "snapshot creation should succeed with stub bridge functions"
1228        );
1229        assert!(
1230            !blob.unwrap().is_empty(),
1231            "snapshot blob should be non-empty"
1232        );
1233    }
1234
1235    // --- Part 15: create_snapshot() auto-registers stubs and injects defaults ---
1236    // Verifies that create_snapshot() registers all bridge function stubs
1237    // and injects _processConfig/_osConfig defaults before running bridge code.
1238    {
1239        // Bridge IIFE that verifies stubs and config globals exist
1240        let iife_code = r#"
1241                (function() {
1242                    // Verify all sync bridge functions are registered as stubs
1243                    var syncFns = ['_log', '_error', '_resolveModule', '_loadFile',
1244                        '_moduleFormat', '_loadPolyfill', '_cryptoRandomFill', '_cryptoRandomUUID',
1245                        '_fsReadFile', '_fsWriteFile', '_fsReadFileBinary',
1246                        '_fsWriteFileBinary', '_fsReadDir', '_fsMkdir', '_fsRmdir',
1247                        '_fsExists', '_fsStat', '_fsUnlink', '_fsRename', '_fsChmod',
1248                        '_fsChown', '_fsLink', '_fsSymlink', '_fsReadlink', '_fsLstat',
1249                        '_fsTruncate', '_fsUtimes', '_childProcessSpawnStart',
1250                        '_childProcessPoll', '_childProcessStdinWrite', '_childProcessStdinClose',
1251                        '_childProcessKill', '_childProcessSpawnSync'];
1252                    for (var i = 0; i < syncFns.length; i++) {
1253                        if (typeof globalThis[syncFns[i]] !== 'function') {
1254                            throw new Error('Missing sync stub: ' + syncFns[i] +
1255                                ' (typeof=' + typeof globalThis[syncFns[i]] + ')');
1256                        }
1257                    }
1258
1259                    // Verify all async bridge functions are registered as stubs
1260                    var asyncFns = ['_dynamicImport', '_scheduleTimer',
1261                        '_networkDnsLookupRaw',
1262                        '_networkDnsResolveRaw',
1263                        '_networkHttpServerListenRaw',
1264                        '_networkHttpServerCloseRaw', '_networkHttpServerWaitRaw',
1265                        '_networkHttp2ServerWaitRaw', '_networkHttp2SessionWaitRaw'];
1266                    for (var i = 0; i < asyncFns.length; i++) {
1267                        if (typeof globalThis[asyncFns[i]] !== 'function') {
1268                            throw new Error('Missing async stub: ' + asyncFns[i] +
1269                                ' (typeof=' + typeof globalThis[asyncFns[i]] + ')');
1270                        }
1271                    }
1272
1273                    // Verify _processConfig default was injected
1274                    if (typeof _processConfig !== 'object' || _processConfig === null) {
1275                        throw new Error('_processConfig not injected: ' + typeof _processConfig);
1276                    }
1277                    if (_processConfig.cwd !== '/') {
1278                        throw new Error('_processConfig.cwd should be "/", got: ' + _processConfig.cwd);
1279                    }
1280
1281                    // Verify _osConfig default was injected
1282                    if (typeof _osConfig !== 'object' || _osConfig === null) {
1283                        throw new Error('_osConfig not injected: ' + typeof _osConfig);
1284                    }
1285                    if (_osConfig.platform !== 'linux') {
1286                        throw new Error('_osConfig.platform should be "linux", got: ' + _osConfig.platform);
1287                    }
1288
1289                    globalThis.__part15_ok = true;
1290                })();
1291            "#;
1292        let blob = create_snapshot(iife_code).expect(
1293            "create_snapshot should succeed with bridge code that checks stubs and defaults",
1294        );
1295        assert!(!blob.is_empty(), "snapshot blob should be non-empty");
1296
1297        // Verify the snapshot can be restored
1298        let mut isolate = create_isolate_from_snapshot(blob, None);
1299        assert_eq!(eval(&mut isolate, "1 + 1"), "2");
1300    }
1301
1302    // --- Part 16: create_snapshot() with getter facade and closures ---
1303    // Verifies that the full bridge pattern (stubs, closures, getter facade,
1304    // config globals) works through create_snapshot() and the context is
1305    // correctly snapshotted via set_default_context.
1306    {
1307        let iife_code = r#"
1308                (function() {
1309                    // Set up getter-based fs facade referencing bridge stubs
1310                    var _fs = {};
1311                    Object.defineProperties(_fs, {
1312                        readFile:  { get: function() { return globalThis._fsReadFile; },  enumerable: true },
1313                        writeFile: { get: function() { return globalThis._fsWriteFile; }, enumerable: true },
1314                    });
1315                    globalThis._fs = _fs;
1316
1317                    // Set up closure wrapping a bridge stub
1318                    globalThis.myLog = function() {
1319                        return globalThis._log.apply(null, arguments);
1320                    };
1321
1322                    // Set up a require-like function (doesn't call _loadPolyfill at setup)
1323                    globalThis.require = function(name) {
1324                        return globalThis._loadPolyfill(name);
1325                    };
1326
1327                    // Set up a console-like object
1328                    globalThis.console = {
1329                        log: function() { globalThis._log.apply(null, arguments); },
1330                        error: function() { globalThis._error.apply(null, arguments); },
1331                    };
1332
1333                    // Read _processConfig at setup time (like process.cwd initialization)
1334                    globalThis.__initialCwd = _processConfig.cwd;
1335
1336                    globalThis.__part16_setup = true;
1337                })();
1338            "#;
1339        let blob = create_snapshot(iife_code)
1340            .expect("create_snapshot should succeed with full bridge IIFE pattern");
1341        assert!(!blob.is_empty());
1342
1343        // Restore and verify default context has the bridge infrastructure
1344        let blob_bytes: Vec<u8> = blob.to_vec();
1345        let mut isolate = create_isolate_from_snapshot(blob_bytes, None);
1346        let scope = &mut v8::HandleScope::new(&mut isolate);
1347        let context = v8::Context::new(scope, Default::default());
1348        let scope = &mut v8::ContextScope::new(scope, context);
1349
1350        // Check that bridge infrastructure from the IIFE is in the default context
1351        let check_code = r#"
1352                (function() {
1353                    var results = [];
1354                    results.push('_fs=' + (typeof _fs === 'object'));
1355                    results.push('_fs.readFile=' + (typeof _fs.readFile === 'function'));
1356                    results.push('myLog=' + (typeof myLog === 'function'));
1357                    results.push('require=' + (typeof require === 'function'));
1358                    results.push('console.log=' + (typeof console.log === 'function'));
1359                    results.push('console.error=' + (typeof console.error === 'function'));
1360                    results.push('__initialCwd=' + __initialCwd);
1361                    results.push('__part16_setup=' + __part16_setup);
1362                    return results.join(';');
1363                })()
1364            "#;
1365        let source = v8::String::new(scope, check_code).unwrap();
1366        let script = v8::Script::compile(scope, source, None).unwrap();
1367        let result = script.run(scope).unwrap();
1368        let result_str = result.to_rust_string_lossy(scope);
1369
1370        assert_eq!(
1371            result_str,
1372            "_fs=true;_fs.readFile=true;myLog=true;require=true;console.log=true;console.error=true;__initialCwd=/;__part16_setup=true",
1373            "restored context should have all bridge infrastructure from the IIFE"
1374        );
1375    }
1376
1377    // --- Part 17: SnapshotCache works with context-snapshot create_snapshot ---
1378    // Verifies cache hit/miss still works now that create_snapshot registers stubs.
1379    {
1380        let cache = SnapshotCache::new(4);
1381        let code = r#"
1382                (function() {
1383                    // Verify stubs are present (create_snapshot registers them)
1384                    if (typeof _log !== 'function') throw new Error('no _log stub');
1385                    if (typeof _processConfig !== 'object') throw new Error('no _processConfig');
1386                    globalThis.__cached_context = true;
1387                })();
1388            "#;
1389
1390        let arc1 = cache.get_or_create(code).expect("first get_or_create");
1391        let arc2 = cache.get_or_create(code).expect("second get_or_create");
1392        assert!(
1393            Arc::ptr_eq(&arc1, &arc2),
1394            "cache hit should return same Arc"
1395        );
1396
1397        // Verify blob is usable
1398        let mut isolate = create_isolate_from_snapshot((*arc1).clone(), None);
1399        assert_eq!(eval(&mut isolate, "1 + 1"), "2");
1400    }
1401
1402    // --- Part 18: Context restore + replace_bridge_fns dispatches correctly ---
1403    // Verifies the full context snapshot restore flow: create snapshot with
1404    // stubs, restore, replace stubs with real bridge functions, verify the
1405    // replaced functions dispatch to the real Rust callbacks.
1406    {
1407        use crate::bridge::{replace_bridge_fns, PendingPromises};
1408        use crate::host_call::BridgeCallContext;
1409
1410        // Create snapshot with stubs + simple bridge IIFE
1411        let bridge_code = r#"
1412                (function() {
1413                    // Getter-based facade referencing globalThis._fsReadFile
1414                    var _fs = {};
1415                    Object.defineProperties(_fs, {
1416                        readFile: { get: function() { return globalThis._fsReadFile; }, enumerable: true },
1417                    });
1418                    globalThis._fs = _fs;
1419                    globalThis.__bridge_ready = true;
1420                })();
1421            "#;
1422        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1423        let mut isolate = create_isolate_from_snapshot(blob, None);
1424
1425        // Create BridgeCallContext (sync calls will fail but we verify dispatch)
1426        let (event_tx, _event_rx) =
1427            crossbeam_channel::unbounded::<crate::session::RuntimeEventEnvelope>();
1428        let call_id_router: crate::host_call::CallIdRouter =
1429            Arc::new(Mutex::new(std::collections::HashMap::new()));
1430        let receiver = crate::host_call::ReaderBridgeResponseReceiver::new(Box::new(
1431            std::io::Cursor::new(Vec::<u8>::new()),
1432        ));
1433        let sender = crate::host_call::ChannelRuntimeEventSender::new(event_tx, None);
1434        let bridge_ctx = BridgeCallContext::with_receiver(
1435            Box::new(sender),
1436            Box::new(receiver),
1437            "test-session".to_string(),
1438            call_id_router,
1439            Arc::new(std::sync::atomic::AtomicU64::new(1)),
1440        );
1441        let pending = PendingPromises::new();
1442
1443        // Restore context and replace bridge functions
1444        let scope = &mut v8::HandleScope::new(&mut isolate);
1445        let context = v8::Context::new(scope, Default::default());
1446        let scope = &mut v8::ContextScope::new(scope, context);
1447
1448        let (_sync_store, _async_store) = replace_bridge_fns(
1449            scope,
1450            &bridge_ctx as *const BridgeCallContext,
1451            &pending as *const PendingPromises,
1452            &["_log", "_fsReadFile"],
1453            &["_scheduleTimer"],
1454        );
1455
1456        // Verify bridge infrastructure from IIFE survives restore
1457        let check = v8::String::new(
1458            scope,
1459            r#"
1460                (function() {
1461                    var results = [];
1462                    results.push('__bridge_ready=' + globalThis.__bridge_ready);
1463                    results.push('_fs_exists=' + (typeof _fs === 'object'));
1464                    // Getter should resolve to the REPLACED function (not stub)
1465                    results.push('_fs.readFile_type=' + typeof _fs.readFile);
1466                    // Direct global should also be the replaced function
1467                    results.push('_log_type=' + typeof _log);
1468                    results.push('_scheduleTimer_type=' + typeof _scheduleTimer);
1469                    return results.join(';');
1470                })()
1471            "#,
1472        )
1473        .unwrap();
1474        let script = v8::Script::compile(scope, check, None).unwrap();
1475        let result = script.run(scope).unwrap();
1476        assert_eq!(
1477            result.to_rust_string_lossy(scope),
1478            "__bridge_ready=true;_fs_exists=true;_fs.readFile_type=function;_log_type=function;_scheduleTimer_type=function",
1479            "restored context should have bridge IIFE state + replaced functions"
1480        );
1481    }
1482
1483    // --- Part 19: _processConfig is overridable after restore ---
1484    // Verifies that inject_snapshot_defaults uses configurable properties
1485    // so inject_globals_from_payload can override them per session.
1486    {
1487        use crate::bridge::serialize_v8_value;
1488
1489        let bridge_code = r#"
1490                (function() {
1491                    // Verify default _processConfig from snapshot
1492                    globalThis.__snapshotCwd = _processConfig.cwd;
1493                })();
1494            "#;
1495        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1496        let mut isolate = create_isolate_from_snapshot(blob, None);
1497
1498        let scope = &mut v8::HandleScope::new(&mut isolate);
1499        let context = v8::Context::new(scope, Default::default());
1500        let scope = &mut v8::ContextScope::new(scope, context);
1501
1502        // Verify snapshot defaults are present
1503        let check = v8::String::new(scope, "__snapshotCwd").unwrap();
1504        let script = v8::Script::compile(scope, check, None).unwrap();
1505        let result = script.run(scope).unwrap();
1506        assert_eq!(result.to_rust_string_lossy(scope), "/");
1507
1508        // Create a V8 payload to override _processConfig
1509        let payload_code = r#"({
1510                processConfig: { cwd: "/app", env: { FOO: "bar" }, timing_mitigation: "off", frozen_time_ms: null },
1511                osConfig: { homedir: "/home/agentos", tmpdir: "/tmp", platform: "linux", arch: "arm64" }
1512            })"#;
1513        let payload_source = v8::String::new(scope, payload_code).unwrap();
1514        let payload_script = v8::Script::compile(scope, payload_source, None).unwrap();
1515        let payload_val = payload_script.run(scope).unwrap();
1516        let payload_bytes = serialize_v8_value(scope, payload_val).expect("serialize payload");
1517
1518        // Inject per-session globals (overrides snapshot defaults)
1519        crate::execution::inject_globals_from_payload(scope, &payload_bytes)
1520            .expect("inject globals payload");
1521
1522        // Verify _processConfig was overridden
1523        let check = v8::String::new(scope, "_processConfig.cwd").unwrap();
1524        let script = v8::Script::compile(scope, check, None).unwrap();
1525        let result = script.run(scope).unwrap();
1526        assert_eq!(
1527            result.to_rust_string_lossy(scope),
1528            "/app",
1529            "_processConfig.cwd should be overridden from '/' to '/app'"
1530        );
1531
1532        // Verify _osConfig was overridden
1533        let check = v8::String::new(scope, "_osConfig.arch").unwrap();
1534        let script = v8::Script::compile(scope, check, None).unwrap();
1535        let result = script.run(scope).unwrap();
1536        assert_eq!(
1537            result.to_rust_string_lossy(scope),
1538            "arm64",
1539            "_osConfig.arch should be overridden to 'arm64'"
1540        );
1541    }
1542
1543    // --- Part 19a: function globals survive snapshot restore ---
1544    {
1545        let bridge_code = r#"
1546                (function() {
1547                    globalThis.__snapshotFn = async function () { return "ok"; };
1548                })();
1549            "#;
1550        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1551        let mut isolate = create_isolate_from_snapshot(blob, None);
1552
1553        let scope = &mut v8::HandleScope::new(&mut isolate);
1554        let context = v8::Context::new(scope, Default::default());
1555        let scope = &mut v8::ContextScope::new(scope, context);
1556
1557        let check = v8::String::new(
1558            scope,
1559            r#"(function() {
1560                    return JSON.stringify({
1561                        fnType: typeof globalThis.__snapshotFn,
1562                        promiseType: typeof globalThis.__snapshotFn?.(),
1563                    });
1564                })()"#,
1565        )
1566        .unwrap();
1567        let script = v8::Script::compile(scope, check, None).unwrap();
1568        let result = script.run(scope).unwrap();
1569        assert_eq!(
1570            result.to_rust_string_lossy(scope),
1571            r#"{"fnType":"function","promiseType":"object"}"#,
1572            "function-valued globals should survive snapshot restore"
1573        );
1574    }
1575
1576    // --- Part 19b: bundled bridge installs fetch globals before snapshot restore ---
1577    {
1578        let bridge_code = concat!(
1579            include_str!(concat!(env!("OUT_DIR"), "/v8-bridge.js")),
1580            "\n",
1581            include_str!(concat!(env!("OUT_DIR"), "/v8-bridge-zlib.js"))
1582        );
1583        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1584        let mut isolate = create_isolate_from_snapshot(blob, None);
1585
1586        let scope = &mut v8::HandleScope::new(&mut isolate);
1587        let context = v8::Context::new(scope, Default::default());
1588        let scope = &mut v8::ContextScope::new(scope, context);
1589
1590        let check = v8::String::new(
1591            scope,
1592            r#"(function() {
1593                    return JSON.stringify({
1594                        fetchType: typeof globalThis.fetch,
1595                        headersType: typeof globalThis.Headers,
1596                        requestType: typeof globalThis.Request,
1597                        responseType: typeof globalThis.Response,
1598                    });
1599                })()"#,
1600        )
1601        .unwrap();
1602        let script = v8::Script::compile(scope, check, None).unwrap();
1603        let result = script.run(scope).unwrap();
1604        assert_eq!(
1605            result.to_rust_string_lossy(scope),
1606            r#"{"fetchType":"function","headersType":"function","requestType":"function","responseType":"function"}"#,
1607            "bundled bridge should expose fetch globals in restored contexts"
1608        );
1609    }
1610
1611    // --- Part 20a: Concurrent get_or_create with different bridge codes ---
1612    // Verifies that concurrent callers requesting different bridge code
1613    // variants are not blocked by each other (two-phase locking).
1614    {
1615        use std::sync::atomic::{AtomicBool, Ordering};
1616        use std::time::Instant;
1617
1618        let cache = Arc::new(SnapshotCache::new(4));
1619        let codes: Vec<String> = (0..3)
1620            .map(|i| {
1621                format!(
1622                    "(function() {{ globalThis.__concurrent_{} = {}; }})();",
1623                    i, i
1624                )
1625            })
1626            .collect();
1627
1628        let barrier = Arc::new(std::sync::Barrier::new(codes.len()));
1629        let all_ok = Arc::new(AtomicBool::new(true));
1630
1631        let mut handles = vec![];
1632        for code in &codes {
1633            let cache = Arc::clone(&cache);
1634            let barrier = Arc::clone(&barrier);
1635            let all_ok = Arc::clone(&all_ok);
1636            let code = code.clone();
1637
1638            handles.push(std::thread::spawn(move || {
1639                barrier.wait();
1640                let start = Instant::now();
1641                match cache.get_or_create(&code) {
1642                    Ok(arc) => {
1643                        assert!(!arc.is_empty());
1644                    }
1645                    Err(e) => {
1646                        eprintln!("get_or_create failed: {}", e);
1647                        all_ok.store(false, Ordering::Relaxed);
1648                    }
1649                }
1650                start.elapsed()
1651            }));
1652        }
1653
1654        let mut durations = vec![];
1655        for h in handles {
1656            durations.push(h.join().expect("thread join"));
1657        }
1658
1659        assert!(
1660            all_ok.load(Ordering::Relaxed),
1661            "all concurrent get_or_create calls should succeed"
1662        );
1663
1664        // Verify all entries are cached (cache hits on second request)
1665        for code in &codes {
1666            let arc1 = cache.get_or_create(code).unwrap();
1667            let arc2 = cache.get_or_create(code).unwrap();
1668            assert!(
1669                Arc::ptr_eq(&arc1, &arc2),
1670                "should be cache hit after creation"
1671            );
1672        }
1673    }
1674
1675    // --- Part 20: Multiple restores from same snapshot are independent ---
1676    // Verifies that user code in one restored context does not leak to another.
1677    {
1678        let bridge_code = r#"
1679                (function() {
1680                    globalThis.__bridge_ok = true;
1681                })();
1682            "#;
1683        let blob = create_snapshot(bridge_code).expect("snapshot creation");
1684        let blob_bytes: Vec<u8> = blob.to_vec();
1685
1686        // Restore A: set a session-specific global
1687        {
1688            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1689            let scope = &mut v8::HandleScope::new(&mut isolate);
1690            let context = v8::Context::new(scope, Default::default());
1691            let scope = &mut v8::ContextScope::new(scope, context);
1692
1693            // Bridge state from snapshot should be present
1694            let check = v8::String::new(scope, "String(__bridge_ok)").unwrap();
1695            let script = v8::Script::compile(scope, check, None).unwrap();
1696            let result = script.run(scope).unwrap();
1697            assert_eq!(result.to_rust_string_lossy(scope), "true");
1698
1699            // Set session-specific state
1700            let code = v8::String::new(scope, "globalThis.__user_data = 'session-a';").unwrap();
1701            let script = v8::Script::compile(scope, code, None).unwrap();
1702            script.run(scope);
1703        }
1704
1705        // Restore B: session A's state should not be visible
1706        {
1707            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1708            let scope = &mut v8::HandleScope::new(&mut isolate);
1709            let context = v8::Context::new(scope, Default::default());
1710            let scope = &mut v8::ContextScope::new(scope, context);
1711
1712            // Bridge state should still be present
1713            let check = v8::String::new(scope, "String(__bridge_ok)").unwrap();
1714            let script = v8::Script::compile(scope, check, None).unwrap();
1715            let result = script.run(scope).unwrap();
1716            assert_eq!(result.to_rust_string_lossy(scope), "true");
1717
1718            // Session A's data should NOT be visible
1719            let check = v8::String::new(scope, "typeof __user_data").unwrap();
1720            let script = v8::Script::compile(scope, check, None).unwrap();
1721            let result = script.run(scope).unwrap();
1722            assert_eq!(
1723                result.to_rust_string_lossy(scope),
1724                "undefined",
1725                "session B should not see session A's user data"
1726            );
1727        }
1728    }
1729
1730    // --- Part 21: Userland snapshot — evaluated graph captured, ZERO re-eval on
1731    // restore, isolation preserved (2b acceptance). ---
1732    {
1733        // Evaluate a string in a fresh context on a restored isolate. Unlike the
1734        // function-level `eval`, this takes an existing ContextScope so successive
1735        // checks observe globals set by earlier scripts in the SAME context.
1736        fn run_in(scope: &mut v8::ContextScope<v8::HandleScope>, code: &str) -> String {
1737            let source = v8::String::new(scope, code).unwrap();
1738            let script = v8::Script::compile(scope, source, None).unwrap();
1739            let result = script.run(scope).unwrap();
1740            result.to_rust_string_lossy(scope)
1741        }
1742
1743        // `userland` stands in for an esbuild IIFE bundle: it evaluates a small
1744        // module graph, references a bridge-provided global (proving the bridge is
1745        // available when userland runs), publishes exports on globalThis, and bumps
1746        // a side-effect counter so we can prove the top-level runs exactly once.
1747        let bridge_code = "(function(){ globalThis.__bridge_ok = true; })();";
1748        let userland = r#"
1749            (function () {
1750                if (typeof globalThis._fsReadFile !== "function") {
1751                    throw new Error("bridge fns missing during userland eval");
1752                }
1753                globalThis.__sideEffectCount = (globalThis.__sideEffectCount || 0) + 1;
1754                var secret = 42;
1755                globalThis.__x = { f: function () { return secret; } };
1756            })();
1757        "#;
1758
1759        let blob = create_snapshot_with_userland(bridge_code, userland)
1760            .expect("userland snapshot creation should succeed");
1761        let blob_bytes: Vec<u8> = blob.to_vec();
1762
1763        // Restore A: fresh isolate + fresh context cloned from the snapshot default
1764        // context. The userland top-level must NOT run again here.
1765        {
1766            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1767            let scope = &mut v8::HandleScope::new(&mut isolate);
1768            let context = v8::Context::new(scope, Default::default());
1769            let scope = &mut v8::ContextScope::new(scope, context);
1770
1771            assert_eq!(
1772                run_in(scope, "String(globalThis.__x.f())"),
1773                "42",
1774                "userland export __x.f() should return 42 from the snapshot"
1775            );
1776            assert_eq!(
1777                run_in(scope, "String(globalThis.__sideEffectCount)"),
1778                "1",
1779                "userland top-level must run exactly once (zero re-eval on restore)"
1780            );
1781            assert_eq!(
1782                run_in(scope, "String(globalThis.__bridge_ok)"),
1783                "true",
1784                "bridge state should coexist with userland state in the snapshot"
1785            );
1786
1787            // Mutate a global in session A.
1788            run_in(scope, "globalThis.__leak = 'session-a'; ''");
1789        }
1790
1791        // Restore B: a separate fresh isolate from the SAME blob must see the
1792        // captured userland state but NOT session A's mutation (isolation).
1793        {
1794            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1795            let scope = &mut v8::HandleScope::new(&mut isolate);
1796            let context = v8::Context::new(scope, Default::default());
1797            let scope = &mut v8::ContextScope::new(scope, context);
1798
1799            assert_eq!(
1800                run_in(scope, "String(globalThis.__x.f())"),
1801                "42",
1802                "session B should see the captured userland export"
1803            );
1804            assert_eq!(
1805                run_in(scope, "String(globalThis.__sideEffectCount)"),
1806                "1",
1807                "session B counter must still be 1 (no re-eval, no cross-session bump)"
1808            );
1809            assert_eq!(
1810                run_in(scope, "typeof globalThis.__leak"),
1811                "undefined",
1812                "session B must NOT observe session A's mutation"
1813            );
1814        }
1815
1816        // Cache: identical (bridge, userland) → same Arc; changed userland → new Arc.
1817        {
1818            let cache = SnapshotCache::new(4);
1819            let a = cache
1820                .get_or_create_with_userland(bridge_code, Some(userland))
1821                .expect("userland cache create");
1822            let b = cache
1823                .get_or_create_with_userland(bridge_code, Some(userland))
1824                .expect("userland cache hit");
1825            assert!(
1826                Arc::ptr_eq(&a, &b),
1827                "identical userland should hit the cache"
1828            );
1829
1830            let userland2 = "(function(){ globalThis.__x = { f: function(){ return 7; } }; })();";
1831            let c = cache
1832                .get_or_create_with_userland(bridge_code, Some(userland2))
1833                .expect("changed userland create");
1834            assert!(
1835                !Arc::ptr_eq(&a, &c),
1836                "changed userland (dep-graph change) should rebuild"
1837            );
1838        }
1839    }
1840
1841    // --- Part 22: REAL agent-SDK bundle snapshots + restores (env-gated). ---
1842    // End-to-end primitive validation against the actual pi SDK snapshot bundle:
1843    // the real bridge bundle + the real esbuild IIFE evaluate together into the
1844    // snapshot, and a fresh restored isolate exposes the SDK runtime global with a
1845    // working createAgentSession. Gated on PI_SNAPSHOT_BUNDLE_PATH so CI without the
1846    // bundle skips it; run with that env var pointing at dist/pi-sdk-snapshot.js.
1847    if let Ok(bundle_path) = std::env::var("PI_SNAPSHOT_BUNDLE_PATH") {
1848        let userland = std::fs::read_to_string(&bundle_path)
1849            .unwrap_or_else(|e| panic!("read pi bundle at {bundle_path}: {e}"));
1850        let bridge_code = concat!(
1851            include_str!(concat!(env!("OUT_DIR"), "/v8-bridge.js")),
1852            "\n",
1853            include_str!(concat!(env!("OUT_DIR"), "/v8-bridge-zlib.js"))
1854        );
1855
1856        let blob = create_snapshot_with_userland(bridge_code, &userland)
1857            .expect("real pi SDK bundle should snapshot cleanly (pure-JS, no top-level I/O)");
1858        let mut isolate = create_isolate_from_snapshot(blob, None);
1859        let scope = &mut v8::HandleScope::new(&mut isolate);
1860        let context = v8::Context::new(scope, Default::default());
1861        let scope = &mut v8::ContextScope::new(scope, context);
1862
1863        let check = v8::String::new(
1864            scope,
1865            "(function(){ var r = globalThis.__PI_SDK_RUNTIME__; \
1866             return r && typeof r.createAgentSession === 'function' && \
1867             typeof r.createAllTools === 'function' ? 'ok' : 'missing'; })()",
1868        )
1869        .unwrap();
1870        let script = v8::Script::compile(scope, check, None).unwrap();
1871        let result = script.run(scope).unwrap();
1872        assert_eq!(
1873            result.to_rust_string_lossy(scope),
1874            "ok",
1875            "restored isolate must expose the pi SDK runtime global from the snapshot"
1876        );
1877    }
1878
1879    // --- Part 23: cross-thread snapshot build → restore (diagnoses pre-warm). ---
1880    // Build a userland snapshot on a SEPARATE spawned+joined thread, then restore and
1881    // eval it on the main thread. If V8 fundamentally forbids restoring a blob built
1882    // on a different thread (the suspected cause of the pre-warm wedge), this aborts.
1883    {
1884        let bridge_code = "(function(){ globalThis.__xt_bridge = true; })();";
1885        let userland = "(function(){ globalThis.__xt = { f: function(){ return 99; } }; })();";
1886        let blob_bytes: Vec<u8> = std::thread::spawn(move || {
1887            create_snapshot_with_userland(bridge_code, userland)
1888                .expect("cross-thread snapshot build should succeed")
1889                .to_vec()
1890        })
1891        .join()
1892        .expect("build thread join");
1893
1894        let mut isolate = create_isolate_from_snapshot(blob_bytes, None);
1895        let scope = &mut v8::HandleScope::new(&mut isolate);
1896        let context = v8::Context::new(scope, Default::default());
1897        let scope = &mut v8::ContextScope::new(scope, context);
1898        let check = v8::String::new(scope, "String(globalThis.__xt.f())").unwrap();
1899        let script = v8::Script::compile(scope, check, None).unwrap();
1900        let result = script.run(scope).unwrap();
1901        assert_eq!(
1902            result.to_rust_string_lossy(scope),
1903            "99",
1904            "a snapshot built on another thread must restore correctly on this thread"
1905        );
1906    }
1907
1908    // --- Part 24: session-level isolation (global AND prototype). ---
1909    // Each agent session leases a FRESH context cloned from the same snapshot's
1910    // default context. This is the isolation unit: a global or built-in-prototype
1911    // mutation in "session A" must NOT be observable in "session B".
1912    {
1913        let bridge_code = "(function(){ globalThis.__iso_ok = true; })();";
1914        let userland = "(function(){ globalThis.__sdk = { v: 1 }; })();";
1915        let blob_bytes: Vec<u8> = create_snapshot_with_userland(bridge_code, userland)
1916            .expect("isolation snapshot")
1917            .to_vec();
1918
1919        // Session A: mutate a global, the captured SDK object, AND a built-in prototype.
1920        {
1921            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1922            let scope = &mut v8::HandleScope::new(&mut isolate);
1923            let context = v8::Context::new(scope, Default::default());
1924            let scope = &mut v8::ContextScope::new(scope, context);
1925            let src = v8::String::new(
1926                scope,
1927                "globalThis.__leakG = 'A'; globalThis.__sdk.v = 999; \
1928                 Array.prototype.__leakP = 'A'; ''",
1929            )
1930            .unwrap();
1931            let script = v8::Script::compile(scope, src, None).unwrap();
1932            script.run(scope);
1933        }
1934
1935        // Session B: a separate fresh context from the SAME blob sees the captured
1936        // snapshot state but NONE of session A's mutations.
1937        {
1938            let mut isolate = create_isolate_from_snapshot(blob_bytes.clone(), None);
1939            let scope = &mut v8::HandleScope::new(&mut isolate);
1940            let context = v8::Context::new(scope, Default::default());
1941            let scope = &mut v8::ContextScope::new(scope, context);
1942            let check = v8::String::new(
1943                scope,
1944                "(function(){ return [ \
1945                   String(globalThis.__iso_ok), \
1946                   typeof globalThis.__leakG, \
1947                   String(globalThis.__sdk.v), \
1948                   typeof ([].__leakP) \
1949                 ].join(','); })()",
1950            )
1951            .unwrap();
1952            let script = v8::Script::compile(scope, check, None).unwrap();
1953            let result = script.run(scope).unwrap();
1954            assert_eq!(
1955                result.to_rust_string_lossy(scope),
1956                "true,undefined,1,undefined",
1957                "session B must see snapshot state but NOT session A's global/SDK/prototype mutations"
1958            );
1959        }
1960    }
1961
1962    // --- Part 25: H-1 regression — the userland-prep `process.versions` wrapper
1963    // DEFERS to the bridge's live getter post-restore instead of freezing a static
1964    // identity. ---
1965    // `SNAPSHOT_USERLAND_PREP` wraps (does not replace) the bridge's lazy
1966    // `process.versions` getter: during snapshot creation the live getter throws
1967    // (bridge fns are stubs) so a static identity is used, but post-restore — once
1968    // the real bridge fns + per-session config are injected — sessions must read the
1969    // LIVE per-session value. A plain static pin (the prior bug) permanently shadowed
1970    // it. This models the deferral with a getter whose result depends on the
1971    // post-restore-injected `_processConfig`, so a regression back to a static pin
1972    // fails here.
1973    {
1974        use crate::bridge::serialize_v8_value;
1975
1976        // Bridge installs a lazy `process.versions` getter that derives its value from
1977        // the per-session `_processConfig` (resolved live on each access). During
1978        // snapshot creation `_processConfig` is the default (no `version`), so the
1979        // getter throws — exactly the case the prep wraps. A userland is required so
1980        // SNAPSHOT_USERLAND_PREP runs.
1981        let bridge_code = r#"
1982            (function () {
1983                globalThis.process = {
1984                    get versions() {
1985                        // Throws during creation (default _processConfig has no
1986                        // version); returns the live per-session value post-restore.
1987                        return { node: _processConfig.version.replace(/^v/, "") };
1988                    },
1989                };
1990            })();
1991        "#;
1992        let userland = "(function(){ globalThis.__sdk_ready = true; })();";
1993        let blob = create_snapshot_with_userland(bridge_code, userland)
1994            .expect("userland snapshot with a lazy process.versions getter");
1995
1996        let mut isolate = create_isolate_from_snapshot(blob, None);
1997        let scope = &mut v8::HandleScope::new(&mut isolate);
1998        let context = v8::Context::new(scope, Default::default());
1999        let scope = &mut v8::ContextScope::new(scope, context);
2000
2001        // Inject a per-session config carrying a distinctive version (as the sidecar
2002        // does post-restore via inject_globals_from_payload).
2003        let payload_code = r#"({
2004            processConfig: { cwd: "/", env: {}, version: "v99.1.2", timing_mitigation: "off", frozen_time_ms: null },
2005            osConfig: { homedir: "/root", tmpdir: "/tmp", platform: "linux", arch: "x64" }
2006        })"#;
2007        let payload_source = v8::String::new(scope, payload_code).unwrap();
2008        let payload_script = v8::Script::compile(scope, payload_source, None).unwrap();
2009        let payload_val = payload_script.run(scope).unwrap();
2010        let payload_bytes = serialize_v8_value(scope, payload_val).expect("serialize payload");
2011        crate::execution::inject_globals_from_payload(scope, &payload_bytes)
2012            .expect("inject per-session config");
2013
2014        // The wrapper must defer to the live getter → per-session "99.1.2", NOT the
2015        // snapshot-build-time static identity "20.0.0".
2016        let check = v8::String::new(scope, "String(process.versions.node)").unwrap();
2017        let script = v8::Script::compile(scope, check, None).unwrap();
2018        let result = script.run(scope).unwrap();
2019        assert_eq!(
2020            result.to_rust_string_lossy(scope),
2021            "99.1.2",
2022            "process.versions must defer to the live per-session getter post-restore, \
2023             not the frozen snapshot-build-time static identity (H-1 regression)"
2024        );
2025    }
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030    use super::*;
2031
2032    #[test]
2033    fn bridge_cache_key_uses_full_sha256_digest() {
2034        // With no userland the key is the plain sha256 of the bridge code, so
2035        // bridge-only snapshot entries keep their historical keys.
2036        assert_eq!(
2037            snapshot_cache_key("abc", None),
2038            [
2039                0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
2040                0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
2041                0xf2, 0x00, 0x15, 0xad,
2042            ]
2043        );
2044    }
2045
2046    #[test]
2047    fn create_snapshot_rejects_oversized_bridge_code_before_v8_creation() {
2048        let bridge_code = " ".repeat(MAX_V8_BRIDGE_CODE_BYTES + 1);
2049        let error = match create_snapshot(&bridge_code) {
2050            Ok(_) => panic!("oversized bridge code should be rejected"),
2051            Err(error) => error,
2052        };
2053
2054        assert!(error.contains(V8_BRIDGE_CODE_LIMIT_ERROR_CODE));
2055        assert!(error.contains("bridge code too large for V8 bridge setup"));
2056        assert!(error.contains(&MAX_V8_BRIDGE_CODE_BYTES.to_string()));
2057    }
2058
2059    #[test]
2060    fn snapshot_cache_rejects_oversized_bridge_code_without_retaining_in_flight_state() {
2061        let cache = SnapshotCache::new(1);
2062        let bridge_code = " ".repeat(MAX_V8_BRIDGE_CODE_BYTES + 1);
2063
2064        for _ in 0..2 {
2065            let error = match cache.get_or_create(&bridge_code) {
2066                Ok(_) => panic!("oversized bridge code should be rejected"),
2067                Err(error) => error,
2068            };
2069
2070            assert!(error.contains(V8_BRIDGE_CODE_LIMIT_ERROR_CODE));
2071        }
2072    }
2073
2074    #[test]
2075    fn snapshot_cache_key_is_dep_keyed_over_bridge_and_userland() {
2076        let bridge = "bridge-a";
2077        // Userland presence changes the key vs bridge-only.
2078        assert_ne!(
2079            snapshot_cache_key(bridge, None),
2080            snapshot_cache_key(bridge, Some("user-1")),
2081            "adding userland must change the key"
2082        );
2083        // Different userland → different key (any dep-graph change invalidates).
2084        assert_ne!(
2085            snapshot_cache_key(bridge, Some("user-1")),
2086            snapshot_cache_key(bridge, Some("user-2")),
2087            "different userland must produce a different key"
2088        );
2089        // Identical inputs → identical key (cache hit).
2090        assert_eq!(
2091            snapshot_cache_key(bridge, Some("user-1")),
2092            snapshot_cache_key(bridge, Some("user-1")),
2093        );
2094        // The NUL separator prevents bridge/userland boundary collisions.
2095        assert_ne!(
2096            snapshot_cache_key("ab", Some("c")),
2097            snapshot_cache_key("a", Some("bc")),
2098            "the bridge/userland split must be unambiguous"
2099        );
2100    }
2101
2102    #[test]
2103    fn create_snapshot_with_userland_rejects_oversized_userland_code() {
2104        let bridge_code = "(function(){})();";
2105        let userland = " ".repeat(MAX_V8_USERLAND_CODE_BYTES + 1);
2106        let error = match create_snapshot_with_userland(bridge_code, &userland) {
2107            Ok(_) => panic!("oversized userland code should be rejected"),
2108            Err(error) => error,
2109        };
2110
2111        assert!(error.contains(V8_USERLAND_CODE_LIMIT_ERROR_CODE));
2112        assert!(error.contains("userland snapshot code too large"));
2113    }
2114}