Skip to main content

capsule_runtime/
precompile.rs

1//! Compiling WASM artifacts ahead of their first launch.
2//!
3//! `mur install` and `mur precompile` hand the payloads one command installs or names to
4//! [`Precompiler::precompile_all`], which compiles each as [`Precompiler::precompile`] does. Each
5//! compiled form is stored through the same [`CompiledForms`] handle and the same
6//! [`stage_root_component`] that `stage_session` uses, on an engine from the same
7//! [`build_engine`]. A form written here therefore has the name, mode, sidecar and stamp a launch
8//! would have given it, and launch cannot tell the two apart. This module reads and writes no file
9//! itself.
10//!
11//! The key is the sha256 of the payload bytes in hand, recomputed here, so a form is only ever
12//! compiled from the root wasm of the payload it is named after, as the key contract in
13//! [`compiled_forms`](crate::compiled_forms) requires.
14//!
15//! [`build_engine`]: crate::runtime::build_engine
16//! [`stage_root_component`]: crate::runtime::stage_root_component
17
18use std::num::NonZeroUsize;
19use std::path::Path;
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22use murmur_artifact::RuntimeType;
23use wasmtime::Engine;
24
25use crate::compiled_forms::CompiledForms;
26
27/// An engine and compiled-forms handle for storing the forms of installed artifacts.
28pub struct Precompiler {
29    engine: Engine,
30    forms: CompiledForms,
31}
32
33/// What [`Precompiler::precompile`] did with one payload. Serialised in `snake_case`, the outcome
34/// `mur precompile --json` reports.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Precompiled {
38    /// The payload's root wasm was compiled and its form stored, or storing it was attempted:
39    /// a form that could not be written is compiled again on first launch.
40    Compiled,
41    /// A form for the payload under the current engine key was already stored and loads.
42    AlreadyStored,
43    /// The payload is not a WASM artifact staged under its own sha256: a skill or a capsule. No
44    /// file was touched.
45    NotWasm,
46    /// The payload's manifest does not parse, or declares a native tool, which
47    /// [`murmur_artifact::refuse_native_tool`] refuses; or its root wasm is missing or does not
48    /// compile. Nothing was stored; a launch staging it fails or compiles exactly as it would have.
49    Failed,
50}
51
52impl Precompiler {
53    /// A precompiler whose forms are withheld, as a launch withholds them, when the murmur home or
54    /// `~/.murmur/compiled` is reachable from `capsule_writable`, the directory the consuming
55    /// capsule's writable root is created in. `None` for a writer that stages no capsule, such as
56    /// an install into the global store. `None` when the engine cannot be built.
57    pub fn new(capsule_writable: Option<&Path>) -> Option<Self> {
58        let engine = crate::runtime::build_engine().ok()?;
59        let forms = match capsule_writable {
60            Some(dir) => CompiledForms::new(&engine, dir),
61            None => CompiledForms::without_capsule(&engine),
62        };
63        Some(Self { engine, forms })
64    }
65
66    /// Stores the compiled form of the artifact `name@version` whose payload is `payload`, unless a
67    /// usable one is already stored. Never fails, panics or prints: whatever the outcome, a launch
68    /// that stages `payload` behaves as it would have without this call, only faster when it
69    /// returns [`Precompiled::Compiled`] or [`Precompiled::AlreadyStored`].
70    ///
71    /// Eligible payloads are those whose packed manifest resolves to wasm execution in any role but
72    /// `capsule`: a capsule's own component is staged under the sha256 of the component, not of
73    /// its payload. The role itself is left open because staging takes it from the consuming
74    /// manifest's entry, not from the packed `runtime:`.
75    pub fn precompile(&self, name: &str, version: &str, payload: &[u8]) -> Precompiled {
76        ineligible(payload).unwrap_or_else(|| self.compile_eligible(name, version, payload))
77    }
78
79    /// [`precompile`](Self::precompile) for every item, one outcome per item in input order,
80    /// whatever order the compiles finish in.
81    ///
82    /// Up to [`std::thread::available_parallelism`] eligible payloads compile at once, each on a
83    /// thread of its own outside rayon's global pool. That pool is where wasmtime compiles one
84    /// component's functions in parallel, so a compile's serial phases never hold a pool thread
85    /// another compile's functions are waiting for. Payloads that are not eligible are settled on
86    /// the calling thread first, so a batch with no WASM payload starts no thread.
87    pub fn precompile_all(&self, items: &[PrecompileItem<'_>]) -> Vec<Precompiled> {
88        let settled: Vec<Option<Precompiled>> =
89            items.iter().map(|item| ineligible(item.payload)).collect();
90        let eligible: Vec<&PrecompileItem<'_>> = items
91            .iter()
92            .zip(&settled)
93            .filter(|(_, settled)| settled.is_none())
94            .map(|(item, _)| item)
95            .collect();
96        let mut compiled = run_bounded(&eligible, compile_bound(), |item| {
97            self.compile_eligible(item.name, item.version, item.payload)
98        })
99        .into_iter();
100        settled
101            .into_iter()
102            .map(|settled| {
103                settled.unwrap_or_else(|| {
104                    compiled
105                        .next()
106                        .expect("one compile outcome per eligible item")
107                })
108            })
109            .collect()
110    }
111
112    /// The compile half of [`precompile`](Self::precompile), for a payload [`ineligible`] passed.
113    fn compile_eligible(&self, name: &str, version: &str, payload: &[u8]) -> Precompiled {
114        let key = murmur_artifact::sha256_hex(payload);
115        if self.forms.load(&self.engine, &key).is_some() {
116            return Precompiled::AlreadyStored;
117        }
118        match crate::runtime::stage_root_component(
119            &self.engine,
120            &self.forms,
121            name,
122            version,
123            payload,
124            &key,
125        ) {
126            Ok(_) => Precompiled::Compiled,
127            Err(_) => Precompiled::Failed,
128        }
129    }
130}
131
132/// One payload of a [`Precompiler::precompile_all`] batch: the artifact `name@version` whose
133/// payload bytes are `payload`.
134#[derive(Debug, Clone, Copy)]
135pub struct PrecompileItem<'a> {
136    pub name: &'a str,
137    pub version: &'a str,
138    pub payload: &'a [u8],
139}
140
141/// The outcome of a payload that is not compiled: [`Precompiled::Failed`] when its manifest does
142/// not parse or declares a native tool, [`Precompiled::NotWasm`] when it is not staged under its
143/// own sha256. `None` for a payload [`Precompiler::compile_eligible`] takes.
144fn ineligible(payload: &[u8]) -> Option<Precompiled> {
145    let Ok(manifest) = murmur_artifact::load_manifest_from_artifact_bytes(payload) else {
146        return Some(Precompiled::Failed);
147    };
148    if manifest.registry_runtime() != RuntimeType::Wasm || manifest.runtime == "capsule" {
149        return Some(Precompiled::NotWasm);
150    }
151    None
152}
153
154/// How many payloads [`Precompiler::precompile_all`] compiles at once: the CPUs this process may
155/// run on, or 1 when that cannot be read.
156fn compile_bound() -> usize {
157    std::thread::available_parallelism().map_or(1, NonZeroUsize::get)
158}
159
160/// `f` of every item, in input order, with at most `bound` items inside `f` at once. Each of
161/// `min(bound, items.len())` scoped threads takes the next unstarted item until none is left. An
162/// empty input starts no thread, and one item, or a bound of 1, runs on the calling thread. A
163/// panic in `f` is resumed on the calling thread.
164fn run_bounded<T: Sync, R: Send>(items: &[T], bound: usize, f: impl Fn(&T) -> R + Sync) -> Vec<R> {
165    let workers = bound.min(items.len());
166    if workers <= 1 {
167        return items.iter().map(f).collect();
168    }
169    let next = AtomicUsize::new(0);
170    let mut results: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
171    std::thread::scope(|scope| {
172        let (f, next) = (&f, &next);
173        let handles: Vec<_> = (0..workers)
174            .map(|_| {
175                scope.spawn(move || {
176                    let mut done = Vec::new();
177                    loop {
178                        let index = next.fetch_add(1, Ordering::Relaxed);
179                        let Some(item) = items.get(index) else {
180                            break done;
181                        };
182                        done.push((index, f(item)));
183                    }
184                })
185            })
186            .collect();
187        for handle in handles {
188            let done: Vec<(usize, R)> = handle
189                .join()
190                .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
191            for (index, result) in done {
192                results[index] = Some(result);
193            }
194        }
195    });
196    results
197        .into_iter()
198        .map(|result| result.expect("every index is taken by exactly one worker"))
199        .collect()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::artifact::test_support::archive_with_files as payload;
206    use std::path::PathBuf;
207
208    fn fixture(relative: &str) -> Vec<u8> {
209        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
210            .join("../murmur-cli/tests/fixtures")
211            .join(relative);
212        std::fs::read(&path).unwrap_or_else(|err| panic!("{}: {err}", path.display()))
213    }
214
215    fn tool() -> Vec<u8> {
216        payload(&[
217            (
218                "murmur.yaml",
219                b"name: echo-tool\nversion: 0.1.0\nruntime: tool\n",
220            ),
221            ("tool.wasm", &fixture("run/components/echo-tool.wasm")),
222        ])
223    }
224
225    fn driver() -> Vec<u8> {
226        payload(&[
227            (
228                "murmur.yaml",
229                b"name: demo-driver\nversion: 0.1.0\nruntime: driver\n",
230            ),
231            (
232                "tool.wasm",
233                &fixture("drivers/anthropic/driver/murmur-driver-anthropic.wasm"),
234            ),
235        ])
236    }
237
238    fn hook() -> Vec<u8> {
239        payload(&[
240            (
241                "murmur.yaml",
242                b"name: demo-hook\nversion: 0.1.0\nruntime: hook\n",
243            ),
244            (
245                "hook.wasm",
246                &fixture("gateway-probe-hook/hook/gateway-probe-hook.wasm"),
247            ),
248        ])
249    }
250
251    fn compiled_dir() -> PathBuf {
252        crate::state_store::murmur_home_dir()
253            .unwrap()
254            .join("compiled")
255    }
256
257    /// The forms stored under `payload`'s sha256, under any engine key.
258    fn forms_of(payload: &[u8]) -> Vec<PathBuf> {
259        let prefix = format!("{}-", murmur_artifact::sha256_hex(payload));
260        std::fs::read_dir(compiled_dir())
261            .into_iter()
262            .flatten()
263            .filter_map(Result::ok)
264            .map(|entry| entry.path())
265            .filter(|path| {
266                path.extension().is_some_and(|ext| ext == "cwasm")
267                    && path
268                        .file_name()
269                        .and_then(|name| name.to_str())
270                        .is_some_and(|name| name.starts_with(&prefix))
271            })
272            .collect()
273    }
274
275    fn inode_and_mtime(path: &Path) -> (u64, std::time::SystemTime) {
276        use std::os::unix::fs::MetadataExt;
277        let metadata = std::fs::symlink_metadata(path).unwrap();
278        (metadata.ino(), metadata.modified().unwrap())
279    }
280
281    #[test]
282    fn a_precompiler_is_shareable_across_threads() {
283        fn shareable<T: Send + Sync>() {}
284        shareable::<Precompiler>();
285    }
286
287    #[test]
288    fn a_wasm_tool_driver_and_hook_are_each_compiled_then_already_stored() {
289        let home = tempfile::tempdir().unwrap();
290        crate::murmur_home::run_with_home(
291            "precompile::tests::inner_a_wasm_tool_driver_and_hook_are_each_compiled_then_already_stored",
292            home.path(),
293        );
294    }
295
296    #[test]
297    #[ignore = "run by a_wasm_tool_driver_and_hook_are_each_compiled_then_already_stored"]
298    fn inner_a_wasm_tool_driver_and_hook_are_each_compiled_then_already_stored() {
299        if !crate::murmur_home::in_scratch_home() {
300            return;
301        }
302        let workdir = tempfile::tempdir().unwrap();
303        let precompiler = Precompiler::new(Some(workdir.path())).unwrap();
304
305        for (name, payload) in [
306            ("echo-tool", tool()),
307            ("demo-driver", driver()),
308            ("demo-hook", hook()),
309        ] {
310            assert_eq!(
311                precompiler.precompile(name, "0.1.0", &payload),
312                Precompiled::Compiled,
313                "{name}"
314            );
315            let forms = forms_of(&payload);
316            let [form] = &forms[..] else {
317                panic!("{name}: expected one form, found {forms:?}");
318            };
319            let before = inode_and_mtime(form);
320            assert_eq!(
321                precompiler.precompile(name, "0.1.0", &payload),
322                Precompiled::AlreadyStored,
323                "{name}"
324            );
325            assert_eq!(inode_and_mtime(form), before, "{name}: form was rewritten");
326        }
327    }
328
329    #[test]
330    fn a_skill_and_a_capsule_are_not_wasm_and_a_native_tool_fails() {
331        let home = tempfile::tempdir().unwrap();
332        crate::murmur_home::run_with_home(
333            "precompile::tests::inner_a_skill_and_a_capsule_are_not_wasm_and_a_native_tool_fails",
334            home.path(),
335        );
336    }
337
338    #[test]
339    #[ignore = "run by a_skill_and_a_capsule_are_not_wasm_and_a_native_tool_fails"]
340    fn inner_a_skill_and_a_capsule_are_not_wasm_and_a_native_tool_fails() {
341        if !crate::murmur_home::in_scratch_home() {
342            return;
343        }
344        let precompiler = Precompiler::new(None).unwrap();
345        let skill = payload(&[
346            (
347                "murmur.yaml",
348                b"name: demo-skill\nversion: 0.1.0\nruntime: skill\n",
349            ),
350            ("skill.md", b"# Demo\n"),
351        ]);
352        let capsule = payload(&[
353            (
354                "murmur.yaml",
355                b"name: demo-capsule\nversion: 0.1.0\nruntime: capsule\n",
356            ),
357            ("capsule.wasm", &fixture("run/components/echo-tool.wasm")),
358        ]);
359
360        for (name, payload) in [("demo-skill", skill), ("demo-capsule", capsule)] {
361            assert_eq!(
362                precompiler.precompile(name, "0.1.0", &payload),
363                Precompiled::NotWasm,
364                "{name}"
365            );
366        }
367        assert_eq!(
368            precompiler.precompile("native-tool", "0.1.0", &native_tool()),
369            Precompiled::Failed
370        );
371        assert!(
372            !compiled_dir().exists(),
373            "{} was created",
374            compiled_dir().display()
375        );
376    }
377
378    #[test]
379    fn a_form_install_wrote_is_the_form_staging_loads() {
380        let home = tempfile::tempdir().unwrap();
381        crate::murmur_home::run_with_home(
382            "precompile::tests::inner_a_form_install_wrote_is_the_form_staging_loads",
383            home.path(),
384        );
385    }
386
387    #[test]
388    #[ignore = "run by a_form_install_wrote_is_the_form_staging_loads"]
389    fn inner_a_form_install_wrote_is_the_form_staging_loads() {
390        if !crate::murmur_home::in_scratch_home() {
391            return;
392        }
393        let payload = tool();
394        let key = murmur_artifact::sha256_hex(&payload);
395        assert_eq!(
396            Precompiler::new(None)
397                .unwrap()
398                .precompile("echo-tool", "0.1.0", &payload),
399            Precompiled::Compiled
400        );
401
402        let engine = crate::runtime::build_engine().unwrap();
403        let workdir = tempfile::tempdir().unwrap();
404        let staging = CompiledForms::new(&engine, workdir.path());
405        assert!(staging.load(&engine, &key).is_some());
406    }
407
408    #[test]
409    fn a_payload_whose_root_wasm_does_not_compile_is_failed_and_stores_nothing() {
410        let home = tempfile::tempdir().unwrap();
411        crate::murmur_home::run_with_home(
412            "precompile::tests::inner_a_payload_whose_root_wasm_does_not_compile_is_failed_and_stores_nothing",
413            home.path(),
414        );
415    }
416
417    #[test]
418    #[ignore = "run by a_payload_whose_root_wasm_does_not_compile_is_failed_and_stores_nothing"]
419    fn inner_a_payload_whose_root_wasm_does_not_compile_is_failed_and_stores_nothing() {
420        if !crate::murmur_home::in_scratch_home() {
421            return;
422        }
423        let precompiler = Precompiler::new(None).unwrap();
424        let not_wasm = payload(&[
425            (
426                "murmur.yaml",
427                b"name: broken-tool\nversion: 0.1.0\nruntime: tool\n",
428            ),
429            ("tool.wasm", b"not wasm"),
430        ]);
431        let no_manifest = payload(&[("tool.wasm", &fixture("run/components/echo-tool.wasm"))]);
432
433        for payload in [not_wasm, no_manifest, b"not a zip".to_vec()] {
434            assert_eq!(
435                precompiler.precompile("broken-tool", "0.1.0", &payload),
436                Precompiled::Failed
437            );
438            assert!(forms_of(&payload).is_empty());
439        }
440    }
441
442    /// A payload whose packed manifest declares `implementation: native`, beside a dummy binary.
443    fn native_tool() -> Vec<u8> {
444        payload(&[
445            (
446                "murmur.yaml",
447                b"name: native-tool\nversion: 0.1.0\nruntime: tool\nimplementation: native\n",
448            ),
449            ("bin/native-tool", b"#!/bin/sh\necho hi\n"),
450        ])
451    }
452
453    /// Counts the closures inside [`run_bounded`] at once, and the most there ever were.
454    #[derive(Default)]
455    struct InFlight {
456        now: AtomicUsize,
457        most: AtomicUsize,
458    }
459
460    impl InFlight {
461        fn enter(&self) {
462            let now = self.now.fetch_add(1, Ordering::SeqCst) + 1;
463            self.most.fetch_max(now, Ordering::SeqCst);
464        }
465
466        fn leave(&self) {
467            self.now.fetch_sub(1, Ordering::SeqCst);
468        }
469
470        fn most(&self) -> usize {
471            self.most.load(Ordering::SeqCst)
472        }
473    }
474
475    #[test]
476    fn bounded_results_come_back_in_input_order_when_the_first_item_is_slowest() {
477        let items: Vec<u64> = (0..6).collect();
478        let results = run_bounded(&items, 4, |&item| {
479            if item == 0 {
480                std::thread::sleep(std::time::Duration::from_millis(200));
481            }
482            item * 10
483        });
484        assert_eq!(results, vec![0, 10, 20, 30, 40, 50]);
485    }
486
487    #[test]
488    fn bounded_never_runs_more_items_at_once_than_its_bound() {
489        let items: Vec<usize> = (0..24).collect();
490        for bound in [2, 3, 5] {
491            let in_flight = InFlight::default();
492            let results = run_bounded(&items, bound, |&item| {
493                in_flight.enter();
494                std::thread::sleep(std::time::Duration::from_millis(10));
495                in_flight.leave();
496                item
497            });
498            assert_eq!(results, items);
499            assert!(
500                (1..=bound).contains(&in_flight.most()),
501                "bound {bound}: {} items ran at once",
502                in_flight.most()
503            );
504        }
505    }
506
507    #[test]
508    fn bounded_runs_two_items_at_once_when_its_bound_allows() {
509        // A two-party barrier that gives up instead of hanging: a serial runner leaves the first
510        // item waiting for a second that never arrives.
511        let arrived = std::sync::Mutex::new(0usize);
512        let both_in = std::sync::Condvar::new();
513        let results = run_bounded(&[(), ()], 2, |_| {
514            let mut count = arrived.lock().unwrap();
515            *count += 1;
516            both_in.notify_all();
517            let (count, _) = both_in
518                .wait_timeout_while(count, std::time::Duration::from_secs(30), |count| {
519                    *count < 2
520                })
521                .unwrap();
522            *count >= 2
523        });
524        assert_eq!(results, vec![true, true], "the two items never overlapped");
525    }
526
527    #[test]
528    fn a_bound_of_one_runs_every_item_in_turn_on_the_calling_thread() {
529        let caller = std::thread::current().id();
530        let in_flight = InFlight::default();
531        let started = std::sync::Mutex::new(Vec::new());
532        let items: Vec<usize> = (0..5).collect();
533        let results = run_bounded(&items, 1, |&item| {
534            in_flight.enter();
535            started.lock().unwrap().push(item);
536            let on_caller = std::thread::current().id() == caller;
537            in_flight.leave();
538            on_caller
539        });
540        assert_eq!(results, vec![true; 5]);
541        assert_eq!(in_flight.most(), 1);
542        assert_eq!(*started.lock().unwrap(), items);
543    }
544
545    #[test]
546    fn bounded_returns_nothing_for_no_items() {
547        let results = run_bounded(&[] as &[u8], 4, |_| -> u8 { panic!("called for no items") });
548        assert!(results.is_empty());
549    }
550
551    #[test]
552    fn a_batch_reports_each_payload_in_order_and_stores_each_wasm_form_once() {
553        let home = tempfile::tempdir().unwrap();
554        crate::murmur_home::run_with_home(
555            "precompile::tests::inner_a_batch_reports_each_payload_in_order_and_stores_each_wasm_form_once",
556            home.path(),
557        );
558    }
559
560    #[test]
561    #[ignore = "run by a_batch_reports_each_payload_in_order_and_stores_each_wasm_form_once"]
562    fn inner_a_batch_reports_each_payload_in_order_and_stores_each_wasm_form_once() {
563        if !crate::murmur_home::in_scratch_home() {
564            return;
565        }
566        let workdir = tempfile::tempdir().unwrap();
567        let precompiler = Precompiler::new(Some(workdir.path())).unwrap();
568        let payloads = [
569            ("demo-driver", driver()),
570            ("not-a-zip", b"not a zip".to_vec()),
571            ("echo-tool", tool()),
572            ("native-tool", native_tool()),
573            ("demo-hook", hook()),
574        ];
575        let items: Vec<PrecompileItem<'_>> = payloads
576            .iter()
577            .map(|(name, payload)| PrecompileItem {
578                name,
579                version: "0.1.0",
580                payload,
581            })
582            .collect();
583
584        assert_eq!(
585            precompiler.precompile_all(&items),
586            vec![
587                Precompiled::Compiled,
588                Precompiled::Failed,
589                Precompiled::Compiled,
590                Precompiled::Failed,
591                Precompiled::Compiled,
592            ]
593        );
594        let engine = crate::runtime::build_engine().unwrap();
595        let staging = CompiledForms::new(&engine, workdir.path());
596        let mut stamps = Vec::new();
597        for (name, payload) in &payloads {
598            let forms = forms_of(payload);
599            match *name {
600                "demo-driver" | "echo-tool" | "demo-hook" => {
601                    let [form] = &forms[..] else {
602                        panic!("{name}: expected one form, found {forms:?}");
603                    };
604                    let key = murmur_artifact::sha256_hex(payload);
605                    assert!(staging.load(&engine, &key).is_some(), "{name}");
606                    stamps.push((form.clone(), inode_and_mtime(form)));
607                }
608                _ => assert!(forms.is_empty(), "{name}: {forms:?}"),
609            }
610        }
611
612        assert_eq!(
613            precompiler.precompile_all(&items),
614            vec![
615                Precompiled::AlreadyStored,
616                Precompiled::Failed,
617                Precompiled::AlreadyStored,
618                Precompiled::Failed,
619                Precompiled::AlreadyStored,
620            ]
621        );
622        for (form, stamp) in stamps {
623            assert_eq!(
624                inode_and_mtime(&form),
625                stamp,
626                "{} was rewritten",
627                form.display()
628            );
629        }
630    }
631}