char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsnapshot: capture and restore extension layouts in one artifactd71572b

main
14.9 KiB384 linesraw
1//! V8 shares one read-only heap between the live isolates of a process and requires
2//! every isolate to bring a byte-identical image. These tests pin how snapshots,
3//! cold bootstraps, and failed creators respect that invariant.
4
5mod support;
6
7use std::io;
8use std::sync::{Arc, Barrier};
9
10use avril_core::{Error, Extension, Op, Runtime, RuntimeOptions, Snapshot, SnapshotError};
11
12fn layout(name: &'static str, seed: u32) -> RuntimeOptions {
13    let variant = match name {
14        "a" => "globalThis.layout = 'a';",
15        "b" => "globalThis.layout = 'b'; globalThis.extra = [1, 2, 3].map(value => () => value);",
16        "cold" => "globalThis.layout = 'cold';",
17        _ => panic!("Unknown test layout: {name}"),
18    };
19    RuntimeOptions {
20        extensions: vec![Extension {
21            name: "ext:layout",
22            dependencies: &[],
23            ops: vec![Op::new("seed", move |(): ()| Ok::<_, io::Error>(seed))],
24            modules: Box::leak(Box::new([
25                ("ext:layout/variant.js", variant),
26                (
27                    "ext:layout/main.js",
28                    r#"
29                    import 'ext:layout/variant.js';
30                    import {onRuntimeStart} from 'ext:core/bootstrap';
31                    import {seed} from 'ext:core/ops';
32                    globalThis.started = 0;
33                    onRuntimeStart(() => { globalThis.seed = seed(); globalThis.started++; });
34                "#,
35                ),
36            ])),
37            entry_point: Some("ext:layout/main.js"),
38        }],
39        ..Default::default()
40    }
41}
42
43fn state(runtime: &mut Runtime) -> (String, u32, u32) {
44    runtime
45        .execute_script("state.js", "[layout, seed, started]")
46        .unwrap()
47}
48
49fn write(name: &str, snapshot: &Snapshot) {
50    std::fs::write(support::directory().join(name), snapshot.as_bytes()).unwrap();
51}
52
53fn read(name: &str) -> Snapshot {
54    let bytes = std::fs::read(support::directory().join(name)).unwrap();
55    // SAFETY: A sibling role produced this artifact with this executable.
56    unsafe { Snapshot::from_bytes(bytes) }.unwrap()
57}
58
59fn rejected<T>(result: Result<T, Error>) -> bool {
60    matches!(
61        result,
62        Err(Error::Snapshot(SnapshotError::ReadOnlyHeapInUse))
63    )
64}
65
66#[test]
67fn layouts_share_one_snapshot_heap() {
68    match support::role().as_deref() {
69        Some("create") => {
70            avril_core::initialize_snapshot_generator().unwrap();
71            let snapshot = Runtime::create_snapshot(vec![layout("a", 0), layout("b", 0)]).unwrap();
72            write("layouts.snapshot", &snapshot);
73        }
74        Some("restore") => {
75            let snapshot = Arc::new(read("layouts.snapshot"));
76            assert!(matches!(
77                Runtime::from_snapshot(layout("cold", 9), &snapshot),
78                Err(Error::Snapshot(SnapshotError::IncompatibleLayout))
79            ));
80            let plan = [("a", 1), ("b", 2), ("a", 3), ("b", 4)];
81            let barrier = Arc::new(Barrier::new(plan.len() + 1));
82            let threads: Vec<_> = plan
83                .into_iter()
84                .map(|(name, seed)| {
85                    let (snapshot, barrier) = (snapshot.clone(), barrier.clone());
86                    std::thread::spawn(move || {
87                        let mut runtime =
88                            Runtime::from_snapshot(layout(name, seed), &snapshot).unwrap();
89                        assert_eq!(state(&mut runtime), (name.into(), seed, 1));
90                        barrier.wait();
91                        barrier.wait();
92                        assert_eq!(state(&mut runtime), (name.into(), seed, 1));
93                    })
94                })
95                .collect();
96            barrier.wait();
97            // Every live isolate shares the snapshot's read-only heap, so V8's
98            // built-in heap, including snapshot creators, must wait.
99            assert!(rejected(Runtime::new(RuntimeOptions::default())));
100            assert!(rejected(Runtime::create_snapshot(vec![layout("a", 0)])));
101            barrier.wait();
102            for thread in threads {
103                thread.join().unwrap();
104            }
105            let mut cold = Runtime::new(layout("cold", 6)).unwrap();
106            assert_eq!(state(&mut cold), ("cold".into(), 6, 1));
107        }
108        _ => support::roles("layouts_share_one_snapshot_heap", &["create", "restore"]),
109    }
110}
111
112#[test]
113fn separately_generated_snapshots_never_overlap() {
114    match support::role().as_deref() {
115        Some("create") => {
116            write(
117                "a.snapshot",
118                &Runtime::create_snapshot(vec![layout("a", 0)]).unwrap(),
119            );
120            write(
121                "b.snapshot",
122                &Runtime::create_snapshot(vec![layout("b", 0)]).unwrap(),
123            );
124        }
125        Some("restore") => {
126            let (a, b) = (read("a.snapshot"), read("b.snapshot"));
127            let mut first = Runtime::from_snapshot(layout("a", 1), &a).unwrap();
128            assert!(rejected(Runtime::from_snapshot(layout("b", 2), &b)));
129            // Another import of the same artifact is the same read-only heap.
130            // Isolates on one thread are entered in creation order, so use them in reverse.
131            let mut again = Runtime::from_snapshot(layout("a", 4), &read("a.snapshot")).unwrap();
132            assert_eq!(state(&mut again), ("a".into(), 4, 1));
133            drop(again);
134            assert_eq!(state(&mut first), ("a".into(), 1, 1));
135            drop(first);
136            let mut second = Runtime::from_snapshot(layout("b", 5), &b).unwrap();
137            assert_eq!(state(&mut second), ("b".into(), 5, 1));
138        }
139        _ => support::roles(
140            "separately_generated_snapshots_never_overlap",
141            &["create", "restore"],
142        ),
143    }
144}
145
146#[test]
147fn failed_creators_restore_the_thread_isolate_and_pin_the_built_in_heap() {
148    if support::role().is_none() {
149        return support::roles(
150            "failed_creators_restore_the_thread_isolate_and_pin_the_built_in_heap",
151            &["run"],
152        );
153    }
154    let snapshot = Runtime::create_snapshot(vec![layout("a", 0)]).unwrap();
155    let mut earlier = Runtime::new(layout("cold", 1)).unwrap();
156    let failure = Runtime::create_snapshot(vec![RuntimeOptions {
157        extensions: vec![Extension {
158            name: "ext:fail",
159            dependencies: &[],
160            ops: vec![],
161            modules: &[("ext:fail/main.js", "throw Error('failed static setup');")],
162            entry_point: Some("ext:fail/main.js"),
163        }],
164        ..Default::default()
165    }]);
166    assert!(
167        matches!(&failure, Err(Error::JavaScript(message)) if message.contains("failed static setup") && message.contains("at ext:fail/main.js:1:7")),
168        "{:?}",
169        failure.err()
170    );
171    // The leaked creator no longer shadows runtimes created before it on this thread.
172    assert_eq!(state(&mut earlier), ("cold".into(), 1, 1));
173    drop(earlier);
174    // It still holds V8's built-in read-only heap for the rest of the process.
175    assert!(rejected(Runtime::from_snapshot(layout("a", 2), &snapshot)));
176    let mut later = Runtime::new(layout("cold", 3)).unwrap();
177    assert_eq!(state(&mut later), ("cold".into(), 3, 1));
178}
179
180#[test]
181fn snapshot_generators_reproduce_artifacts_and_refuse_runtimes() {
182    match support::role().as_deref() {
183        Some(role @ ("first" | "second")) => {
184            avril_core::initialize_snapshot_generator().unwrap();
185            avril_core::initialize_snapshot_generator().unwrap();
186            let snapshot = Runtime::create_snapshot(vec![layout("a", 0), layout("b", 0)]).unwrap();
187            write(&format!("{role}.snapshot"), &snapshot);
188            assert!(matches!(
189                Runtime::new(RuntimeOptions::default()),
190                Err(Error::Snapshot(SnapshotError::GeneratorProcess))
191            ));
192            assert!(matches!(
193                Runtime::from_snapshot(layout("a", 1), &snapshot),
194                Err(Error::Snapshot(SnapshotError::GeneratorProcess))
195            ));
196        }
197        Some("restore") => {
198            let directory = support::directory();
199            assert_eq!(
200                std::fs::read(directory.join("first.snapshot")).unwrap(),
201                std::fs::read(directory.join("second.snapshot")).unwrap(),
202                "snapshot generation is not reproducible"
203            );
204            let mut runtime =
205                Runtime::from_snapshot(layout("b", 7), &read("first.snapshot")).unwrap();
206            assert_eq!(state(&mut runtime), ("b".into(), 7, 1));
207            assert!(matches!(
208                avril_core::initialize_snapshot_generator(),
209                Err(SnapshotError::RuntimeProcess)
210            ));
211        }
212        _ => support::roles(
213            "snapshot_generators_reproduce_artifacts_and_refuse_runtimes",
214            &["first", "second", "restore"],
215        ),
216    }
217}
218
219#[test]
220fn static_bootstrap_sees_stack_limits_and_restores_get_live_ones() {
221    if support::role().is_none() {
222        return support::roles(
223            "static_bootstrap_sees_stack_limits_and_restores_get_live_ones",
224            &["run"],
225        );
226    }
227    let options = |source: &'static str| RuntimeOptions {
228        extensions: vec![Extension {
229            name: "ext:limit",
230            dependencies: &[],
231            ops: vec![],
232            modules: Box::leak(Box::new([("ext:limit/main.js", source)])),
233            entry_point: Some("ext:limit/main.js"),
234        }],
235        ..Default::default()
236    };
237    let observe = r#"
238        globalThis.staticLimit = Error.stackTraceLimit;
239        globalThis.staticStack = new Error('static').stack.includes('ext:limit/main.js:3:');
240    "#;
241    let probe = "[staticLimit, staticStack, JSON.stringify(Object.getOwnPropertyDescriptor(Error, 'stackTraceLimit'))]";
242    let cold: (u32, bool, String) = Runtime::new(options(observe))
243        .unwrap()
244        .execute_script("probe.js", probe)
245        .unwrap();
246    assert_eq!(
247        cold,
248        (
249            10,
250            true,
251            r#"{"value":10,"writable":true,"enumerable":true,"configurable":true}"#.into()
252        )
253    );
254    let snapshot = Runtime::create_snapshot(vec![options(observe)]).unwrap();
255    let state: (u32, bool, String) = Runtime::from_snapshot(options(observe), &snapshot)
256        .unwrap()
257        .execute_script("probe.js", probe)
258        .unwrap();
259    assert_eq!(state, cold);
260    // Restored contexts get V8's live property, so a changed static limit cannot persist.
261    assert!(matches!(
262        Runtime::create_snapshot(vec![options("Error.stackTraceLimit = 3;")]),
263        Err(Error::Snapshot(SnapshotError::ActiveState(
264            "Error.stackTraceLimit"
265        )))
266    ));
267}
268
269#[test]
270fn layout_fingerprints_frame_every_field() {
271    if support::role().is_none() {
272        return support::roles("layout_fingerprints_frame_every_field", &["run"]);
273    }
274    let options = |ops: [&'static str; 2]| RuntimeOptions {
275        extensions: vec![Extension {
276            name: "ext:frame",
277            dependencies: &[],
278            ops: ops
279                .into_iter()
280                .map(|name| Op::new(name, |(): ()| Ok::<_, io::Error>(())))
281                .collect(),
282            modules: &[("ext:frame/main.js", "")],
283            entry_point: Some("ext:frame/main.js"),
284        }],
285        ..Default::default()
286    };
287    let snapshot = Runtime::create_snapshot(vec![options(["ab", "c"])]).unwrap();
288    assert!(matches!(
289        Runtime::from_snapshot(options(["a", "bc"]), &snapshot),
290        Err(Error::Snapshot(SnapshotError::IncompatibleLayout))
291    ));
292    Runtime::from_snapshot(options(["c", "ab"]), &snapshot).unwrap();
293    assert!(matches!(
294        Runtime::create_snapshot(vec![options(["ab", "c"]), options(["c", "ab"])]),
295        Err(Error::Snapshot(SnapshotError::DuplicateLayout))
296    ));
297}
298
299#[test]
300fn snapshot_containers_are_validated() {
301    let cases: [&'static [u8]; 4] = [
302        b"",
303        b"not an avril snapshot",
304        b"AVRILSNP\x02\0\0\0\0\0\0\0",
305        b"AVRILSNP\x01\0\0\0\xff\0\0\0{}",
306    ];
307    for bytes in cases {
308        // SAFETY: Only the avril container is read; invalid containers stop there.
309        let result = unsafe { Snapshot::from_bytes(bytes) };
310        assert!(matches!(result, Err(SnapshotError::InvalidArtifact(_))));
311    }
312}
313
314/// A module loader whose destructor panics, as a faulty native resource might.
315struct PanickingLoader;
316
317impl Drop for PanickingLoader {
318    fn drop(&mut self) {
319        panic!("native state destructor panicked");
320    }
321}
322
323impl avril_core::ModuleLoader for PanickingLoader {
324    fn resolve(&self, specifier: &str, _: &str) -> Result<String, Error> {
325        Ok(specifier.into())
326    }
327
328    fn load(&self, name: &str) -> Result<avril_core::ModuleSource, Error> {
329        Err(Error::Module(format!("Missing {name}")))
330    }
331}
332
333/// Core-owned native state (the module map) holds the only loader reference.
334fn panicking_state(module: &'static str) -> RuntimeOptions {
335    RuntimeOptions {
336        extensions: vec![Extension {
337            name: "ext:panic",
338            dependencies: &[],
339            ops: vec![],
340            modules: Box::leak(Box::new([("ext:panic/main.js", module)])),
341            entry_point: Some("ext:panic/main.js"),
342        }],
343        module_loader: Some(std::rc::Rc::new(PanickingLoader)),
344    }
345}
346
347#[test]
348fn isolates_leaked_by_panicking_disposal_keep_their_heap() {
349    let Some(role) = support::role() else {
350        return support::roles(
351            "isolates_leaked_by_panicking_disposal_keep_their_heap",
352            &["out-of-order", "runtime-state", "creator-state"],
353        );
354    };
355    let snapshot = Runtime::create_snapshot(vec![layout("a", 0)]).unwrap();
356    match role.as_str() {
357        "out-of-order" => {
358            let first = Runtime::new(layout("cold", 1)).unwrap();
359            let mut second = Runtime::new(layout("cold", 2)).unwrap();
360            // rusty_v8 refuses to dispose an isolate that is not the thread's current one.
361            let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(first)));
362            assert!(dropped.is_err());
363            assert_eq!(state(&mut second), ("cold".into(), 2, 1));
364        }
365        "runtime-state" => {
366            let runtime = Runtime::new(panicking_state("")).unwrap();
367            let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(runtime)));
368            assert!(dropped.is_err());
369        }
370        _ => {
371            let mut earlier = Runtime::new(layout("cold", 1)).unwrap();
372            let failed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
373                Runtime::create_snapshot(vec![panicking_state("throw Error('static failure');")])
374            }));
375            assert!(failed.is_err());
376            // The abandoned creator still left the thread's isolate stack.
377            assert_eq!(state(&mut earlier), ("cold".into(), 1, 1));
378        }
379    }
380    // The undisposed isolate still holds V8's built-in read-only heap.
381    assert!(rejected(Runtime::from_snapshot(layout("a", 3), &snapshot)));
382    let mut later = Runtime::new(layout("cold", 4)).unwrap();
383    assert_eq!(state(&mut later), ("cold".into(), 4, 1));
384}