//! V8 shares one read-only heap between the live isolates of a process and requires //! every isolate to bring a byte-identical image. These tests pin how snapshots, //! cold bootstraps, and failed creators respect that invariant. mod support; use std::io; use std::sync::{Arc, Barrier}; use avril_core::{Error, Extension, Op, Runtime, RuntimeOptions, Snapshot, SnapshotError}; fn layout(name: &'static str, seed: u32) -> RuntimeOptions { let variant = match name { "a" => "globalThis.layout = 'a';", "b" => "globalThis.layout = 'b'; globalThis.extra = [1, 2, 3].map(value => () => value);", "cold" => "globalThis.layout = 'cold';", _ => panic!("Unknown test layout: {name}"), }; RuntimeOptions { extensions: vec![Extension { name: "ext:layout", dependencies: &[], ops: vec![Op::new("seed", move |(): ()| Ok::<_, io::Error>(seed))], modules: Box::leak(Box::new([ ("ext:layout/variant.js", variant), ( "ext:layout/main.js", r#" import 'ext:layout/variant.js'; import {onRuntimeStart} from 'ext:core/bootstrap'; import {seed} from 'ext:core/ops'; globalThis.started = 0; onRuntimeStart(() => { globalThis.seed = seed(); globalThis.started++; }); "#, ), ])), entry_point: Some("ext:layout/main.js"), }], ..Default::default() } } fn state(runtime: &mut Runtime) -> (String, u32, u32) { runtime .execute_script("state.js", "[layout, seed, started]") .unwrap() } fn write(name: &str, snapshot: &Snapshot) { std::fs::write(support::directory().join(name), snapshot.as_bytes()).unwrap(); } fn read(name: &str) -> Snapshot { let bytes = std::fs::read(support::directory().join(name)).unwrap(); // SAFETY: A sibling role produced this artifact with this executable. unsafe { Snapshot::from_bytes(bytes) }.unwrap() } fn rejected(result: Result) -> bool { matches!( result, Err(Error::Snapshot(SnapshotError::ReadOnlyHeapInUse)) ) } #[test] fn layouts_share_one_snapshot_heap() { match support::role().as_deref() { Some("create") => { avril_core::initialize_snapshot_generator().unwrap(); let snapshot = Runtime::create_snapshot(vec![layout("a", 0), layout("b", 0)]).unwrap(); write("layouts.snapshot", &snapshot); } Some("restore") => { let snapshot = Arc::new(read("layouts.snapshot")); assert!(matches!( Runtime::from_snapshot(layout("cold", 9), &snapshot), Err(Error::Snapshot(SnapshotError::IncompatibleLayout)) )); let plan = [("a", 1), ("b", 2), ("a", 3), ("b", 4)]; let barrier = Arc::new(Barrier::new(plan.len() + 1)); let threads: Vec<_> = plan .into_iter() .map(|(name, seed)| { let (snapshot, barrier) = (snapshot.clone(), barrier.clone()); std::thread::spawn(move || { let mut runtime = Runtime::from_snapshot(layout(name, seed), &snapshot).unwrap(); assert_eq!(state(&mut runtime), (name.into(), seed, 1)); barrier.wait(); barrier.wait(); assert_eq!(state(&mut runtime), (name.into(), seed, 1)); }) }) .collect(); barrier.wait(); // Every live isolate shares the snapshot's read-only heap, so V8's // built-in heap, including snapshot creators, must wait. assert!(rejected(Runtime::new(RuntimeOptions::default()))); assert!(rejected(Runtime::create_snapshot(vec![layout("a", 0)]))); barrier.wait(); for thread in threads { thread.join().unwrap(); } let mut cold = Runtime::new(layout("cold", 6)).unwrap(); assert_eq!(state(&mut cold), ("cold".into(), 6, 1)); } _ => support::roles("layouts_share_one_snapshot_heap", &["create", "restore"]), } } #[test] fn separately_generated_snapshots_never_overlap() { match support::role().as_deref() { Some("create") => { write( "a.snapshot", &Runtime::create_snapshot(vec![layout("a", 0)]).unwrap(), ); write( "b.snapshot", &Runtime::create_snapshot(vec![layout("b", 0)]).unwrap(), ); } Some("restore") => { let (a, b) = (read("a.snapshot"), read("b.snapshot")); let mut first = Runtime::from_snapshot(layout("a", 1), &a).unwrap(); assert!(rejected(Runtime::from_snapshot(layout("b", 2), &b))); // Another import of the same artifact is the same read-only heap. // Isolates on one thread are entered in creation order, so use them in reverse. let mut again = Runtime::from_snapshot(layout("a", 4), &read("a.snapshot")).unwrap(); assert_eq!(state(&mut again), ("a".into(), 4, 1)); drop(again); assert_eq!(state(&mut first), ("a".into(), 1, 1)); drop(first); let mut second = Runtime::from_snapshot(layout("b", 5), &b).unwrap(); assert_eq!(state(&mut second), ("b".into(), 5, 1)); } _ => support::roles( "separately_generated_snapshots_never_overlap", &["create", "restore"], ), } } #[test] fn failed_creators_restore_the_thread_isolate_and_pin_the_built_in_heap() { if support::role().is_none() { return support::roles( "failed_creators_restore_the_thread_isolate_and_pin_the_built_in_heap", &["run"], ); } let snapshot = Runtime::create_snapshot(vec![layout("a", 0)]).unwrap(); let mut earlier = Runtime::new(layout("cold", 1)).unwrap(); let failure = Runtime::create_snapshot(vec![RuntimeOptions { extensions: vec![Extension { name: "ext:fail", dependencies: &[], ops: vec![], modules: &[("ext:fail/main.js", "throw Error('failed static setup');")], entry_point: Some("ext:fail/main.js"), }], ..Default::default() }]); assert!( matches!(&failure, Err(Error::JavaScript(message)) if message.contains("failed static setup") && message.contains("at ext:fail/main.js:1:7")), "{:?}", failure.err() ); // The leaked creator no longer shadows runtimes created before it on this thread. assert_eq!(state(&mut earlier), ("cold".into(), 1, 1)); drop(earlier); // It still holds V8's built-in read-only heap for the rest of the process. assert!(rejected(Runtime::from_snapshot(layout("a", 2), &snapshot))); let mut later = Runtime::new(layout("cold", 3)).unwrap(); assert_eq!(state(&mut later), ("cold".into(), 3, 1)); } #[test] fn snapshot_generators_reproduce_artifacts_and_refuse_runtimes() { match support::role().as_deref() { Some(role @ ("first" | "second")) => { avril_core::initialize_snapshot_generator().unwrap(); avril_core::initialize_snapshot_generator().unwrap(); let snapshot = Runtime::create_snapshot(vec![layout("a", 0), layout("b", 0)]).unwrap(); write(&format!("{role}.snapshot"), &snapshot); assert!(matches!( Runtime::new(RuntimeOptions::default()), Err(Error::Snapshot(SnapshotError::GeneratorProcess)) )); assert!(matches!( Runtime::from_snapshot(layout("a", 1), &snapshot), Err(Error::Snapshot(SnapshotError::GeneratorProcess)) )); } Some("restore") => { let directory = support::directory(); assert_eq!( std::fs::read(directory.join("first.snapshot")).unwrap(), std::fs::read(directory.join("second.snapshot")).unwrap(), "snapshot generation is not reproducible" ); let mut runtime = Runtime::from_snapshot(layout("b", 7), &read("first.snapshot")).unwrap(); assert_eq!(state(&mut runtime), ("b".into(), 7, 1)); assert!(matches!( avril_core::initialize_snapshot_generator(), Err(SnapshotError::RuntimeProcess) )); } _ => support::roles( "snapshot_generators_reproduce_artifacts_and_refuse_runtimes", &["first", "second", "restore"], ), } } #[test] fn static_bootstrap_sees_stack_limits_and_restores_get_live_ones() { if support::role().is_none() { return support::roles( "static_bootstrap_sees_stack_limits_and_restores_get_live_ones", &["run"], ); } let options = |source: &'static str| RuntimeOptions { extensions: vec![Extension { name: "ext:limit", dependencies: &[], ops: vec![], modules: Box::leak(Box::new([("ext:limit/main.js", source)])), entry_point: Some("ext:limit/main.js"), }], ..Default::default() }; let observe = r#" globalThis.staticLimit = Error.stackTraceLimit; globalThis.staticStack = new Error('static').stack.includes('ext:limit/main.js:3:'); "#; let probe = "[staticLimit, staticStack, JSON.stringify(Object.getOwnPropertyDescriptor(Error, 'stackTraceLimit'))]"; let cold: (u32, bool, String) = Runtime::new(options(observe)) .unwrap() .execute_script("probe.js", probe) .unwrap(); assert_eq!( cold, ( 10, true, r#"{"value":10,"writable":true,"enumerable":true,"configurable":true}"#.into() ) ); let snapshot = Runtime::create_snapshot(vec![options(observe)]).unwrap(); let state: (u32, bool, String) = Runtime::from_snapshot(options(observe), &snapshot) .unwrap() .execute_script("probe.js", probe) .unwrap(); assert_eq!(state, cold); // Restored contexts get V8's live property, so a changed static limit cannot persist. assert!(matches!( Runtime::create_snapshot(vec![options("Error.stackTraceLimit = 3;")]), Err(Error::Snapshot(SnapshotError::ActiveState( "Error.stackTraceLimit" ))) )); } #[test] fn layout_fingerprints_frame_every_field() { if support::role().is_none() { return support::roles("layout_fingerprints_frame_every_field", &["run"]); } let options = |ops: [&'static str; 2]| RuntimeOptions { extensions: vec![Extension { name: "ext:frame", dependencies: &[], ops: ops .into_iter() .map(|name| Op::new(name, |(): ()| Ok::<_, io::Error>(()))) .collect(), modules: &[("ext:frame/main.js", "")], entry_point: Some("ext:frame/main.js"), }], ..Default::default() }; let snapshot = Runtime::create_snapshot(vec![options(["ab", "c"])]).unwrap(); assert!(matches!( Runtime::from_snapshot(options(["a", "bc"]), &snapshot), Err(Error::Snapshot(SnapshotError::IncompatibleLayout)) )); Runtime::from_snapshot(options(["c", "ab"]), &snapshot).unwrap(); assert!(matches!( Runtime::create_snapshot(vec![options(["ab", "c"]), options(["c", "ab"])]), Err(Error::Snapshot(SnapshotError::DuplicateLayout)) )); } #[test] fn snapshot_containers_are_validated() { let cases: [&'static [u8]; 4] = [ b"", b"not an avril snapshot", b"AVRILSNP\x02\0\0\0\0\0\0\0", b"AVRILSNP\x01\0\0\0\xff\0\0\0{}", ]; for bytes in cases { // SAFETY: Only the avril container is read; invalid containers stop there. let result = unsafe { Snapshot::from_bytes(bytes) }; assert!(matches!(result, Err(SnapshotError::InvalidArtifact(_)))); } } /// A module loader whose destructor panics, as a faulty native resource might. struct PanickingLoader; impl Drop for PanickingLoader { fn drop(&mut self) { panic!("native state destructor panicked"); } } impl avril_core::ModuleLoader for PanickingLoader { fn resolve(&self, specifier: &str, _: &str) -> Result { Ok(specifier.into()) } fn load(&self, name: &str) -> Result { Err(Error::Module(format!("Missing {name}"))) } } /// Core-owned native state (the module map) holds the only loader reference. fn panicking_state(module: &'static str) -> RuntimeOptions { RuntimeOptions { extensions: vec![Extension { name: "ext:panic", dependencies: &[], ops: vec![], modules: Box::leak(Box::new([("ext:panic/main.js", module)])), entry_point: Some("ext:panic/main.js"), }], module_loader: Some(std::rc::Rc::new(PanickingLoader)), } } #[test] fn isolates_leaked_by_panicking_disposal_keep_their_heap() { let Some(role) = support::role() else { return support::roles( "isolates_leaked_by_panicking_disposal_keep_their_heap", &["out-of-order", "runtime-state", "creator-state"], ); }; let snapshot = Runtime::create_snapshot(vec![layout("a", 0)]).unwrap(); match role.as_str() { "out-of-order" => { let first = Runtime::new(layout("cold", 1)).unwrap(); let mut second = Runtime::new(layout("cold", 2)).unwrap(); // rusty_v8 refuses to dispose an isolate that is not the thread's current one. let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(first))); assert!(dropped.is_err()); assert_eq!(state(&mut second), ("cold".into(), 2, 1)); } "runtime-state" => { let runtime = Runtime::new(panicking_state("")).unwrap(); let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(runtime))); assert!(dropped.is_err()); } _ => { let mut earlier = Runtime::new(layout("cold", 1)).unwrap(); let failed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { Runtime::create_snapshot(vec![panicking_state("throw Error('static failure');")]) })); assert!(failed.is_err()); // The abandoned creator still left the thread's isolate stack. assert_eq!(state(&mut earlier), ("cold".into(), 1, 1)); } } // The undisposed isolate still holds V8's built-in read-only heap. assert!(rejected(Runtime::from_snapshot(layout("a", 3), &snapshot))); let mut later = Runtime::new(layout("cold", 4)).unwrap(); assert_eq!(state(&mut later), ("cold".into(), 4, 1)); }