diff --git a/Justfile b/Justfile index ab63346a..7abc764d 100644 --- a/Justfile +++ b/Justfile @@ -124,6 +124,7 @@ build-rust-trace target=default-target features="": build-js-host-api target=default-target features="": check-npm (build-rust target features) cd src/js-host-api && npm install cd src/js-host-api && npx napi build --platform {{ if target == "release" { "--release" } else { "" } }} {{ if features == "" { "" } else { "--features=" + features } }} + cd src/js-host-api && npm run normalize:types build-all: (build "debug") (build "release") @echo "✅ All builds complete!" @@ -135,6 +136,8 @@ run-examples target=default-target features="": (build target) cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --example metrics cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example metrics cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features guest-call-stats,monitor-wall-clock,monitor-cpu-time'} else if features=="no-default-features" {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time" } else {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time," + features } }} --example execution_stats + cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example persistent_snapshot -- write target/persistent-rust-snapshot + cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example persistent_snapshot -- read target/persistent-rust-snapshot run-examples-tracing target=default-target features="": (build target) cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features function_call_metrics'} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example tracing fmt @@ -170,6 +173,7 @@ test-monitors target=default-target: cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics --skip custom_native_module --skip builtin_modules_work_with_custom --skip console_log_works_with_custom --skip custom_globals_and_host_clock test-js-host-api target=default-target features="": (build-js-host-api target features) + cargo test --manifest-path src/js-host-api/Cargo.toml --profile={{ if target == "debug" {"dev"} else { target } }} cd src/js-host-api && npm test # Test custom native modules: @@ -247,6 +251,9 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t @echo "" cd src/js-host-api && node examples/user-modules.js @echo "" + cd src/js-host-api && node examples/persistent-snapshot.js write ../../target/persistent-node-snapshot + cd src/js-host-api && node examples/persistent-snapshot.js read ../../target/persistent-node-snapshot + @echo "" @echo "✅ All examples completed successfully!" test-all target=default-target features="": (test target features) (test-monitors target) (test-js-host-api target features) diff --git a/README.md b/README.md index 80d9ba67..9db95226 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Provides a capability to run JavaScript inside of Hyperlight using quickjs as th - [Custom guest runtimes](docs/extending-runtime.md) - Extend with native modules and build and embed a custom guest using cargo-hyperlight - [Execution Monitors](docs/execution-monitors.md) - Timeout and resource limit enforcement for handler execution +- [Persistent snapshots](docs/persistent-snapshots.md) - Save and restore sandbox state across processes - [Observability](docs/observability.md) - Metrics and tracing - [Crashdumps](docs/create-and-analyse-guest-crashdumps.md) - Creating and analyzing guest crash dumps - [Debugging the guest runtime](docs/guest-runtime-debugging.md) - Debugging the guest runtime using GDB or LLDB diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md new file mode 100644 index 00000000..bcec6eb8 --- /dev/null +++ b/docs/persistent-snapshots.md @@ -0,0 +1,318 @@ +# Persistent snapshots + +hyperlight-js sandboxes can be captured to hyperlight's OCI image-layout +snapshot format and restored after the original sandbox or process has exited. +Snapshots are immutable and may be addressed by a human-readable tag or their +`sha256:` manifest digest. + +Two lifecycle states are supported: + +- `JSSandbox` snapshots contain the initialized JavaScript runtime together with + handler and module definitions added to `JSSandbox` but not yet registered in + the guest. +- `LoadedJSSandbox` snapshots additionally contain evaluated guest handlers, + closures, and current mutable guest state, including module-scoped state. + +An OCI image layout is one directory on disk. The same layout can contain any +number of independently tagged `JSSandbox` and `LoadedJSSandbox` snapshots; +each tag or manifest digest identifies one snapshot. + +In-memory `restore()` remains the fastest way to roll back an existing sandbox. +`Snapshot::save()` and `Snapshot::load()` provide a durable, cross-process +workflow. + +## Rust + +```rust +use hyperlight_js::{LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot}; + +fn main() -> hyperlight_js::Result<()> { + let mut sandbox = SandboxBuilder::new().build()?.load_runtime()?; + sandbox.add_handler( + "handler", + Script::from_content("function handler(event) { return event; }"), + )?; + let mut loaded = sandbox.get_loaded_sandbox()?; + + let snapshot = loaded.snapshot()?; + let digest = snapshot.save("./snapshots", &OciTag::new("application-ready")?)?; + + // Later, potentially in another process. + // The tag can be used instead of the digest. + let snapshot = Snapshot::::checked_load("./snapshots", digest)?; + let _loaded = SandboxBuilder::from_snapshot(snapshot).build()?; + Ok(()) +} +``` + +`Snapshot` names the sandbox type the snapshot captured. Loading fails if the +saved snapshot holds the other type. `build()` returns `T`. + +## Node.js + +```javascript +const { SandboxBuilder, Snapshot } = require('@hyperlight-dev/js-host-api'); + +const snapshot = await loaded.snapshot(); +const digest = await snapshot.save('./snapshots', 'application-ready'); + +// Later, potentially in another process. +// 'application-ready' can be used instead of digest to load by tag. +const persisted = await Snapshot.checkedLoad('./snapshots', digest); +const restored = await SandboxBuilder.fromSnapshot(persisted).build(); +``` + +`build()` returns a `JSSandbox` or `LoadedJSSandbox`, matching the saved +snapshot. TypeScript types the result as `JSSandbox | LoadedJSSandbox`. Narrow it +with `instanceof`. + +Configure process-local host resources on the builder before calling `build()`. +A snapshot's memory layout overrides heap, scratch, and transport settings. + +`Snapshot::checked_load()` in Rust and `Snapshot.checkedLoad()` in Node check +every OCI blob against its recorded digest. `Snapshot::load()` and +`Snapshot.load()` skip the check and reduce I/O. Use them only for trusted local +layouts. All four accept a tag or a manifest digest. + +The loaded blobs stay memory-mapped while the snapshot or any sandbox built from +it is alive. Overwriting, truncating, or deleting them during that time can +corrupt guest memory. + +## Modules and host resources + +hyperlight-js has four mechanisms that are all visible to JavaScript as modules +but have different ownership and restore behavior. + +| Category | How it is provided | Where code executes | Snapshot behavior | Restore action | +| --- | --- | --- | --- | --- | +| Host-function module | `HostFunctionModule`s containing Rust closures or Node callbacks | Host process through `CallHostJsFunction` | Records the supplied module and function names, not implementations | Supply fresh modules to `SandboxBuilder` | +| Embedded JavaScript module | `JSSandbox::add_module()` / `addModule()` | Guest QuickJS runtime | A `JSSandbox` snapshot stores staged source in hyperlight-js metadata. A `LoadedJSSandbox` snapshot captures the guest module-loader map and any evaluated module state. | None; hyperlight-js restores it automatically | +| External JavaScript module | Rust `SandboxBuilder::with_module_loader()` backed by `ResolveModule` and `LoadModule` | Guest QuickJS runtime after source is returned by the host | Loaded/evaluated state is captured, but source returned by the loader is not currently archived | Supply a compatible loader to the restoring builder when the underlying snapshot requires it | +| Native guest module | Rust compiled into `hyperlight-js-runtime` with `rquickjs` | Guest runtime | Native module code and state are already resident in the captured guest VM | None | + +### Reusable host-function definitions + +Write one function that builds the modules, and call it for each fresh or +restored builder. A builder consumes its modules, so each call must create fresh +module objects, callbacks, and state. Create mutable state inside the function +to isolate it per sandbox. Capturing an external `Arc`, static, or JavaScript +variable shares that state between sandboxes, which breaks the Sandbox isolation +model. + +Rust: + +```rust +use hyperlight_js::{HostFunctionModule, JSSandbox, OciTag, SandboxBuilder, Snapshot}; + +fn host_function_modules() -> Vec { + let mut database = HostFunctionModule::new("host:database"); + database.register("query", |id: u64| format!("record-{id}")); + vec![database] +} + +let fresh = SandboxBuilder::new() + .with_host_function_modules(host_function_modules()) + .build()? + .load_runtime()?; + +let snapshot = + Snapshot::::checked_load("./snapshots", OciTag::new("with-host-functions")?)?; +let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(host_function_modules()) + .build()?; +``` + +Node.js: + +```javascript +const { + HostFunctionModule, + SandboxBuilder, + Snapshot, +} = require('@hyperlight-dev/js-host-api'); + +function hostFunctionModules() { + const database = new HostFunctionModule('database'); + database.register('query', query); + return [database]; +} + +const freshProto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules()) + .build(); +const fresh = await freshProto.loadRuntime(); + +const snapshot = await Snapshot.checkedLoad('./snapshots', 'with-host-functions'); +const restored = await SandboxBuilder.fromSnapshot(snapshot) + .setHostFunctionModules(hostFunctionModules()) + .build(); +``` + +A snapshot records the host functions its guest state needs. `build()` fails +before running the guest if a recorded module or function is not supplied. + +The guest can call every supplied host function, including ones the snapshot +does not record. Handlers loaded after `unload()` can import them too. Supply +only the modules the guest should reach. + +A loaded snapshot records the host functions its evaluated handlers need. Saving +it again after a restore keeps that record, even when the restoring builder +supplied more functions. A `JSSandbox` snapshot records every function supplied +to its builder, because its staged handlers are not compiled yet. + +hyperlight validates only the typed `CallHostJsFunction` bridge. hyperlight-js +therefore validates individual logical module and function names itself. It +cannot verify argument types, return types, or behavior. +[hyperlight-js#339][hyperlight-js-host-function-signatures] tracks stable +semantic signatures. + +### Embedded JavaScript modules + +Modules added with `add_module()` / `addModule()` are guest application state. + +For a `JSSandbox` snapshot, handlers and modules have not yet been transferred +to the guest. hyperlight-js therefore stores their source in snapshot metadata +and reconstructs the staged definitions automatically during restoration. +hyperlight limits that metadata to 1 MiB, so `save()` fails when the staged +sources exceed it. Load large sources into a `LoadedJSSandbox` before saving. + +Creating a `LoadedJSSandbox` transfers every embedded module source into the +guest's module-loader map before handlers are registered. A loaded snapshot +therefore captures every registered source in guest memory, including modules +that have not yet been imported or evaluated. It also captures evaluated module +instances and mutable module-scoped state. Callers do not add embedded modules +again after restoring either lifecycle state. + +### External JavaScript loaders + +The external loader is lazy. `ResolveModule` and `LoadModule` run only when +QuickJS traverses an import that earlier loaders did not satisfy. Modules never +imported before the snapshot are not present in guest memory. + +```rust +let snapshot = + Snapshot::::checked_load("./snapshots", OciTag::new("with-module-loader")?)?; +let sandbox = SandboxBuilder::from_snapshot(snapshot) + .with_module_loader(file_system) + .build()?; +``` + +hyperlight records `ResolveModule` and `LoadModule`, including their exact type +signatures, in its snapshot. Missing or mismatched replacements produce +`SnapshotHostFunctionMismatch` before guest execution. hyperlight-js translates +that mismatch into a module-loader-specific error. Matching signatures do not +prove that a replacement loader resolves the same paths or returns the same +source. + +hyperlight-js does not currently archive source returned by an arbitrary module +loader. A future capture API must distinguish sources observed through +`LoadModule` from a complete, explicitly supplied archive: observed sources do +not cover dynamic imports that have not executed yet. + +### Native guest modules + +Native modules execute inside the guest and are already resident in guest VM +state when a snapshot is taken. They are restored with that VM state and do not +require host-side registration. The `JSRUNTIME` embedded in the current host +binary is used only when constructing a fresh sandbox, not when restoring one. +CPU architecture and hyperlight snapshot-format compatibility still apply. + +### Module-loader requirements + +hyperlight checks `ResolveModule` and `LoadModule` during `build()`. It does not +yet expose a snapshot's host-function requirements for inspection. +[hyperlight#1870][hyperlight-snapshot-host-functions] tracks that. Until then, +hyperlight-js cannot tell before building whether a snapshot needs a module +loader. Supply one when the application's contract requires it, or handle the +build error. + +`HostPrint` is handled separately. hyperlight provides a default stdout printer +when no custom handler is supplied. Reinstall a custom print handler only when +the same output routing is required. + +## Compatibility and trust + +- Snapshots are tied to their CPU architecture, CPU vendor, hypervisor, and + hyperlight snapshot format. For example, a KVM snapshot taken on an Intel host + does not load on AMD or under MSHV. +- hyperlight-js metadata is schema-versioned and records the producing package + version for diagnostics. It also records the guest runtime ABI version. + Unsupported metadata schema or guest ABI versions are rejected before starting + guest execution. +- Digest verification detects accidental or malicious mutation, but does not + authenticate who produced a snapshot. Authenticate snapshot artifacts through + the distribution mechanism used by your application. + +## Files in a loaded snapshot + +A persisted `LoadedJSSandbox` consists of two hyperlight snapshots in the same +OCI layout: + +- The loaded application state, including registered handlers, modules, and + mutable guest state. +- A raw runtime-ready rollback state captured before user handlers and modules + were registered. + +The rollback state is not an independently restorable hyperlight-js +`JSSandbox` snapshot: it has no hyperlight-js lifecycle metadata or +staged handler and module definitions. The loaded snapshot's hyperlight-js +metadata stores its digest solely so `unload()` can discard evaluated handlers, +modules, and mutable guest state and return a clean `JSSandbox` after restore. + +The digest is stored only in hyperlight-js metadata, not as an OCI descriptor. +Standard OCI tools therefore do not discover or copy the baseline +automatically. [hyperlight#1865][hyperlight-snapshot-bundles] tracks native OCI +bundle support for this relationship. + +### Moving loaded snapshots with ORAS + +Until [hyperlight supports OCI snapshot bundles][hyperlight-snapshot-bundles], a +persisted `LoadedJSSandbox` uses two OCI tags: + +- `application-ready.hljs-base`: the runtime-ready baseline +- `application-ready`: the loaded guest state + +`save()` rejects user tags ending in `.hljs-base`, so a user tag cannot replace a +baseline. + +Copy both tags into the same repository or OCI layout. Push the baseline first +so the public application tag is never published without its dependency: + +```console +oras cp --from-oci-layout \ + ./snapshots:application-ready.hljs-base \ + registry.example.com/snapshots:application-ready.hljs-base + +oras cp --from-oci-layout \ + ./snapshots:application-ready \ + registry.example.com/snapshots:application-ready +``` + +Restore both tags into the same local OCI layout before loading the snapshot: + +```console +oras cp --to-oci-layout \ + registry.example.com/snapshots:application-ready.hljs-base \ + ./snapshots:application-ready.hljs-base + +oras cp --to-oci-layout \ + registry.example.com/snapshots:application-ready \ + ./snapshots:application-ready +``` + +The `oras cp --recursive` option copies OCI referrers. It does not discover the +hyperlight-js baseline because the relationship is currently stored in +application metadata rather than an OCI descriptor. + +[hyperlight-snapshot-bundles]: https://github.com/hyperlight-dev/hyperlight/issues/1865 +[hyperlight-snapshot-host-functions]: https://github.com/hyperlight-dev/hyperlight/issues/1870 +[hyperlight-js-host-function-signatures]: https://github.com/hyperlight-dev/hyperlight-js/issues/339 + +## Examples and benchmarks + +The Rust `persistent_snapshot` example and Node +`examples/persistent-snapshot.js` both have separate `write` and `read` +commands, demonstrating restoration in a new process. + +Criterion benchmarks under `persistent_snapshots` measure save, verified load, +and construction of a new `LoadedJSSandbox` from disk. diff --git a/docs/release.md b/docs/release.md index 8285f81d..97ebbb41 100644 --- a/docs/release.md +++ b/docs/release.md @@ -57,6 +57,17 @@ Pushing a `vX.Y.Z` tag is the **only** manual trigger you need — you do **not* Both crates.io and established npm packages use trusted publishing (OIDC), so they do not need long-lived publishing tokens. A new npm package needs a short-lived `NPM_TOKEN` for its first release, as described below. Provenance attestations are generated for every npm package. +### Release-note labels + +GitHub generates release notes from merged pull-request titles and labels using +`.github/release.yml`. Commit messages, including conventional-commit +`BREAKING CHANGE` footers, are not used for categorization. + +Any pull request that breaks a released API must have the `breaking-change` +label so it appears under **Breaking Changes**. This is in addition to the +single required `kind/*` label enforced by `PRLabelChecker.yml`. Describe the +affected Rust and Node APIs and their migration in the pull-request body. + > **Note:** Only a `vX.Y.Z` **tag** push triggers a real release. Pushing to `main`, or running the workflow manually with **Run workflow**, performs a **dry run** — it builds and validates everything but publishes nothing. ### npm trusted publishing setup diff --git a/src/hyperlight-js-common/src/lib.rs b/src/hyperlight-js-common/src/lib.rs index c2a6c230..f695276f 100644 --- a/src/hyperlight-js-common/src/lib.rs +++ b/src/hyperlight-js-common/src/lib.rs @@ -77,6 +77,15 @@ pub const TAG_JSON_WITH_BINARIES: u8 = 0x02; /// Example: `{"__bin__": 0}` means "insert sidecar blob 0 here". pub const PLACEHOLDER_BIN: &str = "__bin__"; +/// Version of the guest functions the host calls, such as +/// `RegisterHostModules`, `register_module`, `register_handler`, and +/// `RunHandler`, and of the payload formats they exchange. +/// +/// Increment it whenever any of them changes incompatibly. Persisted snapshots +/// carry the guest runtime that produced them, and the host rejects snapshots +/// recorded with another version. +pub const GUEST_ABI_VERSION: u32 = 1; + // ── Error type ─────────────────────────────────────────────────────── /// Lightweight decoding error — `no_std`-compatible (no `anyhow`, no `std`). diff --git a/src/hyperlight-js/Cargo.toml b/src/hyperlight-js/Cargo.toml index 61516ca3..87a00833 100644 --- a/src/hyperlight-js/Cargo.toml +++ b/src/hyperlight-js/Cargo.toml @@ -23,7 +23,7 @@ hyperlight-js-runtime = { workspace = true } metrics = "0.24.6" oxc_resolver = "11.21.0" phf = { version = "0.14", features = ["macros"] } -serde = { version = "1.0" } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0" } tracing = "0.1.44" @@ -136,6 +136,11 @@ name = "user_modules" path = "examples/user_modules/main.rs" test = false +[[example]] +name = "persistent_snapshot" +path = "examples/persistent_snapshot/main.rs" +test = false + [[bench]] name = "benchmarks" harness = false diff --git a/src/hyperlight-js/benches/benchmarks.rs b/src/hyperlight-js/benches/benchmarks.rs index 23c306f5..b7dccc34 100644 --- a/src/hyperlight-js/benches/benchmarks.rs +++ b/src/hyperlight-js/benches/benchmarks.rs @@ -21,7 +21,7 @@ use std::time::{Duration, Instant}; use criterion::{criterion_group, criterion_main, Bencher, Criterion}; #[cfg(all(feature = "monitor-wall-clock", feature = "monitor-cpu-time"))] use hyperlight_js::{CpuTimeMonitor, WallClockMonitor}; -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{OciTag, SandboxBuilder, Script, Snapshot}; fn js_load_handler_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("js_get_loaded_sandbox"); @@ -344,6 +344,66 @@ fn full_lifecycle_benchmark(c: &mut Criterion) { group.finish(); } +fn persistent_snapshot_benchmark(c: &mut Criterion) { + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox + .add_handler( + "counter", + Script::from_content( + r#" + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + "#, + ), + ) + .unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let snapshot = loaded.snapshot().unwrap(); + let directory = tempfile::tempdir().unwrap(); + snapshot + .save(directory.path(), &OciTag::new("benchmark").unwrap()) + .unwrap(); + + let mut group = c.benchmark_group("persistent_snapshots"); + group.bench_function("save_loaded_snapshot", |b| { + b.iter(|| { + snapshot + .save(directory.path(), &OciTag::new("benchmark").unwrap()) + .unwrap() + }) + }); + group.bench_function("checked_load", |b| { + b.iter(|| { + Snapshot::::checked_load( + directory.path(), + OciTag::new("benchmark").unwrap(), + ) + .unwrap() + }) + }); + group.bench_function("restore_loaded_sandbox", |b| { + b.iter(|| { + let snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("benchmark").unwrap(), + ) + .unwrap(); + SandboxBuilder::from_snapshot(snapshot).build().unwrap() + }) + }); + group.finish(); +} + // ============================================================================= // Monitor overhead benchmark // ============================================================================= @@ -449,7 +509,7 @@ fn monitor_overhead_benchmark(c: &mut Criterion) { criterion_group! { name = benches; config = Criterion::default().measurement_time(Duration::from_secs(20)); - targets = js_load_handler_benchmark, handle_events_benchmark, full_lifecycle_benchmark + targets = js_load_handler_benchmark, handle_events_benchmark, full_lifecycle_benchmark, persistent_snapshot_benchmark } #[cfg(all(feature = "monitor-wall-clock", feature = "monitor-cpu-time"))] diff --git a/src/hyperlight-js/examples/persistent_snapshot/main.rs b/src/hyperlight-js/examples/persistent_snapshot/main.rs new file mode 100644 index 00000000..1390cc11 --- /dev/null +++ b/src/hyperlight-js/examples/persistent_snapshot/main.rs @@ -0,0 +1,84 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::path::PathBuf; + +use hyperlight_js::{ + HostFunctionModule, LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot, +}; + +fn host_function_modules() -> Vec { + let mut counter = HostFunctionModule::new("host:counter"); + counter.register("step", |value: i32| value + 1); + vec![counter] +} + +fn main() -> hyperlight_js::Result<()> { + let mut arguments = std::env::args().skip(1); + let command = arguments.next().unwrap_or_else(|| "write".to_owned()); + let path = arguments + .next() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target/persistent-js-snapshot")); + + match command.as_str() { + "write" => write_snapshot(path), + "read" => read_snapshot(path), + other => Err(hyperlight_js::new_error!( + "Unknown command '{other}'; expected 'write' or 'read'" + )), + } +} + +fn write_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(host_function_modules()) + .build()? + .load_runtime()?; + sandbox.add_handler( + "counter", + Script::from_content( + r#" + import * as counter from "host:counter"; + let count = 0; + function handler(event) { + count = counter.step(count); + event.count = count; + return event; + } + "#, + ), + )?; + let mut loaded = sandbox.get_loaded_sandbox()?; + let result = loaded.handle_event("counter", "{}".to_owned(), None)?; + println!("Before snapshot: {result}"); + + let snapshot = loaded.snapshot()?; + let digest = snapshot.save(path, &OciTag::new("application-ready")?)?; + println!("Saved application-ready as {digest}"); + Ok(()) +} + +fn read_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { + let snapshot = + Snapshot::::checked_load(path, OciTag::new("application-ready")?)?; + let mut loaded = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(host_function_modules()) + .build()?; + let result = loaded.handle_event("counter", "{}".to_owned(), None)?; + println!("After restore: {result}"); + Ok(()) +} diff --git a/src/hyperlight-js/src/lib.rs b/src/hyperlight-js/src/lib.rs index 04d9c957..af7c5bbb 100644 --- a/src/hyperlight-js/src/lib.rs +++ b/src/hyperlight-js/src/lib.rs @@ -28,8 +28,12 @@ pub mod sandbox; use hyperlight_host::func::HostFunction; // Re-export FnReturn for the NAPI bridge (used in register_js signature). +/// OCI tags, digests, and references used to save and load snapshots. +pub use hyperlight_host::sandbox::snapshot::{OciDigest, OciReference, OciTag}; #[doc(hidden)] pub use sandbox::host_fn::FnReturn; +/// A named group of host functions that guest JavaScript imports as a module. +pub use sandbox::host_fn::HostFunctionModule; /// Validate a module identifier (name or namespace). pub use sandbox::js_sandbox::validate_module_identifier; /// Validate that a namespace is not reserved. @@ -45,8 +49,12 @@ pub use sandbox::loaded_js_sandbox::LoadedJSSandbox; /// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. /// This is used to register new host functions prior to loading the JavaScript runtime. pub use sandbox::proto_js_sandbox::ProtoJSSandbox; -/// A builder for creating a new `JSSandbox` -pub use sandbox::sandbox_builder::SandboxBuilder; +/// Configures a new sandbox, or one restored from a [`Snapshot`]. +pub use sandbox::sandbox_builder::{NewSandbox, SandboxBuilder}; +#[doc(hidden)] +pub use sandbox::snapshot::AnySnapshot; +/// A persistent snapshot of a Hyperlight JavaScript sandbox. +pub use sandbox::snapshot::{Snapshot, SnapshotTarget}; /// Types for working with JS script. pub use script::Script; /// The function to pass to a new `JSSandbox` to tell it how to handle @@ -69,8 +77,6 @@ pub type ParameterValue = hyperlight_host::func::ParameterValue; pub type ReturnValue = hyperlight_host::func::ReturnValue; /// The type of the return value from a guest function call. pub type ReturnType = hyperlight_host::func::ReturnType; -/// A snapshot of sandbox state that can be used to restore it later. -pub use hyperlight_host::sandbox::snapshot::Snapshot; /// Configuration for sandbox resource limits and behavior. pub use hyperlight_host::sandbox::{SandboxConfiguration, SandboxStatus}; /// Module resolution and loading functionality. diff --git a/src/hyperlight-js/src/resolver.rs b/src/hyperlight-js/src/resolver.rs index 094ad985..75bf5580 100644 --- a/src/hyperlight-js/src/resolver.rs +++ b/src/hyperlight-js/src/resolver.rs @@ -172,9 +172,9 @@ impl FileSystem for FileSystemEmbedded { /// "strings.js" => "../tests/fixtures/strings.js", /// }; /// -/// let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); -/// let sandbox = proto_js_sandbox -/// .set_module_loader(fs) +/// let sandbox = SandboxBuilder::new() +/// .with_module_loader(fs) +/// .build() /// .unwrap() /// .load_runtime() /// .unwrap(); diff --git a/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index c766d9e3..69282208 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -22,6 +22,8 @@ use serde::ser::SerializeSeq; use serde::Serialize; use serde_json::Value as JsonValue; +use super::snapshot::HostFunctionManifest; + // Unlike hyperlight-host's Function, this Function trait uses `serde`'s Serialize and DeserializeOwned traits for input and output types. /// A trait representing a host function that can be called from the guest JavaScript code. @@ -51,25 +53,74 @@ where } } -type JsonFn = std::sync::Arc crate::Result + Send + Sync>; +type JsonFn = Box crate::Result + Send + Sync>; /// Re-export the unified return type from the common crate. pub use hyperlight_js_common::FnReturn; +/// Expose a logical host-function manifest to the guest module loader. +pub(crate) fn register_host_function_manifest( + sandbox: &mut hyperlight_host::Sandbox, + manifest: &HostFunctionManifest, +) -> crate::Result<()> { + let manifest_json = serde_json::to_string(manifest)?; + sandbox.call("RegisterHostModules", manifest_json) +} + +/// Return the registered host functions grouped by module. +pub(crate) fn host_function_manifest( + modules: &HashMap, +) -> HostFunctionManifest { + modules + .iter() + .map(|(module, functions)| { + let mut functions = functions + .function_names() + .map(str::to_owned) + .collect::>(); + functions.sort(); + (module.clone(), functions) + }) + .collect() +} + +/// Build the single Hyperlight callback that dispatches logical host functions. +pub(crate) fn host_function_dispatch( + modules: HashMap, +) -> impl Fn(String, String, Vec, Vec) -> crate::Result> + Send + Sync + 'static +{ + move |module_name, function_name, args_json, binaries| { + let args_json = String::from_utf8(args_json).map_err(|error| { + crate::new_error!("Invalid UTF-8 in host function arguments: {error}") + })?; + let module = modules + .get(&module_name) + .ok_or_else(|| crate::new_error!("Host module '{}' not found", module_name))?; + module + .call(&function_name, args_json, Some(binaries)) + .map_err(|error| { + crate::new_error!( + "Error calling host function '{}' in module '{}': {}", + function_name, + module_name, + error + ) + }) + } +} + /// The closure type for JS bridge host functions. /// /// Receives the parsed JSON arguments (with `{"__bin__": N}` placeholders /// still in place) and the decoded individual binary blobs. This avoids a /// redundant stringify→parse round-trip that would occur if we passed a /// pre-processed JSON string. -type BinaryFn = - std::sync::Arc>) -> crate::Result + Send + Sync>; +type BinaryFn = Box>) -> crate::Result + Send + Sync>; /// A registered host function — either typed (serde) or JS bridge. /// /// This enum allows a single `HashMap` to store both variants, eliminating /// the need for parallel maps and cross-removal bookkeeping. -#[derive(Clone)] enum HostFn { /// Typed: receives a JSON args string, deserializes via serde, /// returns a JSON result string. Does not support binary args. @@ -82,7 +133,7 @@ enum HostFn { fn type_erased( func: impl Function + Send + Sync + 'static, ) -> JsonFn { - std::sync::Arc::new(move |args: String| { + Box::new(move |args: String| { let args: Args = serde_json::from_str(&args)?; let output: Output = func.call(args); Ok(serde_json::to_string(&output)?) @@ -106,7 +157,7 @@ pub(crate) fn decode_binaries(data: Vec) -> crate::Result>> { } /// A module containing host functions that can be called from the guest JavaScript code. -#[derive(Default, Clone)] +#[derive(Default)] pub struct HostModule { functions: HashMap, } @@ -124,6 +175,10 @@ impl Serialize for HostModule { } impl HostModule { + pub(crate) fn function_names(&self) -> impl Iterator { + self.functions.keys().map(String::as_str) + } + /// Register a typed host function that can be called from the guest /// JavaScript code. /// @@ -164,7 +219,7 @@ impl HostModule { func: impl Fn(JsonValue, Vec>) -> crate::Result + Send + Sync + 'static, ) -> &mut Self { self.functions - .insert(name.into(), HostFn::JsBridge(std::sync::Arc::new(func))); + .insert(name.into(), HostFn::JsBridge(Box::new(func))); self } @@ -231,8 +286,87 @@ impl HostModule { } } +/// A named collection of host functions exposed to one JavaScript sandbox. +/// +/// Host-function modules execute in the host process through +/// `CallHostJsFunction`. They are distinct from native modules compiled into the +/// guest runtime and JavaScript source modules executed inside the guest. Define +/// a constructor function when multiple sandboxes need equivalent modules; each +/// sandbox must receive a newly constructed module and callback implementation. +pub struct HostFunctionModule { + name: String, + module: HostModule, +} + +impl HostFunctionModule { + /// Create an empty host-function module with the supplied import name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + module: HostModule::default(), + } + } + + /// Return the exact module name visible to guest JavaScript. + pub fn name(&self) -> &str { + &self.name + } + + /// Register a typed host function on this module. + pub fn register( + &mut self, + name: impl Into, + func: impl Function + Send + Sync + 'static, + ) -> &mut Self { + self.module.register(name, func); + self + } + + /// Register a JavaScript-bridge host function on this module. + #[doc(hidden)] + pub fn register_js( + &mut self, + name: impl Into, + func: impl Fn(JsonValue, Vec>) -> crate::Result + Send + Sync + 'static, + ) -> &mut Self { + self.module.register_js(name, func); + self + } +} + +impl std::fmt::Debug for HostFunctionModule { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HostFunctionModule") + .field("name", &self.name) + .field( + "functions", + &self.module.function_names().collect::>(), + ) + .finish() + } +} + +pub(crate) fn host_modules_by_name( + modules: Vec, +) -> crate::Result> { + let mut selected = HashMap::with_capacity(modules.len()); + for HostFunctionModule { name, module } in modules { + if selected.contains_key(&name) { + return Err(crate::new_error!( + "Duplicate host-function module '{}'", + name + )); + } + selected.insert(name, module); + } + Ok(selected) +} + #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use super::*; /// Hyperlight's cursor handles byte-sized segments and interspersed empties. @@ -256,6 +390,13 @@ mod tests { } } + fn stateful_modules() -> Vec { + let calls = AtomicUsize::new(0); + let mut module = HostFunctionModule::new("state"); + module.register("next", move || calls.fetch_add(1, Ordering::Relaxed) + 1); + vec![module] + } + #[test] fn call_typed_no_binaries() { let mut module = HostModule::default(); @@ -270,6 +411,67 @@ mod tests { assert_eq!(&result[1..], b"7"); } + #[test] + fn modules_select_all_functions() { + let mut module = HostFunctionModule::new("math"); + module + .register("add", |a: i32, b: i32| a + b) + .register("multiply", |a: i32, b: i32| a * b); + + let selected = host_modules_by_name(vec![module]).unwrap(); + + assert_eq!( + host_function_manifest(&selected).get("math"), + Some(&vec!["add".to_owned(), "multiply".to_owned()]) + ); + } + + #[test] + fn repeated_factory_calls_create_independent_callback_state() { + let first = host_modules_by_name(stateful_modules()) + .unwrap() + .remove("state") + .unwrap(); + let second = host_modules_by_name(stateful_modules()) + .unwrap() + .remove("state") + .unwrap(); + + let first_call = hyperlight_js_common::encode_json_return("1"); + let second_call = hyperlight_js_common::encode_json_return("2"); + + assert_eq!( + first.call("next", "null".to_owned(), None).unwrap(), + first_call + ); + assert_eq!( + first.call("next", "null".to_owned(), None).unwrap(), + second_call + ); + assert_eq!( + second.call("next", "null".to_owned(), None).unwrap(), + first_call + ); + } + + #[test] + fn duplicate_module_names_are_rejected() { + let modules = vec![ + HostFunctionModule::new("math"), + HostFunctionModule::new("math"), + ]; + + let result = host_modules_by_name(modules); + let error = match result { + Ok(_) => panic!("duplicate host-function modules were unexpectedly accepted"), + Err(error) => error, + }; + + assert!(error + .to_string() + .contains("Duplicate host-function module 'math'")); + } + #[test] fn call_typed_rejects_binary_args() { let mut module = HostModule::default(); diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 33042e0d..12f37718 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -17,11 +17,15 @@ use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; -use hyperlight_host::sandbox::snapshot::Snapshot; +use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; use hyperlight_host::{new_error, Result, Sandbox, SandboxStatus}; use tracing::{instrument, Level}; +use super::host_fn::register_host_function_manifest; use super::loaded_js_sandbox::LoadedJSSandbox; +use super::snapshot::{ + HostFunctionManifest, ScriptMetadata, Snapshot, SnapshotKind, SnapshotMetadata, +}; use crate::sandbox::metrics::SandboxMetricsGuard; use crate::Script; @@ -73,41 +77,99 @@ pub fn validate_namespace_not_reserved(namespace: &str) -> Result<()> { /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub struct JSSandbox { pub(super) inner: Sandbox, + // Mutable definitions staged for guest registration. Snapshot metadata is + // built from these maps only when a snapshot or LoadedJSSandbox is created. handlers: HashMap, /// User modules keyed by qualified name (e.g. `user:utils`). modules: HashMap, + // Host functions selected by SandboxBuilder. + host_functions: HostFunctionManifest, // Snapshot of state before any handlers are added. // This is used to restore state back to a neutral JSSandbox. - snapshot: Arc, + snapshot: Arc, // metric drop guard to manage sandbox metric _metric_guard: SandboxMetricsGuard, } impl JSSandbox { #[instrument(err(Debug), skip(inner), level=Level::INFO)] - pub(super) fn new(mut inner: Sandbox) -> Result { + pub(super) fn new(mut inner: Sandbox, host_functions: HostFunctionManifest) -> Result { let snapshot = inner.snapshot()?; Ok(Self { inner, handlers: HashMap::new(), modules: HashMap::new(), + host_functions, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) } /// Creates a new `JSSandbox` from a `Sandbox` and a `Snapshot` of state before any handlers were added. - pub(crate) fn from_loaded(mut loaded: Sandbox, snapshot: Arc) -> Result { + pub(crate) fn from_loaded( + mut loaded: Sandbox, + snapshot: Arc, + host_functions: HostFunctionManifest, + ) -> Result { loaded.restore(snapshot.clone())?; + register_host_function_manifest(&mut loaded, &host_functions)?; Ok(Self { inner: loaded, handlers: HashMap::new(), modules: HashMap::new(), + host_functions, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) } + pub(crate) fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + Ok(Self { + inner, + handlers: snapshot.metadata().handlers().collect(), + modules: snapshot.metadata().modules().collect(), + host_functions, + snapshot: snapshot.inner(), + _metric_guard: SandboxMetricsGuard::new(), + }) + } + + /// Capture the current runtime state and handlers and modules added to this + /// sandbox but not yet registered in the guest. + #[instrument(err(Debug), skip_all, level=Level::DEBUG)] + pub fn snapshot(&mut self) -> Result> { + let inner = self.inner.snapshot()?; + let metadata = SnapshotMetadata::new( + SnapshotKind::JsSandbox, + self.handlers + .iter() + .map(|(name, script)| (name.clone(), ScriptMetadata::from(script))), + self.modules + .iter() + .map(|(name, script)| (name.clone(), ScriptMetadata::from(script))), + self.host_functions.clone(), + ); + Ok(Snapshot::new(inner, None, Arc::new(metadata))) + } + + /// Restore the runtime state and handlers and modules that were added to the + /// snapshotted sandbox but not yet registered in the guest. + #[instrument(err(Debug), skip_all, level=Level::DEBUG)] + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { + snapshot + .metadata() + .validate_host_functions(&self.host_functions)?; + self.inner.restore(snapshot.inner())?; + self.handlers = snapshot.metadata().handlers().collect(); + self.modules = snapshot.metadata().modules().collect(); + self.snapshot = snapshot.inner(); + Ok(()) + } + /// Adds a new handler function to the sandboxes collection of handlers. This Handler will be /// available to the host to call once `get_loaded_sandbox` is called. #[instrument(err(Debug), skip(self, script), level=Level::DEBUG)] @@ -308,6 +370,17 @@ impl JSSandbox { return Err(new_error!("No handlers have been added to the sandbox")); } + let metadata = Arc::new(SnapshotMetadata::new( + SnapshotKind::LoadedJsSandbox, + std::iter::empty(), + std::iter::empty(), + self.host_functions.clone(), + )); + + // Publish the builder-selected host functions before compiling modules + // and handlers that may import them. + register_host_function_manifest(&mut self.inner, &self.host_functions)?; + // Register user modules first so that handlers can import them. // NOTE: HashMap iteration order is non-deterministic, but this is safe // because modules are lazily compiled by the UserModuleLoader when first @@ -328,7 +401,7 @@ impl JSSandbox { )?; } - LoadedJSSandbox::new(self.inner, self.snapshot) + LoadedJSSandbox::new(self.inner, self.snapshot, metadata, self.host_functions) } /// Generate a crash dump of the current state of the VM underlying this sandbox. /// diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index 452eed1d..ac86c4b0 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -17,7 +17,7 @@ use std::fmt::Debug; use std::sync::Arc; use hyperlight_host::hypervisor::InterruptHandle; -use hyperlight_host::sandbox::snapshot::Snapshot; +use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; use hyperlight_host::HyperlightError::{self, JsonConversionFailure}; use hyperlight_host::{Result, Sandbox, SandboxStatus}; use tokio::task::JoinHandle; @@ -29,6 +29,7 @@ use super::js_sandbox::JSSandbox; use super::metrics::{METRIC_SANDBOX_LOADS, METRIC_SANDBOX_UNLOADS}; use super::monitor::runtime::get_monitor_runtime; use super::monitor::MonitorSet; +use super::snapshot::{HostFunctionManifest, Snapshot, SnapshotMetadata}; #[cfg(feature = "function_call_metrics")] use crate::sandbox::metrics::EventHandlerMetricGuard; use crate::sandbox::metrics::SandboxMetricsGuard; @@ -38,7 +39,13 @@ pub struct LoadedJSSandbox { inner: Sandbox, // Snapshot of state before the sandbox was loaded and before any handlers were added. // This is used to restore state back to a JSSandbox. - snapshot: Arc, + snapshot: Arc, + // Metadata for the currently captured loaded guest state. Its host-function + // manifest records only the capabilities required by that state. + snapshot_metadata: Arc, + // Builder-selected callbacks available to host dispatch. These become the + // capability manifest if this sandbox is unloaded back to a JSSandbox. + available_host_functions: HostFunctionManifest, // metric drop guard to manage sandbox metric _metric_guard: SandboxMetricsGuard, // Stats from the most recent handle_event / handle_event_with_monitor call. @@ -63,17 +70,40 @@ impl Drop for MonitorTask { impl LoadedJSSandbox { #[instrument(err(Debug), skip_all, level=Level::INFO)] - pub(super) fn new(inner: Sandbox, snapshot: Arc) -> Result { + pub(super) fn new( + inner: Sandbox, + snapshot: Arc, + snapshot_metadata: Arc, + available_host_functions: HostFunctionManifest, + ) -> Result { metrics::counter!(METRIC_SANDBOX_LOADS).increment(1); Ok(LoadedJSSandbox { inner, snapshot, + snapshot_metadata, + available_host_functions, _metric_guard: SandboxMetricsGuard::new(), #[cfg(feature = "guest-call-stats")] last_call_stats: None, }) } + pub(crate) fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + available_host_functions: HostFunctionManifest, + ) -> Result { + let base = snapshot.base().ok_or_else(|| { + crate::new_error!("LoadedJSSandbox snapshot is missing its base snapshot") + })?; + Self::new( + inner, + base, + Arc::new(snapshot.metadata().clone()), + available_host_functions, + ) + } + /// Handles an event by calling the specified function with the event data. #[instrument(err(Debug), skip(self, event, gc), level=Level::INFO)] pub fn handle_event( @@ -175,22 +205,33 @@ impl LoadedJSSandbox { /// Unloads the Handlers from the sandbox and returns a `JSSandbox` with the JavaScript runtime loaded. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] pub fn unload(self) -> Result { - JSSandbox::from_loaded(self.inner, self.snapshot).inspect(|_| { - metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1); - }) + JSSandbox::from_loaded(self.inner, self.snapshot, self.available_host_functions) + .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) } /// Take a snapshot of the the current state of the sandbox. /// This can be used to restore the state of the sandbox later. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] - pub fn snapshot(&mut self) -> Result> { - self.inner.snapshot() + pub fn snapshot(&mut self) -> Result> { + let inner = self.inner.snapshot()?; + Ok(Snapshot::new( + inner, + Some(Arc::clone(&self.snapshot)), + Arc::clone(&self.snapshot_metadata), + )) } /// Restore the state of the sandbox to a previous snapshot. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] - pub fn restore(&mut self, snapshot: Arc) -> Result<()> { - self.inner.restore(snapshot)?; + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { + snapshot + .metadata() + .validate_host_functions(&self.available_host_functions)?; + self.inner.restore(snapshot.inner())?; + self.snapshot = snapshot.base().ok_or_else(|| { + crate::new_error!("LoadedJSSandbox snapshot is missing its base snapshot") + })?; + self.snapshot_metadata = Arc::new(snapshot.metadata().clone()); Ok(()) } diff --git a/src/hyperlight-js/src/sandbox/mod.rs b/src/hyperlight-js/src/sandbox/mod.rs index 2d6893c8..f4986808 100644 --- a/src/hyperlight-js/src/sandbox/mod.rs +++ b/src/hyperlight-js/src/sandbox/mod.rs @@ -26,6 +26,8 @@ pub(crate) mod js_sandbox; pub(crate) mod loaded_js_sandbox; /// Metric definitions for Sandbox module. pub(crate) mod metrics; +/// External JavaScript module-loader host resources. +pub(crate) mod module_loader; /// Execution monitoring and enforcement (timeouts, resource limits, etc.). pub mod monitor; /// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. @@ -33,6 +35,8 @@ pub mod monitor; pub(crate) mod proto_js_sandbox; /// A builder for creating a new `JSSandbox` pub(crate) mod sandbox_builder; +/// Persistent Hyperlight JavaScript snapshots. +pub(crate) mod snapshot; // This include! macro is replaced by the build.rs script. // The build.rs script reads the hyperlight-js-runtime binary into a static byte array named JSRUNTIME. include!(concat!(env!("OUT_DIR"), "/host_resource.rs")); diff --git a/src/hyperlight-js/src/sandbox/module_loader.rs b/src/hyperlight-js/src/sandbox/module_loader.rs new file mode 100644 index 00000000..ec7e765e --- /dev/null +++ b/src/hyperlight-js/src/sandbox/module_loader.rs @@ -0,0 +1,87 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::path::PathBuf; + +use hyperlight_host::{ + new_error, Result, SandboxBuilder as HyperlightSandboxBuilder, UninitializedSandbox, +}; +use oxc_resolver::{ResolveOptions, ResolverGeneric}; + +pub(super) const RESOLVE_MODULE_HOST_FUNCTION: &str = "ResolveModule"; +pub(super) const LOAD_MODULE_HOST_FUNCTION: &str = "LoadModule"; + +type ResolveModule = Box Result + Send + Sync>; +type LoadModule = Box Result> + Send + Sync>; + +pub(super) struct ExternalModuleLoader { + resolve: ResolveModule, + load: LoadModule, +} + +impl ExternalModuleLoader { + pub(super) fn new(file_system: Fs) -> Self { + let resolver = ResolverGeneric::new_with_file_system( + file_system.clone(), + ResolveOptions { + extensions: vec![".js".into(), ".mjs".into()], + condition_names: vec!["import".into(), "module".into()], + ..Default::default() + }, + ); + let resolve = Box::new(move |base: String, specifier: String| { + tracing::debug!(base = %base, specifier = %specifier, "Resolving module"); + let resolved = resolver.resolve(&base, &specifier).map_err(|error| { + new_error!( + "Failed to resolve module '{}' from '{}': {:?}", + specifier, + base, + error + ) + })?; + Ok(resolved.path().to_string_lossy().to_string()) + }); + let load = Box::new(move |path: String| { + tracing::debug!(path = %path, "Loading module"); + file_system + .read_to_string(&PathBuf::from(&path)) + .map(String::into_bytes) + .map_err(|error| new_error!("Failed to read module '{}': {}", path, error)) + }); + Self { resolve, load } + } + + pub(super) fn register(self, sandbox: &mut UninitializedSandbox) -> Result<()> { + sandbox.register( + RESOLVE_MODULE_HOST_FUNCTION, + move |base: String, specifier: String| (self.resolve)(base, specifier), + )?; + sandbox.register(LOAD_MODULE_HOST_FUNCTION, move |path: String| { + (self.load)(path) + })?; + Ok(()) + } + + pub(super) fn apply(self, builder: HyperlightSandboxBuilder) -> HyperlightSandboxBuilder { + let builder = builder.host_function( + RESOLVE_MODULE_HOST_FUNCTION, + move |base: String, specifier: String| (self.resolve)(base, specifier), + ); + builder.host_function(LOAD_MODULE_HOST_FUNCTION, move |path: String| { + (self.load)(path) + }) + } +} diff --git a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs index 6b175aac..683d9fc5 100644 --- a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs @@ -18,21 +18,32 @@ use std::fmt::Debug; use std::time::SystemTime; use anyhow::Context; -use hyperlight_host::func::Bytes; use hyperlight_host::sandbox::SandboxConfiguration; -use hyperlight_host::{new_error, GuestBinary, Result, UninitializedSandbox}; +use hyperlight_host::{GuestBinary, Result, UninitializedSandbox}; use serde::de::DeserializeOwned; use serde::Serialize; use tracing::{instrument, Level}; use super::js_sandbox::JSSandbox; use super::sandbox_builder::SandboxBuilder; -use crate::sandbox::host_fn::{Function, HostModule}; +use crate::sandbox::host_fn::{ + host_function_dispatch, host_function_manifest, Function, HostModule, +}; use crate::sandbox::metrics::SandboxMetricsGuard; +use crate::sandbox::module_loader::ExternalModuleLoader; use crate::HostPrintFn; -/// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. -/// This is used to register new host functions prior to loading the JavaScript run time. +pub(super) fn current_time_micros() -> hyperlight_host::Result { + Ok(SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .with_context(|| "Unable to get duration since epoch") + .map(|duration| duration.as_micros() as u64)?) +} + +/// A fresh Hyperlight sandbox before the JavaScript runtime is loaded. +/// +/// Register host resources, then call [`Self::load_runtime`] to create a +/// [`JSSandbox`]. Restore snapshots with [`crate::SandboxBuilder::from_snapshot`]. pub struct ProtoJSSandbox { inner: UninitializedSandbox, host_modules: HashMap, @@ -46,6 +57,8 @@ impl ProtoJSSandbox { guest_binary: GuestBinary, cfg: Option, host_print_writer: Option, + host_modules: HashMap, + module_loader: Option, ) -> Result { let mut usbox: UninitializedSandbox = UninitializedSandbox::new(guest_binary, cfg)?; @@ -54,19 +67,14 @@ impl ProtoJSSandbox { usbox.register_print(host_print_writer)?; } - // host function used by rquickjs for Date.now() - fn current_time_micros() -> hyperlight_host::Result { - Ok(SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .with_context(|| "Unable to get duration since epoch") - .map(|d| d.as_micros() as u64)?) - } - usbox.register("CurrentTimeMicros", current_time_micros)?; + if let Some(module_loader) = module_loader { + module_loader.register(&mut usbox)?; + } Ok(Self { inner: usbox, - host_modules: HashMap::new(), + host_modules, _metric_guard: SandboxMetricsGuard::new(), }) } @@ -74,103 +82,36 @@ impl ProtoJSSandbox { /// Install a custom file system for module resolution and loading. /// /// Enables JavaScript module imports using the provided ~FileSystem~ implementation. + #[deprecated( + note = "Use SandboxBuilder::with_module_loader, which also works when restoring snapshots" + )] #[instrument(err(Debug), skip_all, level=Level::INFO)] pub fn set_module_loader( mut self, file_system: Fs, ) -> Result { - use std::path::PathBuf; - - use oxc_resolver::{ResolveOptions, ResolverGeneric}; - - let resolver = ResolverGeneric::new_with_file_system( - file_system.clone(), - ResolveOptions { - extensions: vec![".js".into(), ".mjs".into()], - condition_names: vec!["import".into(), "module".into()], - ..Default::default() - }, - ); - - self.inner.register( - "ResolveModule", - move |base: String, specifier: String| -> hyperlight_host::Result { - tracing::debug!( - base = %base, - specifier = %specifier, - "Resolving module" - ); - - let resolved = resolver.resolve(&base, &specifier).map_err(|e| { - new_error!( - "Failed to resolve module '{}' from '{}': {:?}", - specifier, - base, - e - ) - })?; - - Ok(resolved.path().to_string_lossy().to_string()) - }, - )?; - - self.inner.register( - "LoadModule", - move |path: String| -> hyperlight_host::Result> { - tracing::debug!(path = %path, "Loading module"); - let path_buf = PathBuf::from(&path); - let source = file_system - .read_to_string(&path_buf) - .map_err(|e| new_error!("Failed to read module '{}': {}", path, e))?; - - Ok(source.into_bytes()) - }, - )?; - + ExternalModuleLoader::new(file_system).register(&mut self.inner)?; Ok(self) } /// Load the JavaScript runtime into the sandbox. #[instrument(err(Debug), skip(self), level=Level::INFO)] pub fn load_runtime(mut self) -> Result { + let host_functions = host_function_manifest(&self.host_modules); + let host_modules_json = serde_json::to_string(&self.host_modules)?; let host_modules = self.host_modules; - let host_modules_json = serde_json::to_string(&host_modules)?; - // Register the host function that the guest calls for all host // function invocations. Binary data (if any) is carried in a // chunked length-prefixed sidecar alongside the JSON args. - self.inner.register( - "CallHostJsFunction", - move |module_name: String, - func_name: String, - args_json: Vec, - binaries: Vec| - -> Result> { - let args_json = String::from_utf8(args_json).map_err(|error| { - new_error!("Invalid UTF-8 in host function arguments: {error}") - })?; - let module = host_modules - .get(&module_name) - .ok_or_else(|| new_error!("Host module '{}' not found", module_name))?; - module - .call(&func_name, args_json, Some(binaries)) - .map_err(|e| { - new_error!( - "Error calling host function '{}' in module '{}': {}", - func_name, - module_name, - e - ) - }) - }, - )?; + self.inner + .register("CallHostJsFunction", host_function_dispatch(host_modules))?; let mut multi_use_sandbox = self.inner.evolve()?; let _: () = multi_use_sandbox.call("RegisterHostModules", host_modules_json)?; - JSSandbox::new(multi_use_sandbox) + JSSandbox::new(multi_use_sandbox, host_functions) } /// Register a host module that can be called from the guest JavaScript code. @@ -193,6 +134,7 @@ impl ProtoJSSandbox { /// # Example /// /// ``` + /// # #![allow(deprecated)] /// use hyperlight_js::SandboxBuilder; /// /// // Create a proto sandbox and register a host function. @@ -205,6 +147,9 @@ impl ProtoJSSandbox { /// let js_sandbox = sbox.load_runtime()?; /// # Ok::<(), hyperlight_host::HyperlightError>(()) /// ``` + #[deprecated( + note = "Use SandboxBuilder::with_host_function_modules, which also works when restoring snapshots" + )] #[instrument(skip(self), level=Level::INFO)] pub fn host_module(&mut self, name: impl Into + Debug) -> &mut HostModule { self.host_modules.entry(name.into()).or_default() @@ -215,6 +160,9 @@ impl ProtoJSSandbox { /// /// Registering a function with the same `module` and `name` as an existing function /// overwrites the previous registration. + #[deprecated( + note = "Use SandboxBuilder::with_host_function_modules, which also works when restoring snapshots" + )] #[instrument(err(Debug), skip(self, func), level=Level::INFO)] pub fn register( &mut self, @@ -222,6 +170,7 @@ impl ProtoJSSandbox { name: impl Into + Debug, func: impl Function + Send + Sync + 'static, ) -> Result<()> { + #[allow(deprecated)] self.host_module(module).register(name, func); Ok(()) } diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index 996264ff..d57ca096 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -19,17 +19,41 @@ use std::time::Duration; use hyperlight_common::virtq::G2H_LOWER_SLOT_COUNT; use hyperlight_common::vmem::PAGE_SIZE; use hyperlight_host::sandbox::SandboxConfiguration; -use hyperlight_host::{is_hypervisor_present, GuestBinary, HyperlightError, Result}; - -use super::proto_js_sandbox::ProtoJSSandbox; +use hyperlight_host::{ + is_hypervisor_present, new_error, GuestBinary, HyperlightError, Result, + SandboxBuilder as HyperlightSandboxBuilder, +}; + +use super::host_fn::{ + host_function_dispatch, host_function_manifest, host_modules_by_name, HostFunctionModule, +}; +use super::module_loader::{ + ExternalModuleLoader, LOAD_MODULE_HOST_FUNCTION, RESOLVE_MODULE_HOST_FUNCTION, +}; +use super::proto_js_sandbox::{current_time_micros, ProtoJSSandbox}; +use super::snapshot::{restore_target, Snapshot, SnapshotTarget}; use crate::HostPrintFn; -/// A builder for a ProtoJSSandbox. +/// Builder source for a sandbox that starts the bundled JavaScript runtime. +#[derive(Debug, Default)] +pub struct NewSandbox; + +/// Configures a new JavaScript sandbox, or one restored from a [`Snapshot`]. /// -/// Transport setters record choices; [`Self::get_config`] and [`Self::build`] -/// resolve buffer sizes, pool budgets, and descriptor counts. -pub struct SandboxBuilder { +/// [`SandboxBuilder::new`] builds a [`ProtoJSSandbox`]. +/// [`SandboxBuilder::from_snapshot`] builds the sandbox type the snapshot captured. +/// A snapshot's memory layout overrides heap, scratch, and transport settings. +/// +/// Transport setters record choices. [`Self::get_config`] and +/// [`SandboxBuilder::build`] resolve buffer sizes, pool budgets, and descriptor +/// counts. +pub struct SandboxBuilder { + source: S, base_config: SandboxConfiguration, + #[cfg(crashdump)] + guest_core_dump: Option, + #[cfg(gdb)] + guest_debug_info: Option, host_print_fn: Option, /// Requested legacy input capacity in bytes, also used for host replies. legacy_input_capacity: usize, @@ -43,6 +67,8 @@ pub struct SandboxBuilder { input_transport_buffer_size: usize, /// Requested output buffer size in bytes, normalized during resolution. output_transport_buffer_size: usize, + host_function_modules: Vec, + module_loader: Option, } /// The minimum scratch size for the JS runtime sandbox. @@ -75,15 +101,19 @@ const DEFAULT_TRANSPORT_BUFFER_SIZE: usize = 16 * 1024; /// The previous 8 KiB minimum for byte-based buffer configuration. const MIN_LEGACY_BUFFER_SIZE: usize = 8 * 1024; -impl SandboxBuilder { - /// Create a new SandboxBuilder - pub fn new() -> Self { +impl SandboxBuilder { + fn with_source(source: S) -> Self { let mut config = SandboxConfiguration::default(); config.set_heap_size(MIN_HEAP_SIZE); config.set_scratch_size(MIN_SCRATCH_SIZE); Self { + source, base_config: config, + #[cfg(crashdump)] + guest_core_dump: None, + #[cfg(gdb)] + guest_debug_info: None, host_print_fn: None, legacy_input_capacity: DEFAULT_LEGACY_BUFFER_SIZE, legacy_output_capacity: DEFAULT_LEGACY_BUFFER_SIZE, @@ -91,15 +121,73 @@ impl SandboxBuilder { output_transport_buffer_size: DEFAULT_TRANSPORT_BUFFER_SIZE, requested_input_pool_pages: None, requested_output_pool_pages: None, + host_function_modules: Vec::new(), + module_loader: None, } } + /// Supply the host-function modules for this sandbox. + /// + /// Each module moves into the builder, so one module cannot be supplied to + /// two builders. Build fresh modules for each sandbox, for example with a + /// function that returns them. Capturing an [`std::sync::Arc`] or static + /// mutable state in a callback shares that state between sandboxes. + /// + /// ```compile_fail + /// use hyperlight_js::{HostFunctionModule, SandboxBuilder}; + /// + /// let module = HostFunctionModule::new("host:example"); + /// let _first = SandboxBuilder::new().with_host_function_modules([module]); + /// let _second = SandboxBuilder::new().with_host_function_modules([module]); + /// ``` + pub fn with_host_function_modules( + mut self, + modules: impl IntoIterator, + ) -> Self { + self.host_function_modules.extend(modules); + self + } + + /// Install an external JavaScript module loader for fresh or restored sandboxes. + pub fn with_module_loader( + mut self, + file_system: Fs, + ) -> Self { + self.module_loader = Some(ExternalModuleLoader::new(file_system)); + self + } + /// Set the host print function pub fn with_host_print_fn(mut self, host_print_fn: HostPrintFn) -> Self { self.host_print_fn = Some(host_print_fn); self } + /// Sets the offset from `SIGRTMIN` to determine the real-time signal used for + /// interrupting the VCPU thread. + /// + /// The final signal number is computed as `SIGRTMIN + offset`, and it must fall within + /// the valid range of real-time signals supported by the host system. + /// + /// Returns Ok(()) if the offset is valid, or an error if it exceeds the maximum real-time signal number. + #[cfg(target_os = "linux")] + pub fn set_interrupt_vcpu_sigrtmin_offset(&mut self, offset: u8) -> Result<()> { + self.base_config + .set_interrupt_vcpu_sigrtmin_offset(offset)?; + Ok(()) + } + + /// Sets the interrupt retry delay + /// This controls the delay between sending signals to the VCPU thread to interrupt it. + /// + /// Only available for the hypervisor backends that use retrying interrupts + /// (KVM and MSHV on Linux, Hypervisor.framework on macOS). + #[cfg(any(kvm, mshv3, hvf))] + pub fn with_interrupt_retry_delay(mut self, delay: Duration) -> Self { + self.base_config.set_interrupt_retry_delay(delay); + self + } + /// Set the guest-to-host message capacity in bytes. #[deprecated(note = "Use with_output_transport_pool_pages for an explicit pool budget")] pub fn with_guest_output_buffer_size(mut self, guest_output_buffer_size: usize) -> Self { @@ -194,32 +282,8 @@ impl SandboxBuilder { self } - /// Sets the offset from `SIGRTMIN` to determine the real-time signal used for - /// interrupting the VCPU thread. - /// - /// The final signal number is computed as `SIGRTMIN + offset`, and it must fall within - /// the valid range of real-time signals supported by the host system. - /// - /// Returns Ok(()) if the offset is valid, or an error if it exceeds the maximum real-time signal number. - #[cfg(target_os = "linux")] - pub fn set_interrupt_vcpu_sigrtmin_offset(&mut self, offset: u8) -> Result<()> { - self.base_config - .set_interrupt_vcpu_sigrtmin_offset(offset)?; - Ok(()) - } - - /// Sets the interrupt retry delay - /// This controls the delay between sending signals to the VCPU thread to interrupt it. - /// - /// Only available for the hypervisor backends that use retrying interrupts - /// (KVM and MSHV on Linux, Hypervisor.framework on macOS). - #[cfg(any(kvm, mshv3, hvf))] - pub fn with_interrupt_retry_delay(mut self, delay: Duration) -> Self { - self.base_config.set_interrupt_retry_delay(delay); - self - } - - /// Get an owned snapshot of the effective configuration used by [`Self::build`]. + /// Get an owned snapshot of the effective configuration used by + /// [`SandboxBuilder::build`] for a new sandbox. /// /// Resolves transport sizes without changing the builder. Modifying the /// returned value does not affect subsequent configuration or builds. @@ -236,6 +300,7 @@ impl SandboxBuilder { #[cfg(crashdump)] pub fn with_crashdump_enabled(mut self, enabled: bool) -> Self { self.base_config.set_guest_core_dump(enabled); + self.guest_core_dump = Some(enabled); self } @@ -259,21 +324,10 @@ impl SandboxBuilder { pub fn with_debugging_enabled(mut self, port: u16) -> Self { let debug_info = hyperlight_host::sandbox::config::DebugInfo { port }; self.base_config.set_guest_debug_info(debug_info); + self.guest_debug_info = Some(debug_info); self } - /// Build the ProtoJSSandbox - pub fn build(self) -> Result { - if !is_hypervisor_present() { - return Err(HyperlightError::NoHypervisorFound()); - } - - let config = self.get_config(); - let guest_binary = GuestBinary::Buffer(super::JSRUNTIME.to_vec()); - let proto_js_sandbox = ProtoJSSandbox::new(guest_binary, Some(config), self.host_print_fn)?; - Ok(proto_js_sandbox) - } - /// Normalizes buffer sizes, applies explicit pool budgets or legacy /// capacities, and sizes descriptor queues from the complete buffer counts. /// Reserves a control spare and room for simultaneous callback requests/replies. @@ -339,8 +393,134 @@ impl SandboxBuilder { } } -impl Default for SandboxBuilder { +impl SandboxBuilder { + /// Create a builder for a new sandbox. + pub fn new() -> Self { + Self::with_source(NewSandbox) + } + + /// Build the ProtoJSSandbox + pub fn build(self) -> Result { + if !is_hypervisor_present() { + return Err(HyperlightError::NoHypervisorFound()); + } + + let config = self.get_config(); + let host_modules = host_modules_by_name(self.host_function_modules)?; + let guest_binary = GuestBinary::Buffer(super::JSRUNTIME.to_vec()); + let proto_js_sandbox = ProtoJSSandbox::new( + guest_binary, + Some(config), + self.host_print_fn, + host_modules, + self.module_loader, + )?; + Ok(proto_js_sandbox) + } +} + +impl SandboxBuilder> { + /// Create a builder that restores `snapshot`. + /// + /// The snapshot's memory layout overrides heap, scratch, and transport + /// settings. Supply every host function and module loader the snapshot uses. + pub fn from_snapshot(snapshot: Snapshot) -> Self { + Self::with_source(snapshot) + } + + /// Restore the snapshot. + /// + /// Checks host-function requirements before running the guest. + pub fn build(self) -> Result { + if !is_hypervisor_present() { + return Err(HyperlightError::NoHypervisorFound()); + } + let mut builder = self + .apply_runtime_config(HyperlightSandboxBuilder::from_snapshot(self.source.inner()))?; + let snapshot = self.source; + let host_modules = host_modules_by_name(self.host_function_modules)?; + let available_host_functions = host_function_manifest(&host_modules); + snapshot + .metadata() + .validate_host_functions(&available_host_functions)?; + + if let Some(host_print_fn) = self.host_print_fn { + builder = builder.host_print(host_print_fn); + } + builder = builder + .host_function("CurrentTimeMicros", current_time_micros) + .host_function("CallHostJsFunction", host_function_dispatch(host_modules)); + if let Some(module_loader) = self.module_loader { + builder = module_loader.apply(builder); + } + + let sandbox = builder.build().map_err(|error| match &error { + HyperlightError::SnapshotHostFunctionMismatch { missing, .. } + if missing.iter().any(|name| { + name == RESOLVE_MODULE_HOST_FUNCTION || name == LOAD_MODULE_HOST_FUNCTION + }) => + { + new_error!( + "Snapshot requires a module loader; call with_module_loader() on SandboxBuilder ({})", + error + ) + } + _ => error, + })?; + restore_target(sandbox, &snapshot, available_host_functions) + } +} + +impl SandboxBuilder { + /// Apply settings that the snapshot layout does not fix. + fn apply_runtime_config( + &self, + builder: HyperlightSandboxBuilder, + ) -> Result { + #[cfg(target_os = "linux")] + let builder = builder.interrupt_vcpu_sigrtmin_offset( + self.base_config.get_interrupt_vcpu_sigrtmin_offset(), + )?; + #[cfg(any(kvm, mshv3))] + let builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); + #[cfg(crashdump)] + let builder = match self.guest_core_dump { + Some(enabled) => builder.guest_core_dump(enabled), + None => builder, + }; + #[cfg(gdb)] + let builder = match self.guest_debug_info { + Some(debug_info) => builder.guest_debug_info(debug_info), + None => builder, + }; + Ok(builder) + } +} + +impl Default for SandboxBuilder { fn default() -> Self { Self::new() } } + +#[cfg(all(test, any(kvm, mshv3)))] +mod tests { + use super::*; + + #[test] + fn runtime_config_reaches_hyperlight_builder() { + let mut builder = + SandboxBuilder::new().with_interrupt_retry_delay(Duration::from_millis(7)); + builder.set_interrupt_vcpu_sigrtmin_offset(3).unwrap(); + + let hyperlight = builder + .apply_runtime_config(HyperlightSandboxBuilder::from_bytes(Vec::new())) + .unwrap(); + + assert_eq!(hyperlight.get_interrupt_vcpu_sigrtmin_offset(), 3); + assert_eq!( + hyperlight.get_interrupt_retry_delay(), + Duration::from_millis(7) + ); + } +} diff --git a/src/hyperlight-js/src/sandbox/snapshot.rs b/src/hyperlight-js/src/sandbox/snapshot.rs new file mode 100644 index 00000000..71c43c86 --- /dev/null +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -0,0 +1,690 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use hyperlight_host::sandbox::snapshot::{ + OciDigest, OciReference, OciTag, Snapshot as HyperlightSnapshot, +}; +use hyperlight_host::Sandbox; +use hyperlight_js_common::GUEST_ABI_VERSION; +use serde::{Deserialize, Serialize}; + +use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reserved}; +use crate::{new_error, JSSandbox, LoadedJSSandbox, Result, Script}; + +const METADATA_NAMESPACE: &str = "dev.hyperlight.hyperlight-js.snapshot.v1"; +const METADATA_SCHEMA_VERSION: u32 = 1; +const BASE_TAG_SUFFIX: &str = ".hljs-base"; +const MAX_OCI_TAG_LENGTH: usize = 128; +const MAX_LOADED_SNAPSHOT_TAG_LENGTH: usize = MAX_OCI_TAG_LENGTH - BASE_TAG_SUFFIX.len(); + +/// Logical JavaScript host modules and exported function names. +/// +/// Signatures are intentionally not represented. Typed Rust callbacks are +/// erased to a JSON bridge, JavaScript callbacks are dynamic, and Hyperlight +/// can validate only the fixed `CallHostJsFunction` bridge signature. This +/// manifest therefore guarantees name availability, not semantic signature +/// compatibility; callers must keep implementations compatible across restore. +pub(crate) type HostFunctionManifest = BTreeMap>; + +/// The Hyperlight JavaScript lifecycle state recorded in snapshot metadata. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotKind { + /// The JavaScript runtime is loaded, with handlers and modules added to the + /// `JSSandbox` but not yet registered in the guest. + JsSandbox, + /// The JavaScript runtime, handlers, and modules are loaded in the guest. + LoadedJsSandbox, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScriptMetadata { + content: String, + base_path: Option, +} + +impl From<&Script> for ScriptMetadata { + fn from(script: &Script) -> Self { + Self { + content: script.content().to_owned(), + base_path: script.base_path().map(Path::to_path_buf), + } + } +} + +impl From for Script { + fn from(metadata: ScriptMetadata) -> Self { + let script = Script::from_content(metadata.content); + match metadata.base_path { + Some(path) => script.with_virtual_base(path.to_string_lossy()), + None => script, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SnapshotMetadata { + schema_version: u32, + hyperlight_js_version: String, + guest_abi_version: u32, + kind: SnapshotKind, + handlers: BTreeMap, + modules: BTreeMap, + host_functions: HostFunctionManifest, + // Persistence-only OCI digest used to resolve `Snapshot::base` during load. + // `Snapshot::base` is authoritative in memory; save rewrites this field. + base_snapshot: Option, +} + +impl SnapshotMetadata { + pub(crate) fn new( + kind: SnapshotKind, + handlers: impl Iterator, + modules: impl Iterator, + host_functions: HostFunctionManifest, + ) -> Self { + Self { + schema_version: METADATA_SCHEMA_VERSION, + hyperlight_js_version: env!("CARGO_PKG_VERSION").to_owned(), + guest_abi_version: GUEST_ABI_VERSION, + kind, + handlers: handlers.collect(), + modules: modules.collect(), + host_functions, + base_snapshot: None, + } + } + + pub(crate) fn kind(&self) -> SnapshotKind { + self.kind + } + + pub(crate) fn handlers(&self) -> impl Iterator + '_ { + self.handlers + .clone() + .into_iter() + .map(|(name, script)| (name, script.into())) + } + + pub(crate) fn modules(&self) -> impl Iterator + '_ { + self.modules + .clone() + .into_iter() + .map(|(name, script)| (name, script.into())) + } + + /// Validate that the destination sandbox provides every logical host function + /// required by this guest state. Hyperlight validates only the single typed + /// `CallHostJsFunction` bridge, so its registry cannot see these JS module + /// and function names. + pub(crate) fn validate_host_functions(&self, available: &HostFunctionManifest) -> Result<()> { + // A module with no functions gives the guest nothing to call. + let required = self + .host_functions + .iter() + .filter(|(_, functions)| !functions.is_empty()); + for (module, required_functions) in required { + let available_functions = available.get(module).ok_or_else(|| { + new_error!( + "Snapshot requires host module '{}', but it was not registered", + module + ) + })?; + for function in required_functions { + if !available_functions.contains(function) { + return Err(new_error!( + "Snapshot requires host function '{}.{}', but it was not registered", + module, + function + )); + } + } + } + Ok(()) + } + + fn validate(&self) -> Result<()> { + if self.schema_version != METADATA_SCHEMA_VERSION { + return Err(new_error!( + "Unsupported Hyperlight JavaScript snapshot metadata version {} produced by hyperlight-js {}; expected {}", + self.schema_version, + self.hyperlight_js_version, + METADATA_SCHEMA_VERSION + )); + } + if self.guest_abi_version != GUEST_ABI_VERSION { + return Err(new_error!( + "Snapshot guest runtime ABI version {} produced by hyperlight-js {} is incompatible with ABI version {}", + self.guest_abi_version, + self.hyperlight_js_version, + GUEST_ABI_VERSION + )); + } + match (self.kind, self.base_snapshot.is_some()) { + (SnapshotKind::JsSandbox, true) => { + return Err(new_error!( + "JSSandbox snapshot metadata must not contain a base snapshot" + )); + } + (SnapshotKind::LoadedJsSandbox, false) => { + return Err(new_error!( + "LoadedJSSandbox snapshot metadata is missing its base snapshot" + )); + } + _ => {} + } + + if self.handlers.keys().any(|name| name.is_empty()) { + return Err(new_error!( + "Snapshot metadata contains an empty handler name" + )); + } + + for qualified_name in self.modules.keys() { + let (namespace, module_name) = qualified_name.split_once(':').ok_or_else(|| { + new_error!( + "Snapshot module name '{}' must be qualified as ':'", + qualified_name + ) + })?; + validate_module_identifier(namespace, "Module namespace")?; + validate_module_identifier(module_name, "Module name")?; + validate_namespace_not_reserved(namespace)?; + } + + Ok(()) + } +} + +mod private { + pub trait Sealed { + const KIND: super::SnapshotKind; + + fn from_snapshot( + inner: super::Sandbox, + snapshot: &super::Snapshot, + host_functions: super::HostFunctionManifest, + ) -> super::Result + where + Self: Sized; + } +} + +/// A sandbox type that a [`Snapshot`] can capture and restore. +/// +/// This trait is sealed and implemented only for [`JSSandbox`] and +/// [`LoadedJSSandbox`]. +pub trait SnapshotTarget: private::Sealed + Sized {} + +impl private::Sealed for JSSandbox { + const KIND: SnapshotKind = SnapshotKind::JsSandbox; + + fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + JSSandbox::from_snapshot(inner, snapshot, host_functions) + } +} + +impl SnapshotTarget for JSSandbox {} + +impl private::Sealed for LoadedJSSandbox { + const KIND: SnapshotKind = SnapshotKind::LoadedJsSandbox; + + fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + LoadedJSSandbox::from_snapshot(inner, snapshot, host_functions) + } +} + +impl SnapshotTarget for LoadedJSSandbox {} + +pub(crate) fn restore_target( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, +) -> Result { + ::from_snapshot(inner, snapshot, host_functions) +} + +/// A persistent snapshot of a [`JSSandbox`] or [`LoadedJSSandbox`]. +/// +/// `T` is the sandbox type the snapshot restores to. Loading checks the +/// persisted kind once against `T`. +pub struct Snapshot { + inner: Arc, + base: Option>, + metadata: Arc, + target: PhantomData T>, +} + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + base: self.base.clone(), + metadata: Arc::clone(&self.metadata), + target: PhantomData, + } + } +} + +impl Snapshot { + /// Load a snapshot from an OCI image layout without checking blob digests. + /// + /// Fails if the snapshot captures a sandbox type other than `T`. The memory + /// blob stays mapped while the snapshot or any sandbox built from it is + /// alive. Overwriting, truncating, or deleting the layout's blobs during + /// that time can corrupt guest memory and is undefined behavior. + pub fn load(path: impl AsRef, reference: impl Into) -> Result { + Self::load_inner(path.as_ref(), reference.into(), false) + } + + /// Load a snapshot like [`Self::load`] and check every blob against its + /// recorded sha256 digest. + /// + /// A digest check does not prove the bytes are authentic. Load only from a + /// trusted source. + pub fn checked_load( + path: impl AsRef, + reference: impl Into, + ) -> Result { + Self::load_inner(path.as_ref(), reference.into(), true) + } + + fn load_inner(path: &Path, reference: OciReference, verified: bool) -> Result { + let (inner, base, metadata) = load_parts( + path, + reference, + verified, + Some(::KIND), + )?; + Ok(Self::new(inner, base, metadata)) + } +} + +type SnapshotParts = ( + Arc, + Option>, + Arc, +); + +fn load_parts( + path: &Path, + reference: OciReference, + verified: bool, + expected: Option, +) -> Result { + let inner = Arc::new(if verified { + HyperlightSnapshot::checked_load(path, reference)? + } else { + HyperlightSnapshot::load(path, reference)? + }); + let metadata = inner + .metadata::(METADATA_NAMESPACE)? + .ok_or_else(|| { + new_error!( + "Snapshot does not contain Hyperlight JavaScript metadata under namespace '{}'", + METADATA_NAMESPACE + ) + })?; + metadata.validate()?; + if let Some(expected) = expected + && metadata.kind() != expected + { + return Err(new_error!( + "Snapshot captures a {:?}, not a {:?}", + metadata.kind(), + expected + )); + } + + let base = match &metadata.base_snapshot { + Some(reference) => { + let reference = reference.parse::()?; + Some(Arc::new(if verified { + HyperlightSnapshot::checked_load(path, reference)? + } else { + HyperlightSnapshot::load(path, reference)? + })) + } + None => None, + }; + + Ok((inner, base, Arc::new(metadata))) +} + +/// A snapshot of either sandbox type, for bindings that cannot name `T`. +#[doc(hidden)] +#[derive(Clone)] +pub enum AnySnapshot { + /// A [`JSSandbox`] snapshot. + JsSandbox(Snapshot), + /// A [`LoadedJSSandbox`] snapshot. + LoadedJsSandbox(Snapshot), +} + +impl AnySnapshot { + /// Load a snapshot of either type, like [`Snapshot::load`]. + pub fn load(path: impl AsRef, reference: impl Into) -> Result { + Self::load_inner(path.as_ref(), reference.into(), false) + } + + /// Load a snapshot of either type, like [`Snapshot::checked_load`]. + pub fn checked_load( + path: impl AsRef, + reference: impl Into, + ) -> Result { + Self::load_inner(path.as_ref(), reference.into(), true) + } + + fn load_inner(path: &Path, reference: OciReference, verified: bool) -> Result { + let (inner, base, metadata) = load_parts(path, reference, verified, None)?; + Ok(match metadata.kind() { + SnapshotKind::JsSandbox => Self::JsSandbox(Snapshot::new(inner, base, metadata)), + SnapshotKind::LoadedJsSandbox => { + Self::LoadedJsSandbox(Snapshot::new(inner, base, metadata)) + } + }) + } + + /// Save the snapshot, like [`Snapshot::save`]. + pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { + match self { + Self::JsSandbox(snapshot) => snapshot.save(path, tag), + Self::LoadedJsSandbox(snapshot) => snapshot.save(path, tag), + } + } +} + +impl Snapshot { + pub(crate) fn new( + inner: Arc, + base: Option>, + metadata: Arc, + ) -> Self { + Self { + inner, + base, + metadata, + target: PhantomData, + } + } + + /// Save the snapshot to an OCI image layout and return its manifest digest. + /// + /// A [`LoadedJSSandbox`] snapshot also saves its runtime-ready baseline under + /// `.hljs-base`, which limits `tag` to 118 bytes. Tags ending in + /// `.hljs-base` are reserved for these baselines. + pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { + validate_user_tag(tag)?; + let mut metadata = self.metadata.as_ref().clone(); + if let Some(base) = &self.base { + // Hyperlight does not yet support OCI snapshot bundles, so save the + // runtime-ready baseline separately and link it through metadata. + // https://github.com/hyperlight-dev/hyperlight/issues/1865 + let base_tag = base_tag(tag)?; + let base_digest = base.save(path.as_ref(), &base_tag)?; + metadata.base_snapshot = Some(base_digest.to_string()); + } + let snapshot = self.inner.with_metadata(METADATA_NAMESPACE, &metadata)?; + + snapshot.save(path, tag) + } + + pub(crate) fn inner(&self) -> Arc { + Arc::clone(&self.inner) + } + + pub(crate) fn base(&self) -> Option> { + self.base.clone() + } + + pub(crate) fn metadata(&self) -> &SnapshotMetadata { + &self.metadata + } +} + +fn validate_user_tag(tag: &OciTag) -> Result<()> { + if tag.as_str().ends_with(BASE_TAG_SUFFIX) { + return Err(new_error!( + "Snapshot tag '{}' must not end in '{BASE_TAG_SUFFIX}', which is reserved for LoadedJSSandbox baselines", + tag.as_str() + )); + } + Ok(()) +} + +fn base_tag(tag: &OciTag) -> Result { + // TODO(https://github.com/hyperlight-dev/hyperlight/issues/1865): Remove + // this shorter limit when both states are saved as one OCI bundle. + if tag.as_str().len() > MAX_LOADED_SNAPSHOT_TAG_LENGTH { + return Err(new_error!( + "LoadedJSSandbox snapshot tag must be at most {} bytes to reserve space for its runtime-ready baseline tag", + MAX_LOADED_SNAPSHOT_TAG_LENGTH + )); + } + + OciTag::new(format!("{}{BASE_TAG_SUFFIX}", tag.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(kind: SnapshotKind) -> SnapshotMetadata { + SnapshotMetadata::new( + kind, + std::iter::empty(), + std::iter::empty(), + BTreeMap::new(), + ) + } + + fn script_metadata() -> ScriptMetadata { + ScriptMetadata::from(&Script::from_content("function handler() {}")) + } + + #[test] + fn metadata_rejects_unknown_fields() { + let mut value = serde_json::to_value(metadata(SnapshotKind::JsSandbox)).unwrap(); + value["unknown"] = serde_json::json!(true); + + let error = serde_json::from_value::(value).unwrap_err(); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn script_metadata_rejects_unknown_fields() { + let mut value = serde_json::to_value(script_metadata()).unwrap(); + value["unknown"] = serde_json::json!(true); + + let error = serde_json::from_value::(value).unwrap_err(); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn metadata_schema_error_includes_producer_version() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.schema_version = METADATA_SCHEMA_VERSION + 1; + metadata.hyperlight_js_version = "9.8.7".to_owned(); + + let error = metadata.validate().unwrap_err(); + + assert!(error + .to_string() + .contains("produced by hyperlight-js 9.8.7")); + } + + #[test] + fn metadata_rejects_other_guest_abi_version() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.guest_abi_version = GUEST_ABI_VERSION + 1; + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("guest runtime ABI version")); + } + + #[test] + fn loaded_metadata_does_not_require_staged_definitions() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata.base_snapshot = Some(format!("sha256:{}", "0".repeat(64))); + + metadata.validate().unwrap(); + } + + #[test] + fn metadata_rejects_empty_handler_name() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.handlers.insert(String::new(), script_metadata()); + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("empty handler name")); + } + + #[test] + fn metadata_rejects_invalid_qualified_module_names() { + for name in [ + "module", + ":module", + "user:", + "user:module:extra", + "host:module", + ] { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.modules.insert(name.to_owned(), script_metadata()); + + assert!( + metadata.validate().is_err(), + "accepted module name {name:?}" + ); + } + } + + #[test] + fn metadata_accepts_valid_public_api_identifiers() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata + .handlers + .insert("handler:name".to_owned(), script_metadata()); + metadata + .modules + .insert("user:module".to_owned(), script_metadata()); + + metadata.validate().unwrap(); + } + + #[test] + fn host_function_validation_requires_every_recorded_function() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata.host_functions.insert( + "host:database".to_owned(), + vec!["execute".to_owned(), "query".to_owned()], + ); + let available = |functions: &[&str]| { + HostFunctionManifest::from([( + "host:database".to_owned(), + functions.iter().map(|name| (*name).to_owned()).collect(), + )]) + }; + + metadata + .validate_host_functions(&available(&["execute", "extra", "query"])) + .unwrap(); + let error = metadata + .validate_host_functions(&available(&["query"])) + .unwrap_err(); + assert!(error + .to_string() + .contains("host function 'host:database.execute'")); + let error = metadata + .validate_host_functions(&HostFunctionManifest::new()) + .unwrap_err(); + assert!(error.to_string().contains("host module 'host:database'")); + } + + #[test] + fn host_function_validation_ignores_recorded_empty_modules() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata + .host_functions + .insert("host:empty".to_owned(), Vec::new()); + + metadata + .validate_host_functions(&HostFunctionManifest::new()) + .unwrap(); + } + + #[test] + fn script_metadata_round_trips_source_and_json_shape() { + let script = + Script::from_content("export const answer = 42;").with_virtual_base("/modules"); + let metadata = ScriptMetadata::from(&script); + + let json = serde_json::to_value(&metadata).unwrap(); + let restored_metadata: ScriptMetadata = serde_json::from_value(json.clone()).unwrap(); + let restored_script: Script = restored_metadata.into(); + + assert_eq!(json["content"], "export const answer = 42;"); + assert_eq!(json["base_path"], "/modules"); + assert_eq!(restored_script.content(), "export const answer = 42;"); + assert_eq!(restored_script.base_path(), Some(Path::new("/modules"))); + } + + #[test] + fn base_tag_accepts_maximum_loaded_snapshot_tag_length() { + let tag = OciTag::new("a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH)).unwrap(); + + let base = base_tag(&tag).unwrap(); + + assert_eq!(base.as_str().len(), MAX_OCI_TAG_LENGTH); + assert_eq!(base.as_str(), format!("{}{BASE_TAG_SUFFIX}", tag.as_str())); + } + + #[test] + fn base_tag_rejects_tag_that_would_require_truncation() { + let tag = OciTag::new("a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH + 1)).unwrap(); + + let error = base_tag(&tag).unwrap_err(); + + assert!(error.to_string().contains("must be at most 118 bytes")); + } + + #[test] + fn distinct_loaded_snapshot_tags_have_distinct_base_tags() { + let shared_prefix = "a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH - 1); + let first = OciTag::new(format!("{shared_prefix}x")).unwrap(); + let second = OciTag::new(format!("{shared_prefix}y")).unwrap(); + + assert_ne!(base_tag(&first).unwrap(), base_tag(&second).unwrap()); + } +} diff --git a/src/hyperlight-js/tests/host_functions.rs b/src/hyperlight-js/tests/host_functions.rs index e132508f..b2a71c0a 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -15,7 +15,11 @@ limitations under the License. */ //! Test for host modules / functions. -use hyperlight_js::{SandboxBuilder, Script}; +#![allow(clippy::disallowed_macros)] +// Most tests here cover the deprecated ProtoJSSandbox registration API. +#![allow(deprecated)] + +use hyperlight_js::{HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; #[test] fn can_call_host_functions() { @@ -1037,3 +1041,205 @@ fn register_js_deeply_nested_binary_return() { assert_eq!(res, r#"{"inner_len":2,"first_byte":171,"label":"deep"}"#); } + +#[test] +fn js_restore_rejects_missing_host_function_before_mutation() { + let mut source_proto = SandboxBuilder::new().build().unwrap(); + source_proto + .register("required", "value", |_: i32| 1) + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut destination = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return { intact: true }; }"), + ) + .unwrap(); + + let error = destination.restore(snapshot).unwrap_err(); + assert!(error.to_string().contains("required")); + + let mut loaded = destination.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("destination", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"intact":true}"#); +} + +#[test] +fn loaded_restore_rejects_missing_host_function_before_mutation() { + let mut source_proto = SandboxBuilder::new().build().unwrap(); + source_proto + .register("required", "value", |_: i32| 1) + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.get_loaded_sandbox().unwrap().snapshot().unwrap(); + + let mut destination = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return { intact: true }; }"), + ) + .unwrap(); + let mut destination = destination.get_loaded_sandbox().unwrap(); + + let error = destination.restore(snapshot).unwrap_err(); + assert!(error.to_string().contains("required")); + + let result = destination + .handle_event("destination", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"intact":true}"#); +} + +fn required_host_function_module() -> HostFunctionModule { + let mut required = HostFunctionModule::new("required"); + required.register("value", |_: i32| 1); + required +} + +fn extra_host_function_module() -> HostFunctionModule { + let mut extra = HostFunctionModule::new("extra"); + extra.register("value", |_: i32| 2); + extra +} + +fn loaded_snapshot_requiring_host_function() -> Snapshot { + let mut source = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + source.get_loaded_sandbox().unwrap().snapshot().unwrap() +} + +#[test] +fn loaded_restore_keeps_captured_requirements_until_unload() { + let snapshot = loaded_snapshot_requiring_host_function(); + let mut restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules([ + required_host_function_module(), + extra_host_function_module(), + ]) + .build() + .unwrap(); + + let loaded_snapshot = restored.snapshot().unwrap(); + SandboxBuilder::from_snapshot(loaded_snapshot) + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap(); + + let mut unloaded = restored.unload().unwrap(); + let unloaded_snapshot = unloaded.snapshot().unwrap(); + let error = match SandboxBuilder::from_snapshot(unloaded_snapshot) + .with_host_function_modules([required_host_function_module()]) + .build() + { + Ok(_) => panic!("restore without the extra module unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.to_string().contains("extra")); + + unloaded + .add_handler( + "extra", + Script::from_content( + r#" + import * as extra from "extra"; + function handler(event) { return { value: extra.value(0) }; } + "#, + ), + ) + .unwrap(); + let mut reloaded = unloaded.get_loaded_sandbox().unwrap(); + let result = reloaded + .handle_event("extra", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":2}"#); +} + +#[test] +fn restored_js_sandbox_records_supplied_host_functions() { + let source_proto = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules([ + required_host_function_module(), + extra_host_function_module(), + ]) + .build() + .unwrap(); + let complete_snapshot = restored.snapshot().unwrap(); + + let mut insufficient = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + let error = insufficient.restore(complete_snapshot).unwrap_err(); + assert!(error.to_string().contains("extra")); +} diff --git a/src/hyperlight-js/tests/module_loader.rs b/src/hyperlight-js/tests/module_loader.rs index 99f9e945..a3d44cc4 100644 --- a/src/hyperlight-js/tests/module_loader.rs +++ b/src/hyperlight-js/tests/module_loader.rs @@ -15,7 +15,67 @@ limitations under the License. */ //! Tests for the module loader that import files from the embedded filesystem. -use hyperlight_js::{embed_modules, SandboxBuilder, Script}; +#![allow(clippy::disallowed_macros)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use hyperlight_js::{ + embed_modules, FileMetadata, FileSystem, FileSystemEmbedded, OciTag, ResolveError, + SandboxBuilder, Script, Snapshot, +}; + +struct CloneCountingFileSystem { + inner: FileSystemEmbedded, + clone_count: Arc, +} + +impl CloneCountingFileSystem { + fn new(inner: FileSystemEmbedded, clone_count: Arc) -> Self { + Self { inner, clone_count } + } +} + +impl Clone for CloneCountingFileSystem { + fn clone(&self) -> Self { + self.clone_count.fetch_add(1, Ordering::Relaxed); + Self { + inner: self.inner, + clone_count: Arc::clone(&self.clone_count), + } + } +} + +impl FileSystem for CloneCountingFileSystem { + fn new() -> Self { + unreachable!("CloneCountingFileSystem must wrap an embedded file system") + } + + fn read(&self, path: &Path) -> std::io::Result> { + self.inner.read(path) + } + + fn read_to_string(&self, path: &Path) -> std::io::Result { + self.inner.read_to_string(path) + } + + fn metadata(&self, path: &Path) -> std::io::Result { + self.inner.metadata(path) + } + + fn symlink_metadata(&self, path: &Path) -> std::io::Result { + self.inner.symlink_metadata(path) + } + + fn read_link(&self, path: &Path) -> Result { + self.inner.read_link(path) + } + + fn canonicalize(&self, path: &Path) -> std::io::Result { + self.inner.canonicalize(path) + } +} #[test] fn test_handler_with_multiple_imports() { @@ -39,8 +99,10 @@ fn test_handler_with_multiple_imports() { let event = r#"{"a": 5, "b": 3}"#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + let proto_js_sandbox = SandboxBuilder::new() + .with_module_loader(fs) + .build() + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -76,8 +138,13 @@ fn test_handler_import_restrictions() { } "#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + // Compatibility coverage for callers that still configure a fresh ProtoJSSandbox. + #[allow(deprecated)] + let proto_js_sandbox = SandboxBuilder::new() + .build() + .unwrap() + .set_module_loader(fs) + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -131,8 +198,10 @@ fn test_handler_import_from_a_subfolder() { } "#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + let proto_js_sandbox = SandboxBuilder::new() + .with_module_loader(fs) + .build() + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -146,3 +215,113 @@ fn test_handler_import_from_a_subfolder() { assert_eq!(res, "42"); } + +#[test] +fn restored_sandbox_reuses_module_resolver() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + "strings.js" => "fixtures/strings.js", + }; + let clone_count = Arc::new(AtomicUsize::new(0)); + let file_system = CloneCountingFileSystem::new(modules, Arc::clone(&clone_count)); + let handler = Script::from_content( + r#" + import { add } from './math.js'; + import { toUpperCase } from './strings.js'; + + function handler(event) { + event.sum = add(event.a, event.b); + event.message = toUpperCase('restored'); + return event; + } + "#, + ) + .with_virtual_base("/"); + + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("calculator", handler).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let builder = SandboxBuilder::from_snapshot(snapshot).with_module_loader(file_system); + let clones_after_setup = clone_count.load(Ordering::Relaxed); + let sandbox = builder.build().unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + assert_eq!(clone_count.load(Ordering::Relaxed), clones_after_setup); + + let result = loaded + .handle_event("calculator", r#"{"a":5,"b":3}"#.to_owned(), None) + .unwrap(); + assert!(result.contains(r#""sum":8"#)); + assert!(result.contains(r#""message":"RESTORED""#)); +} + +#[test] +fn restore_reports_required_module_loader() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + }; + let proto = SandboxBuilder::new() + .with_module_loader(modules) + .build() + .unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let error = match SandboxBuilder::from_snapshot(snapshot).build() { + Ok(_) => panic!("restore unexpectedly succeeded without a module loader"), + Err(error) => error, + }; + let message = error.to_string(); + + assert!(message.contains("Snapshot requires a module loader")); + assert!(message.contains("with_module_loader()")); + assert!(message.contains("LoadModule")); + assert!(message.contains("ResolveModule")); +} + +#[test] +fn persisted_snapshot_restores_with_a_replacement_module_loader() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + }; + let handler = Script::from_content( + r#" + import { add } from './math.js'; + function handler(event) { return { value: add(event.value, 1) }; } + "#, + ) + .with_virtual_base("/"); + let proto = SandboxBuilder::new() + .with_module_loader(modules) + .build() + .unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + sandbox.add_handler("increment", handler).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + let directory = tempfile::tempdir().unwrap(); + snapshot + .save(directory.path(), &OciTag::new("external-modules").unwrap()) + .unwrap(); + + let snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("external-modules").unwrap(), + ) + .unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot) + .with_module_loader(modules) + .build() + .unwrap() + .get_loaded_sandbox() + .unwrap(); + + let result = loaded + .handle_event("increment", r#"{"value":41}"#.to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":42}"#); +} diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index 958999cb..66a7b2ed 100644 --- a/src/hyperlight-js/tests/native_modules.rs +++ b/src/hyperlight-js/tests/native_modules.rs @@ -28,7 +28,7 @@ limitations under the License. //! //! This recipe builds the custom runtime automatically during the host build. -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{OciTag, SandboxBuilder, Script, Snapshot}; /// Test that a custom native module ("math") can be imported and used /// from a handler running inside the Hyperlight VM. @@ -132,6 +132,74 @@ fn console_log_works_with_custom_native_module() { assert_eq!(result, "54"); } +/// Test that a persisted, disk-backed snapshot of a `LoadedJSSandbox` using a +/// custom native module ("math") restores correctly and keeps the custom +/// module callable afterwards. +/// +/// Custom native modules are compiled directly into the guest binary, unlike +/// host functions, which execute in the host process. Persistent restoration +/// builds directly from the Hyperlight snapshot, so the captured guest runtime +/// already contains the native module and requires no capability reattachment. +#[test] +#[ignore] +fn persistent_snapshot_restores_custom_native_module() { + let handler = Script::from_content( + r#" + import { add, multiply } from "math"; + let calls = 0; + function handler(event) { + calls += 1; + return { + sum: add(event.a, event.b), + product: multiply(event.a, event.b), + calls, + }; + } + "#, + ); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + sandbox.add_handler("compute", handler).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + // Exercise the custom module once before snapshotting so the restored + // state's `calls` counter proves guest state (not just the module) came + // back correctly. + let result = loaded + .handle_event("compute", r#"{"a":6,"b":7}"#.to_string(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["sum"], 13.0); + assert_eq!(result["product"], 42.0); + assert_eq!(result["calls"], 1); + + let snapshot = loaded.snapshot().unwrap(); + snapshot + .save( + directory.path(), + &OciTag::new("custom-module-ready").unwrap(), + ) + .unwrap(); + + let snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("custom-module-ready").unwrap(), + ) + .unwrap(); + let mut restored = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); + + let result = restored + .handle_event("compute", r#"{"a":10,"b":32}"#.to_string(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["sum"], 42.0); + assert_eq!(result["product"], 320.0); + assert_eq!(result["calls"], 2); +} #[test] #[ignore] fn custom_globals_and_host_clock_work_in_vm() { diff --git a/src/hyperlight-js/tests/persistent_snapshots.rs b/src/hyperlight-js/tests/persistent_snapshots.rs new file mode 100644 index 00000000..0cc8b85c --- /dev/null +++ b/src/hyperlight-js/tests/persistent_snapshots.rs @@ -0,0 +1,355 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#![allow(clippy::disallowed_macros)] + +use hyperlight_js::{JSSandbox, LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot}; + +fn oci_tag(name: &str) -> OciTag { + OciTag::new(name).unwrap() +} + +fn counter_handler() -> Script { + Script::from_content( + r#" + let count = 0; + function handler(event) { + count += 1; + event.count = count; + return event; + } + "#, + ) +} + +#[test] +fn persists_and_restores_js_sandbox() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + + let snapshot = sandbox.snapshot().unwrap(); + let digest = snapshot + .save(directory.path(), &oci_tag("runtime-ready")) + .unwrap(); + assert!(digest.to_string().starts_with("sha256:")); + + let snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("runtime-ready")).unwrap(); + let sandbox = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn js_sandbox_snapshot_accepts_maximum_oci_tag_length() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + let tag = oci_tag(&"a".repeat(128)); + + snapshot.save(directory.path(), &tag).unwrap(); + + Snapshot::::load(directory.path(), tag).unwrap(); +} + +#[test] +fn restores_pending_js_sandbox_state_in_memory() { + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + sandbox.clear_handlers(); + sandbox + .add_handler( + "replacement", + Script::from_content("function handler(event) { return event; }"), + ) + .unwrap(); + sandbox.restore(snapshot).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + assert!(loaded + .handle_event("replacement", "{}".to_owned(), None) + .is_err()); +} + +#[test] +fn rejects_loading_snapshot_as_wrong_lifecycle() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + sandbox + .snapshot() + .unwrap() + .save(directory.path(), &oci_tag("runtime-ready")) + .unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + loaded + .snapshot() + .unwrap() + .save(directory.path(), &oci_tag("application-ready")) + .unwrap(); + + let error = + match Snapshot::::checked_load(directory.path(), oci_tag("runtime-ready")) + { + Ok(_) => panic!("loading a JSSandbox snapshot as loaded should fail"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("Snapshot captures a JsSandbox, not a LoadedJsSandbox")); + + let error = + match Snapshot::::checked_load(directory.path(), oci_tag("application-ready")) { + Ok(_) => panic!("loading a LoadedJSSandbox snapshot as runtime-ready should fail"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("Snapshot captures a LoadedJsSandbox, not a JsSandbox")); +} + +#[test] +fn persists_loaded_state_and_preserves_unload() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + + let snapshot = loaded.snapshot().unwrap(); + snapshot + .save(directory.path(), &oci_tag("application-ready")) + .unwrap(); + + let snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("application-ready")) + .unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 2); + + let mut sandbox = loaded.unload().unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn loaded_snapshot_can_be_resaved_to_another_layout() { + let first_directory = tempfile::tempdir().unwrap(); + let second_directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + + let snapshot = loaded.snapshot().unwrap(); + let first_digest = snapshot + .save(first_directory.path(), &oci_tag("application-ready")) + .unwrap(); + let snapshot = + Snapshot::::checked_load(first_directory.path(), first_digest.clone()) + .unwrap(); + let second_digest = snapshot + .save(second_directory.path(), &oci_tag("application-copy")) + .unwrap(); + + assert_eq!(second_digest, first_digest); + + let snapshot = + Snapshot::::checked_load(second_directory.path(), second_digest).unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 2); + + let mut sandbox = loaded.unload().unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn loaded_snapshot_rejects_tag_that_would_collide_after_truncation() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let snapshot = loaded.snapshot().unwrap(); + let tag = oci_tag(&"a".repeat(119)); + + let error = snapshot.save(directory.path(), &tag).unwrap_err(); + + assert!(error.to_string().contains("must be at most 118 bytes")); + assert!(!directory.path().join("index.json").exists()); +} + +#[test] +fn js_sandbox_snapshot_rejects_staged_sources_over_config_limit() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + let padding = "x".repeat(1024 * 1024); + sandbox + .add_handler( + "large", + Script::from_content(format!( + "const padding = '{padding}';\nfunction handler(event) {{ return event; }}" + )), + ) + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let error = snapshot + .save(directory.path(), &oci_tag("large")) + .unwrap_err(); + + assert!(error.to_string().contains("config blob")); + assert!(!directory.path().join("index.json").exists()); +} + +#[test] +fn snapshots_reject_reserved_baseline_tag_suffix() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let js_snapshot = sandbox.snapshot().unwrap(); + let loaded_snapshot = sandbox.get_loaded_sandbox().unwrap().snapshot().unwrap(); + let tag = oci_tag("application.hljs-base"); + + let js_error = js_snapshot.save(directory.path(), &tag).unwrap_err(); + let loaded_error = loaded_snapshot.save(directory.path(), &tag).unwrap_err(); + + assert!(js_error.to_string().contains("reserved")); + assert!(loaded_error.to_string().contains("reserved")); + assert!(!directory.path().join("index.json").exists()); +} + +#[test] +fn persisted_js_sandbox_restores_embedded_javascript_modules() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox + .add_module( + "math", + Script::from_content("export function double(value) { return value * 2; }"), + ) + .unwrap(); + sandbox + .add_handler( + "double", + Script::from_content( + r#" + import { double } from "user:math"; + function handler(event) { return { value: double(event.value) }; } + "#, + ), + ) + .unwrap(); + + let snapshot = sandbox.snapshot().unwrap(); + snapshot + .save(directory.path(), &oci_tag("embedded-js")) + .unwrap(); + let snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("embedded-js")).unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot) + .build() + .unwrap() + .get_loaded_sandbox() + .unwrap(); + + let result = loaded + .handle_event("double", r#"{"value":21}"#.to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":42}"#); +} diff --git a/src/hyperlight-js/tests/user_modules.rs b/src/hyperlight-js/tests/user_modules.rs index f0b02189..544f3a78 100644 --- a/src/hyperlight-js/tests/user_modules.rs +++ b/src/hyperlight-js/tests/user_modules.rs @@ -18,7 +18,7 @@ limitations under the License. //! These tests exercise the full lifecycle: host-side registration → guest-side //! lazy compilation → handler import → execution. -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{HostFunctionModule, SandboxBuilder, Script}; // ── Basic import ───────────────────────────────────────────────────── @@ -523,13 +523,14 @@ fn user_module_can_import_host_function() { "#, ); - let mut proto = SandboxBuilder::new().build().unwrap(); - proto - .register( - "db", - "lookup", - |id: i32| serde_json::json!({ "id": id, "name": format!("User {}", id) }), - ) + let mut db = HostFunctionModule::new("db"); + db.register( + "lookup", + |id: i32| serde_json::json!({ "id": id, "name": format!("User {}", id) }), + ); + let proto = SandboxBuilder::new() + .with_host_function_modules([db]) + .build() .unwrap(); let mut sandbox = proto.load_runtime().unwrap(); @@ -834,11 +835,9 @@ fn external_text_payloads_preserve_capacity_across_buffer_sizes() { assert_eq!(config.get_h2g_pool_pages(), input_pages); assert_eq!(config.get_g2h_pool_pages(), output_pages); - let mut proto = builder.build().unwrap(); - - proto - .register("host", "echo", |value: String| value) - .unwrap(); + let mut host = HostFunctionModule::new("host"); + host.register("echo", |value: String| value); + let proto = builder.with_host_function_modules([host]).build().unwrap(); let mut sandbox = proto.load_runtime().unwrap(); sandbox diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index ddda059b..73561289 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -59,8 +59,12 @@ Creates and configures a new sandbox. - `setOutputTransportBufferSize(bytes: number)` → `this` — Set the capacity of each large guest-to-host transport buffer (must be > 0, chainable) - `setInputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy input/host-reply capacity in bytes; use `setInputTransportPoolPages` and `setOutputTransportPoolPages` for explicit pool budgets - `setOutputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy output/host-request capacity in bytes; use `setOutputTransportPoolPages` +- `setHostFunctionModules(modules: HostFunctionModule[])` → `this` — Supply host callbacks for one sandbox. Each module is consumed - `build()` → `Promise` — Builds a proto sandbox ready to load the JavaScript runtime +**Static methods:** +- `SandboxBuilder.fromSnapshot(snapshot)` → `SandboxBuilder` — Creates a builder whose `build()` returns the `JSSandbox` or `LoadedJSSandbox` the snapshot captured. The snapshot's memory layout overrides heap, scratch, and transport settings. + ```javascript const builder = new SandboxBuilder() .setHeapSize(8 * 1024 * 1024) @@ -74,21 +78,22 @@ const protoSandbox = await builder.build(); ### ProtoJSSandbox -A proto sandbox ready to load the JavaScript runtime. This is also where -you register **host functions** — callbacks that guest sandboxed code can -call. See [Host Functions](#host-functions) below. +A proto sandbox is a fresh sandbox ready to load the JavaScript runtime. +Configure host functions on `SandboxBuilder` before calling `build()`. **Methods:** -- `loadRuntime()` → `Promise` — Loads the JavaScript runtime into the sandbox. All host functions registered via `hostModule()` / `register()` are applied before the runtime loads. -- `hostModule(name: string)` → `HostModule` — Create a builder for registering functions in a named module -- `register(moduleName, functionName, callback)` — Convenience method to register a single host function (args are spread, return value auto-stringified) +- `loadRuntime()` → `Promise` — Loads the JavaScript runtime into the sandbox. +- `hostModule()` / `register()` — **Deprecated:** use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. ```javascript -// Register host functions, then load the runtime -const math = protoSandbox.hostModule('math'); -math.register('add', (a, b) => a + b); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + return [math]; +} -const jsSandbox = await protoSandbox.loadRuntime(); +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); +const jsSandbox = await proto.loadRuntime(); ``` ### JSSandbox @@ -107,6 +112,13 @@ A sandbox with the JavaScript runtime loaded, ready for handlers and modules. **Other Methods:** - `getLoadedSandbox()` → `Promise` — Registers all module sources and compiles/loads handlers into the guest; modules are compiled on-demand when first imported. +- `snapshot()` → `Promise` — Captures runtime state and staged definitions. +- `restore(snapshot)` → `Promise` — Restores runtime state and staged definitions. + +While `snapshot()` or `restore()` is running, synchronous methods and properties +fail immediately with `ERR_BUSY`. They never wait on the background VM operation +and therefore do not block the Node.js event loop. Await the operation before +accessing the same `JSSandbox` again. ```javascript // Add a module (sync) @@ -289,7 +301,9 @@ try { ### Snapshot -An opaque handle representing a point-in-time snapshot of the sandbox state. Use `snapshot()` to capture and `restore()` to roll back after a poisoned state or any other reason. +An immutable point-in-time snapshot of a `JSSandbox` or `LoadedJSSandbox`. +Use `restore()` for fast in-memory rollback, or save the snapshot to an OCI +image layout for later or cross-process restoration. ```javascript const snapshot = await loaded.snapshot(); @@ -300,6 +314,26 @@ await loaded.restore(snapshot); console.log(loaded.status); // "ready" ``` +```javascript +await snapshot.save('./snapshots', 'application-ready'); + +// Later, potentially in another process. +const persisted = await Snapshot.checkedLoad('./snapshots', 'application-ready'); +const restored = await SandboxBuilder.fromSnapshot(persisted) + .setHostFunctionModules(hostFunctionModules()) + .build(); +``` + +`Snapshot.checkedLoad()` checks every blob against its recorded digest. +`Snapshot.load()` skips the check. Use it only for trusted local layouts. + +The same workflow restores a `JSSandbox` snapshot taken before handlers are +loaded. Native callbacks are not serialized, so the restoring builder must +receive fresh, compatible `HostFunctionModule`s. Restoring a +snapshot of the other sandbox type into an existing sandbox fails with +`ERR_INVALID_ARG`. +See [Persistent snapshots](../../docs/persistent-snapshots.md). + ### Error Codes All errors thrown by the API include a `code` property for programmatic handling: @@ -311,6 +345,7 @@ All errors thrown by the API include a `code` property for programmatic handling | `ERR_POISONED` | Sandbox is in an inconsistent state (after timeout kill, guest abort, stack overflow, etc.) — restore from snapshot or unload | | `ERR_CANCELLED` | Execution was cancelled (by monitor timeout or manual `kill()`) | | `ERR_GUEST_ABORT` | Guest code aborted | +| `ERR_BUSY` | A `JSSandbox` method was called while its `snapshot()` or `restore()` was still running | | `ERR_INTERNAL` | Unexpected internal error | ```javascript @@ -344,9 +379,9 @@ sequenceDiagram participant Bridge as NAPI Bridge participant Host as Host Callback (Node.js) - Note over Guest,Host: Registration (before loadRuntime) - Host->>Bridge: proto.hostModule('math').register('add', callback) - Bridge->>HL: Stores closure in HostModule + Note over Guest,Host: Builder configuration + Host->>Bridge: builder.setHostFunctionModules(hostFunctionModules()) + Bridge->>HL: Installs callback closures Note over Guest,Host: Invocation (during callHandler) Guest->>HL: math.add(1, 2) @@ -358,21 +393,22 @@ sequenceDiagram HL-->>Guest: 3 ``` -1. **Register** host functions on the `ProtoJSSandbox` (before loading the runtime) +1. **Define** a function that creates fresh `HostFunctionModule` objects and supply its result to `SandboxBuilder` 2. **Guest code** imports them as ES modules: `import * as math from "host:math"` 3. **At call time**, the guest's arguments are JSON-serialised and dispatched to the Node.js main thread via a threadsafe function (V8 is single-threaded, so callbacks *must* execute there). The JSON result is then returned to the guest ### Quick Start ```javascript -const { SandboxBuilder } = require('@hyperlight-dev/js-host-api'); - -const proto = await new SandboxBuilder().build(); +const { HostFunctionModule, SandboxBuilder } = require('@hyperlight-dev/js-host-api'); -// Register a sync host function — args are spread, return auto-stringified -proto.hostModule('math').register('add', (a, b) => a + b); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + return [math]; +} -// Load the runtime (applies all registrations) +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const sandbox = await proto.loadRuntime(); // Guest code can now call math.add() @@ -424,39 +460,37 @@ db.register('query', (table) => ({ ### API Reference -#### `proto.hostModule(name)` → `HostModule` +#### `new HostFunctionModule(name)` Creates a builder for a named module. The module name is what guest code uses in its `import` statement. ```javascript -const math = proto.hostModule('math'); +const math = new HostFunctionModule('math'); // Guest: import * as math from "host:math"; ``` Throws `ERR_INVALID_ARG` if name is empty. -#### `builder.register(name, callback)` → `void` +#### `module.register(name, callback)` → `HostFunctionModule` Registers a function within the module. Arguments are auto-parsed from the guest's JSON array and spread into your callback. The return value is automatically `JSON.stringify`'d. ```javascript -const math = proto.hostModule('math'); +const math = new HostFunctionModule('math'); math.register('add', (a, b) => a + b); math.register('multiply', (a, b) => a * b); ``` Throws `ERR_INVALID_ARG` if function name is empty. -#### `proto.register(moduleName, functionName, callback)` - -Convenience shorthand — equivalent to `proto.hostModule(moduleName).register(functionName, callback)`. - -```javascript -proto.register('strings', 'upper', (s) => s.toUpperCase()); -``` +Supply modules with `builder.setHostFunctionModules(hostFunctionModules())`. +The builder consumes them. Call `hostFunctionModules()` again for each fresh or +restored builder. Create mutable state inside the function to isolate it per +sandbox. State captured from an outer scope is shared between every sandbox +using those modules. ### Async Callbacks @@ -464,7 +498,8 @@ Host function callbacks can be `async` or return a `Promise`. The bridge automatically awaits the result before returning to the guest: ```javascript -proto.hostModule('api').register('fetchUser', async (userId) => { +const api = new HostFunctionModule('api'); +api.register('fetchUser', async (userId) => { const response = await fetch(`https://api.example.com/users/${userId}`); return await response.json(); }); @@ -501,25 +536,32 @@ and arrays are also extracted into the binary sidecar and restored on the guest side. ```javascript -const { SandboxBuilder } = require('@hyperlight/js-host-api'); +const { HostFunctionModule, SandboxBuilder } = require('@hyperlight/js-host-api'); const { createHash } = require('crypto'); const zlib = require('zlib'); -const proto = await new SandboxBuilder().build(); +function hostFunctionModules() { + // Buffer arguments: guest Uint8Array → host Buffer + const crypto = new HostFunctionModule('crypto'); + crypto.register('sha256', (data) => { + // data is a Node.js Buffer + return createHash('sha256').update(data).digest(); // returns Buffer + }); -// Buffer arguments: guest Uint8Array → host Buffer -proto.hostModule('crypto').register('sha256', (data) => { - // data is a Node.js Buffer - return createHash('sha256').update(data).digest(); // returns Buffer -}); + // Mixed args: regular values and Buffers together + const io = new HostFunctionModule('io'); + io.register('compress', (algorithm, data) => { + // algorithm is a string, data is a Buffer + if (algorithm === 'gzip') return zlib.gzipSync(data); + return data; + }); -// Mixed args: regular values and Buffers together -proto.hostModule('io').register('compress', (algorithm, data) => { - // algorithm is a string, data is a Buffer - if (algorithm === 'gzip') return zlib.gzipSync(data); - return data; -}); + return [crypto, io]; +} +const proto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules()) + .build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` import * as crypto from "host:crypto"; @@ -561,7 +603,8 @@ If your callback throws (sync) or rejects (async), the error propagates to the guest as a `HostFunctionError`: ```javascript -proto.hostModule('auth').register('validate', (token) => { +const auth = new HostFunctionModule('auth'); +auth.register('validate', (token) => { if (!token) { throw new Error('Token is required'); } @@ -583,35 +626,42 @@ function handler(event) { ### Registration Timing -Host functions must be registered **before** calling `loadRuntime()`. -Registrations are accumulated and applied in bulk when the runtime loads. +Host-function modules must be supplied to the builder before `build()`. ```javascript -const proto = await new SandboxBuilder().build(); - -// ✅ Register before loadRuntime() -proto.hostModule('math').register('add', (a, b) => a + b); -proto.hostModule('strings').register('upper', (s) => s.toUpperCase()); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + const strings = new HostFunctionModule('strings'); + strings.register('upper', (s) => s.toUpperCase()); + return [math, strings]; +} -const sandbox = await proto.loadRuntime(); // all registrations applied here +const proto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules()) + .build(); +const sandbox = await proto.loadRuntime(); ``` ### Snapshot / Restore -Host functions survive snapshot/restore cycles. The snapshot captures the -guest micro-VM's memory — the host-side JS callbacks live in the Node.js -process and are unaffected by restore: +Host callbacks remain attached during in-memory restore because the same host +process and sandbox are reused. Persistent snapshots do not serialize callbacks; +supply fresh modules to the restoring builder as shown above. ```javascript -// Host-side counter — lives in Node.js, outside the micro-VM -let callCount = 0; - -const proto = await new SandboxBuilder().build(); -proto.register('stats', 'hit', () => { - callCount++; - return callCount; -}); +function hostFunctionModules() { + // This counter belongs to one sandbox because each call creates it. + let callCount = 0; + const stats = new HostFunctionModule('stats'); + stats.register('hit', () => { + callCount++; + return callCount; + }); + return [stats]; +} +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` import * as stats from "host:stats"; @@ -703,7 +753,7 @@ Module imports use the format `:`: |-----------|-----------|----------------| | `user` | `addModule()` (default) | `import { add } from 'user:math'` | | Custom | `addModule(name, source, namespace)` | `import { greet } from 'mylib:utils'` | -| `host` | `hostModule().register()` (reserved) | `import * as db from 'host:db'` | +| `host` | `HostFunctionModule` (reserved) | `import * as db from 'host:db'` | The `host` namespace is reserved — you cannot use it for user modules. @@ -821,15 +871,18 @@ Timeout-based handler termination using wall-clock timeout. Demonstrates killing Combined CPU + wall-clock monitoring — the recommended pattern for comprehensive resource protection. Demonstrates OR semantics where the CPU monitor fires first for compute-bound work, with wall-clock as backstop. ### Host Functions (`host-functions.js`) -Registering sync and async host functions that guest code can call. Demonstrates -`hostModule().register()` with spread args, `async` callbacks, and the convenience -`register()` API. +Reusable sync and async `HostFunctionModule` objects supplied through +`SandboxBuilder`, with spread arguments and Promise-returning callbacks. ### User Modules (`user-modules.js`) Registering reusable ES modules that handlers can import. Demonstrates inter-module dependencies, custom namespaces, multiple handlers sharing a module, and **cross-handler mutable state sharing** via a shared counter module. +### Persistent Snapshots (`persistent-snapshot.js`) +Separate write/read processes using the same reusable host-function module +definition. + ## Requirements - **Node.js** >= 18 diff --git a/src/js-host-api/examples/README.md b/src/js-host-api/examples/README.md index 2b541582..d526357c 100644 --- a/src/js-host-api/examples/README.md +++ b/src/js-host-api/examples/README.md @@ -117,6 +117,34 @@ Testing calculator operations: === All tests passed! === ``` +### Host Functions + +Defines a function that creates fresh `HostFunctionModule` objects for +each `SandboxBuilder` before loading the guest runtime: + +```bash +node host-functions.js +``` + +### User Modules + +Demonstrates embedded JavaScript modules, custom namespaces, dependencies, and +shared module state: + +```bash +node user-modules.js +``` + +### Persistent Snapshots + +Writes and restores a loaded snapshot in separate processes. Both processes +construct the same reusable host-function module catalog: + +```bash +node persistent-snapshot.js write ../../target/persistent-node-snapshot +node persistent-snapshot.js read ../../target/persistent-node-snapshot +``` + ## API Overview ### SandboxBuilder diff --git a/src/js-host-api/examples/host-functions.js b/src/js-host-api/examples/host-functions.js index 3cff6610..eda8dddb 100644 --- a/src/js-host-api/examples/host-functions.js +++ b/src/js-host-api/examples/host-functions.js @@ -4,28 +4,15 @@ // - Sync host functions (immediate return) // - Async host functions (Promise-returning) // - Multiple modules and functions -// - HostModule API and convenience register() API +// - Reusable host-function module definitions with per-sandbox state -const { SandboxBuilder } = require('../lib.js'); +const { HostFunctionModule, SandboxBuilder } = require('../lib.js'); -async function main() { - console.log('=== Hyperlight JS Host Functions ===\n'); - - // ── Build the proto sandbox ────────────────────────────────────── - console.log('1. Creating sandbox...'); - const proto = await new SandboxBuilder() - .setHeapSize(8 * 1024 * 1024) - .setScratchSize(1024 * 1024) - .build(); - console.log(' ✓ Proto sandbox created\n'); - - // ── Register host functions (sync, spread args) ────────────────── +function hostFunctionModules() { + // ── Define fresh host-function modules ─────────────────────────── // Guest JS imports these as: import * as math from "host:math" // Args are auto-parsed from JSON and spread; return value auto-stringified. - console.log('2. Registering host functions...'); - - const math = proto.hostModule('math'); - + const math = new HostFunctionModule('math'); math.register('add', (a, b) => a + b); math.register('multiply', (a, b) => a * b); @@ -33,17 +20,32 @@ async function main() { // Async callbacks are automatically awaited by the bridge. // The guest call still blocks (Hyperlight is sync), but the host // can do async work (DB queries, HTTP, file I/O, etc.) - proto.hostModule('greetings').register('hello', async (name) => { + const greetings = new HostFunctionModule('greetings'); + greetings.register('hello', async (name) => { // Simulate async work (e.g. looking up a name in a database) await new Promise((resolve) => setTimeout(resolve, 50)); return `Hello, ${name}! 👋`; }); - // ── Convenience API: register(module, name, callback) ──────────── - proto.register('strings', 'upper', (s) => s.toUpperCase()); + const strings = new HostFunctionModule('strings'); + strings.register('upper', (s) => s.toUpperCase()); + return [math, greetings, strings]; +} + +async function main() { + console.log('=== Hyperlight JS Host Functions ===\n'); + console.log('2. Registering host functions...'); console.log(' ✓ Host functions registered\n'); + // Call hostFunctionModules() again for another builder. Each call creates + // fresh module objects, callbacks, and captured state. + const proto = await new SandboxBuilder() + .setHeapSize(8 * 1024 * 1024) + .setScratchSize(1024 * 1024) + .setHostFunctionModules(hostFunctionModules()) + .build(); + // ── Load runtime and add a handler ─────────────────────────────── console.log('3. Loading runtime...'); const sandbox = await proto.loadRuntime(); diff --git a/src/js-host-api/examples/persistent-snapshot.js b/src/js-host-api/examples/persistent-snapshot.js new file mode 100644 index 00000000..42f517bf --- /dev/null +++ b/src/js-host-api/examples/persistent-snapshot.js @@ -0,0 +1,72 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const { HostFunctionModule, SandboxBuilder, Snapshot } = require('../lib.js'); + +const command = process.argv[2] ?? 'write'; +const path = process.argv[3] ?? '../../target/persistent-node-snapshot'; + +function hostFunctionModules() { + const counter = new HostFunctionModule('counter'); + counter.register('step', (value) => value + 1); + return [counter]; +} + +async function writeSnapshot() { + const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + import * as counter from "host:counter"; + let count = 0; + function handler(event) { + count = counter.step(count); + event.count = count; + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + console.log('Before snapshot:', await loaded.callHandler('counter', {})); + + const snapshot = await loaded.snapshot(); + const digest = await snapshot.save(path, 'application-ready'); + console.log(`Saved application-ready as ${digest}`); + await loaded.dispose(); +} + +async function readSnapshot() { + const snapshot = await Snapshot.checkedLoad(path, 'application-ready'); + const loaded = await SandboxBuilder.fromSnapshot(snapshot) + .setHostFunctionModules(hostFunctionModules()) + .build(); + console.log('After restore:', await loaded.callHandler('counter', {})); + await loaded.dispose(); +} + +if (command === 'write') { + writeSnapshot().catch(fail); +} else if (command === 'read') { + readSnapshot().catch(fail); +} else { + fail(new Error(`Unknown command '${command}'; expected 'write' or 'read'`)); +} + +function fail(error) { + console.error(error); + process.exitCode = 1; +} diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index b55ecd3e..68da226b 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -117,7 +117,15 @@ function wrapSync(fn) { // cached by require(), so prototypes are patched once per process, after // this module has been required at least once. -const { LoadedJSSandbox, JSSandbox, ProtoJSSandbox, SandboxBuilder, HostModule } = native; +const { + LoadedJSSandbox, + JSSandbox, + ProtoJSSandbox, + SandboxBuilder, + Snapshot, + HostFunctionModule, + HostModule, +} = native; /** * Wrap a getter so that thrown errors have enriched codes. @@ -154,7 +162,11 @@ wrapGetter(LoadedJSSandbox, 'interruptHandle'); wrapGetter(LoadedJSSandbox, 'lastCallStats'); // JSSandbox — async + sync methods + getters -JSSandbox.prototype.getLoadedSandbox = wrapAsync(JSSandbox.prototype.getLoadedSandbox); +for (const method of ['getLoadedSandbox', 'snapshot', 'restore']) { + const orig = JSSandbox.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`); + JSSandbox.prototype[method] = wrapAsync(orig); +} for (const method of [ 'addHandler', @@ -169,10 +181,15 @@ for (const method of [ if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`); JSSandbox.prototype[method] = wrapSync(orig); } +wrapGetter(JSSandbox, 'status'); wrapGetter(JSSandbox, 'poisoned'); -// ProtoJSSandbox — async + sync methods -ProtoJSSandbox.prototype.loadRuntime = wrapAsync(ProtoJSSandbox.prototype.loadRuntime); +// ProtoJSSandbox — fresh runtime construction +for (const method of ['loadRuntime']) { + const orig = ProtoJSSandbox.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: ProtoJSSandbox.${method}`); + ProtoJSSandbox.prototype[method] = wrapAsync(orig); +} // hostModule() is sync — just wrap for error enrichment ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModule); @@ -189,11 +206,11 @@ ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModu }); } -// HostModule — register() with Buffer support -{ - const origRegister = HostModule.prototype.register; - if (!origRegister) throw new Error('Cannot wrap missing method: HostModule.register'); - HostModule.prototype.register = wrapSync(function (name, callback) { +// Host-function modules — register() with Buffer support +function wrapHostFunctionModule(cls) { + const origRegister = cls.prototype.register; + if (!origRegister) throw new Error(`Cannot wrap missing method: ${cls.name}.register`); + cls.prototype.register = wrapSync(function (name, callback) { // Wrap the callback to handle Buffer returns. // Args: Rust creates native Buffer objects directly via the // NAPI C API — no conversion needed on the JS side. @@ -220,9 +237,54 @@ ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModu ); }); } +wrapHostFunctionModule(HostFunctionModule); +wrapHostFunctionModule(HostModule); -// SandboxBuilder — async build + sync setters +const HostFunctionModuleExport = new Proxy(HostFunctionModule, { + construct(target, args, newTarget) { + try { + return Reflect.construct(target, args, newTarget); + } catch (err) { + throw enrichError(err); + } + }, +}); +native.HostFunctionModule = HostFunctionModuleExport; +native.HostFunctionModuleWrapper = HostFunctionModuleExport; + +/** + * Replace a native class export with one whose static factories have + * enriched errors. Native static properties cannot be reassigned. + * + * @param {Function} cls — the native class + * @param {Object} statics — wrapped static methods keyed by name + * @returns {Function} the class to export + */ +function withWrappedStatics(cls, statics) { + function ClassExport(...args) { + return Reflect.construct(cls, args, cls); + } + ClassExport.prototype = cls.prototype; + Object.setPrototypeOf(ClassExport, cls); + Object.defineProperties( + ClassExport, + Object.fromEntries(Object.entries(statics).map(([name, value]) => [name, { value }])) + ); + return ClassExport; +} + +// SandboxBuilder — async build, static snapshot factory, sync setters SandboxBuilder.prototype.build = wrapAsync(SandboxBuilder.prototype.build); +native.SandboxBuilder = withWrappedStatics(SandboxBuilder, { + fromSnapshot: wrapSync(SandboxBuilder.fromSnapshot.bind(SandboxBuilder)), +}); + +// Snapshot — async persistence and static loaders +Snapshot.prototype.save = wrapAsync(Snapshot.prototype.save); +native.Snapshot = withWrappedStatics(Snapshot, { + load: wrapAsync(Snapshot.load.bind(Snapshot)), + checkedLoad: wrapAsync(Snapshot.checkedLoad.bind(Snapshot)), +}); for (const method of [ 'setHeapSize', @@ -233,6 +295,7 @@ for (const method of [ 'setOutputTransportBufferSize', 'setInputBufferSize', 'setOutputBufferSize', + 'setHostFunctionModules', ]) { const orig = SandboxBuilder.prototype[method]; if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`); diff --git a/src/js-host-api/lib.mjs b/src/js-host-api/lib.mjs index da04dc17..291f7f4c 100644 --- a/src/js-host-api/lib.mjs +++ b/src/js-host-api/lib.mjs @@ -21,20 +21,15 @@ limitations under the License. import native from './lib.js'; export const { + HostFunctionModule, HostModule, - HostModuleWrapper, InterruptHandle, - InterruptHandleWrapper, JSSandbox, - JSSandboxWrapper, LoadedJSSandbox, - LoadedJSSandboxWrapper, ProtoJSSandbox, - ProtoJSSandboxWrapper, SandboxBuilder, - SandboxBuilderWrapper, + SandboxStatus, Snapshot, - SnapshotWrapper, } = native; export default native; diff --git a/src/js-host-api/package.json b/src/js-host-api/package.json index cf6134c1..04174180 100644 --- a/src/js-host-api/package.json +++ b/src/js-host-api/package.json @@ -54,15 +54,16 @@ "node": ">= 18" }, "scripts": { - "build": "napi build --platform --release", - "build:debug": "napi build --platform", + "build": "napi build --platform --release && npm run normalize:types", + "build:debug": "napi build --platform && npm run normalize:types", + "normalize:types": "node scripts/normalize-generated-types.mjs", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint lib.js tests/ examples/", - "lint:fix": "eslint lib.js tests/ examples/ --fix", - "fmt": "prettier --write \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"*.config.{js,mjs}\"", - "fmt:check": "prettier --check \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"*.config.{js,mjs}\"", - "prepublishOnly": "napi prepublish -t npm --skip-optional-publish", + "lint": "eslint lib.js tests/ examples/ scripts/", + "lint:fix": "eslint lib.js tests/ examples/ scripts/ --fix", + "fmt": "prettier --write \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"scripts/**/*.mjs\" \"*.config.{js,mjs}\"", + "fmt:check": "prettier --check \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"scripts/**/*.mjs\" \"*.config.{js,mjs}\"", + "prepublishOnly": "napi prepublish -t npm --skip-optional-publish && npm run normalize:types", "artifacts": "napi artifacts", "version": "napi version" } diff --git a/src/js-host-api/scripts/normalize-generated-types.mjs b/src/js-host-api/scripts/normalize-generated-types.mjs new file mode 100644 index 00000000..b50a591c --- /dev/null +++ b/src/js-host-api/scripts/normalize-generated-types.mjs @@ -0,0 +1,55 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { readFile, writeFile } from 'node:fs/promises'; +import { URL } from 'node:url'; + +const declarationsPath = new URL('../index.d.ts', import.meta.url); +const publicTypes = [ + 'HostFunctionModule', + 'HostModule', + 'InterruptHandle', + 'JSSandbox', + 'LoadedJSSandbox', + 'ProtoJSSandbox', + 'SandboxBuilder', + 'Snapshot', +]; + +let declarations = await readFile(declarationsPath, 'utf8'); +// Remove every alias first. Renaming `JSSandboxWrapper` would otherwise also +// rewrite `LoadedJSSandboxWrapper` before its own alias is removed. +for (const publicType of publicTypes) { + declarations = declarations.replace( + new RegExp(`^export type ${publicType}Wrapper = ${publicType}\\r?\\n`, 'm'), + '' + ); +} +for (const publicType of publicTypes) { + declarations = declarations.replace(new RegExp(`\\b${publicType}Wrapper\\b`, 'g'), publicType); +} + +// `build()` returns the sandbox type selected by `new SandboxBuilder()` or +// `SandboxBuilder.fromSnapshot()`. napi cannot declare class type parameters. +const builderClass = /^export declare class SandboxBuilder \{/m; +if (!builderClass.test(declarations)) { + throw new Error('Generated declarations do not declare class SandboxBuilder'); +} +declarations = declarations.replace( + builderClass, + 'export declare class SandboxBuilder {' +); +await writeFile(declarationsPath, declarations); diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index f5137381..94203f37 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -19,13 +19,14 @@ use std::time::Duration; use arc_swap::ArcSwapOption; use hyperlight_js::{ - CpuTimeMonitor, ExecutionStats, HyperlightError, InterruptHandle, JSSandbox, LoadedJSSandbox, - ProtoJSSandbox, SandboxBuilder, SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, - WallClockMonitor, + AnySnapshot, CpuTimeMonitor, ExecutionStats, HostFunctionModule, HyperlightError, + InterruptHandle, JSSandbox, LoadedJSSandbox, OciReference, OciTag, ProtoJSSandbox, + SandboxBuilder, SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, WallClockMonitor, }; use hyperlight_js_common::Bytes; use napi::bindgen_prelude::{ - Buffer, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, ToNapiValue, + Buffer, ClassInstance, Either3, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, + ToNapiValue, }; use napi::sys::{napi_env, napi_value}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; @@ -175,6 +176,8 @@ enum ErrorCode { Internal, /// Reentrant call detected — calling sandbox methods from a host callback. Reentrant, + /// Sandbox is busy with a background snapshot or restore operation. + Busy, } impl ErrorCode { @@ -188,6 +191,7 @@ impl ErrorCode { Self::Consumed => "ERR_CONSUMED", Self::Internal => "ERR_INTERNAL", Self::Reentrant => "ERR_REENTRANT", + Self::Busy => "ERR_BUSY", } } } @@ -255,7 +259,7 @@ fn consumed_error(type_name: &str) -> napi::Error { } /// Creates an error for invalid argument conditions. -fn invalid_arg_error(msg: &str) -> napi::Error { +fn invalid_arg_error(msg: impl std::fmt::Display) -> napi::Error { hl_error(ErrorCode::InvalidArg, msg) } @@ -288,14 +292,14 @@ fn validate_napi_module_identifier(value: &str, label: &str) -> napi::Result<()> // its errors through invalid_arg_error so callers consistently receive // ERR_INVALID_ARG rather than generic ERR_INTERNAL codes. if value.len() > MAX_MODULE_IDENTIFIER_LEN { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "{label} must not exceed {MAX_MODULE_IDENTIFIER_LEN} bytes" ))); } hyperlight_js::validate_module_identifier(value, label) - .map_err(|e| invalid_arg_error(&e.to_string()))?; + .map_err(|e| invalid_arg_error(e.to_string()))?; if value.chars().any(|c| c.is_control()) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "{label} must not contain control characters" ))); } @@ -307,7 +311,7 @@ fn validate_napi_module_identifier(value: &str, label: &str) -> napi::Result<()> /// Uses the shared [`hyperlight_js::RESERVED_NAMESPACES`] list. fn validate_napi_namespace_not_reserved(namespace: &str) -> napi::Result<()> { hyperlight_js::validate_namespace_not_reserved(namespace) - .map_err(|e| invalid_arg_error(&e.to_string())) + .map_err(|e| invalid_arg_error(e.to_string())) } /// Creates an error when a Mutex is poisoned (Rust-level, not sandbox-level). @@ -335,13 +339,39 @@ fn reentrant_error() -> napi::Error { ) } +fn busy_error() -> napi::Error { + hl_error( + ErrorCode::Busy, + "JSSandbox is busy with a snapshot or restore operation; wait for it to complete", + ) +} + +struct JSSandboxBusyGuard { + flag: Arc, +} + +impl JSSandboxBusyGuard { + fn acquire(flag: Arc) -> napi::Result { + flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| busy_error())?; + Ok(Self { flag }) + } +} + +impl Drop for JSSandboxBusyGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::Release); + } +} + // ── Snapshot ───────────────────────────────────────────────────────── -/// A captured point-in-time state of a sandbox. +/// A captured point-in-time state of a `JSSandbox` or `LoadedJSSandbox`. /// /// Take a snapshot before risky operations (e.g., running untrusted code /// with monitors). If the sandbox becomes poisoned, restore from the -/// snapshot to recover. +/// snapshot to recover. Restore a saved snapshot into a new sandbox with +/// `SandboxBuilder.fromSnapshot()`. /// /// ```js /// const snapshot = await loaded.snapshot(); @@ -353,25 +383,106 @@ fn reentrant_error() -> napi::Error { /// ``` #[napi(js_name = "Snapshot")] pub struct SnapshotWrapper { - inner: Arc, + inner: AnySnapshot, +} + +fn parse_reference(reference: &str) -> napi::Result { + reference + .parse() + .map_err(|error: HyperlightError| invalid_arg_error(error)) +} + +#[napi] +impl SnapshotWrapper { + /// Save this snapshot to an OCI image layout and return its manifest digest. + /// + /// A `LoadedJSSandbox` snapshot also saves its runtime-ready baseline under + /// `.hljs-base`, which limits `tag` to 118 bytes. + #[napi] + pub async fn save(&self, path: String, tag: String) -> napi::Result { + let tag = OciTag::new(tag).map_err(invalid_arg_error)?; + let snapshot = self.inner.clone(); + tokio::task::spawn_blocking(move || { + snapshot + .save(path, &tag) + .map(|digest| digest.to_string()) + .map_err(to_napi_error) + }) + .await + .map_err(join_error)? + } + + /// Load a snapshot from an OCI image layout without checking blob digests. + /// + /// The layout's blobs stay memory-mapped while the snapshot or any sandbox + /// built from it is alive. Overwriting, truncating, or deleting them during + /// that time can corrupt guest memory. + #[napi(factory)] + pub async fn load(path: String, reference: String) -> napi::Result { + let reference = parse_reference(&reference)?; + let snapshot = tokio::task::spawn_blocking(move || { + AnySnapshot::load(path, reference).map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(Self { inner: snapshot }) + } + + /// Load a snapshot like `load()` and check every blob against its recorded + /// sha256 digest. + /// + /// A digest check does not prove the bytes are authentic. Load only from a + /// trusted source. + #[napi(factory)] + pub async fn checked_load(path: String, reference: String) -> napi::Result { + let reference = parse_reference(&reference)?; + let snapshot = tokio::task::spawn_blocking(move || { + AnySnapshot::checked_load(path, reference).map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(Self { inner: snapshot }) + } } // ── SandboxBuilder ─────────────────────────────────────────────────── -/// Configures and creates a new sandbox. +/// The sandbox a `SandboxBuilder` creates. +enum BuilderState { + New(SandboxBuilder), + JsSandbox(SandboxBuilder>), + LoadedJsSandbox(SandboxBuilder>), +} + +/// Apply one builder setter to whichever sandbox the builder creates. +macro_rules! map_builder { + ($state:expr, $builder:ident => $body:expr) => { + match $state { + BuilderState::New($builder) => BuilderState::New($body), + BuilderState::JsSandbox($builder) => BuilderState::JsSandbox($body), + BuilderState::LoadedJsSandbox($builder) => BuilderState::LoadedJsSandbox($body), + } + }; +} + +/// Configures and creates a new sandbox, or restores one from a snapshot. /// /// Use the builder to set memory limits before constructing the sandbox. -/// All size setters support method chaining. +/// All size setters support method chaining. A snapshot's memory layout +/// overrides heap, scratch, and transport settings. /// /// ```js /// const proto = await new SandboxBuilder() /// .setHeapSize(8 * 1024 * 1024) /// .setScratchSize(1024 * 1024) /// .build(); +/// +/// const snapshot = await Snapshot.checkedLoad('./snapshots', 'app'); +/// const restored = await SandboxBuilder.fromSnapshot(snapshot).build(); /// ``` #[napi(js_name = "SandboxBuilder")] pub struct SandboxBuilderWrapper { - inner: Arc>>, + inner: Arc>>, } impl Default for SandboxBuilderWrapper { @@ -385,7 +496,7 @@ impl SandboxBuilderWrapper { /// consumed (after `build()` has been called). fn with_inner(&self, f: F) -> napi::Result<&Self> where - F: FnOnce(SandboxBuilder) -> SandboxBuilder, + F: FnOnce(BuilderState) -> BuilderState, { let mut guard = self.inner.lock().map_err(|_| lock_error())?; let builder = guard @@ -396,7 +507,7 @@ impl SandboxBuilderWrapper { } /// Take ownership of the inner builder, or error if consumed. - fn take_inner(&self) -> napi::Result { + fn take_inner(&self) -> napi::Result { self.inner .lock() .map_err(|_| lock_error())? @@ -411,7 +522,29 @@ impl SandboxBuilderWrapper { #[napi(constructor)] pub fn new() -> Self { Self { - inner: Arc::new(Mutex::new(Some(SandboxBuilder::new()))), + inner: Arc::new(Mutex::new(Some(BuilderState::New(SandboxBuilder::new())))), + } + } + + /// Create a builder that restores `snapshot`. + /// + /// `build()` returns the sandbox type the snapshot captured. Supply every + /// host function the snapshot uses. + #[napi( + factory, + ts_return_type = "SandboxBuilder" + )] + pub fn from_snapshot(snapshot: &SnapshotWrapper) -> Self { + let state = match &snapshot.inner { + AnySnapshot::JsSandbox(snapshot) => { + BuilderState::JsSandbox(SandboxBuilder::from_snapshot(snapshot.clone())) + } + AnySnapshot::LoadedJsSandbox(snapshot) => { + BuilderState::LoadedJsSandbox(SandboxBuilder::from_snapshot(snapshot.clone())) + } + }; + Self { + inner: Arc::new(Mutex::new(Some(state))), } } @@ -434,7 +567,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_guest_output_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_guest_output_buffer_size(size as usize)), + ) } /// Set the legacy host-to-guest message capacity in bytes. @@ -456,7 +591,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_guest_input_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_guest_input_buffer_size(size as usize)), + ) } /// Set the guest scratch size in bytes. @@ -473,7 +610,7 @@ impl SandboxBuilderWrapper { if size == 0 { return Err(invalid_arg_error("Scratch size must be greater than 0")); } - self.with_inner(|b| b.with_guest_scratch_size(size as usize)) + self.with_inner(|state| map_builder!(state, b => b.with_guest_scratch_size(size as usize))) } /// Set the guest heap size in bytes. @@ -490,29 +627,95 @@ impl SandboxBuilderWrapper { if size == 0 { return Err(invalid_arg_error("Heap size must be greater than 0")); } - self.with_inner(|b| b.with_guest_heap_size(size as u64)) + self.with_inner(|state| map_builder!(state, b => b.with_guest_heap_size(size as u64))) + } + + /// Supply host-function modules for this sandbox. + /// + /// Each module is consumed and cannot be supplied to another builder. + /// Build fresh modules for each sandbox, for example with a function that + /// returns them. + #[napi] + pub fn set_host_function_modules( + &self, + modules: Vec>, + ) -> napi::Result<&Self> { + let mut builder_guard = self.inner.lock().map_err(|_| lock_error())?; + if builder_guard.is_none() { + return Err(consumed_error("SandboxBuilder")); + } + + for (index, module) in modules.iter().enumerate() { + if modules[..index] + .iter() + .any(|candidate| std::ptr::eq(&**candidate, &**module)) + { + return Err(invalid_arg_error( + "Host-function modules contain the same module object more than once", + )); + } + } + + let mut module_guards = modules + .iter() + .map(|module| module.inner.lock().map_err(|_| lock_error())) + .collect::>>()?; + if module_guards.iter().any(|module| module.is_none()) { + return Err(consumed_error("HostFunctionModule")); + } + + let taken = module_guards + .iter_mut() + .map(|module| { + module + .take() + .ok_or_else(|| consumed_error("HostFunctionModule")) + }) + .collect::>>()?; + let builder = builder_guard + .take() + .ok_or_else(|| consumed_error("SandboxBuilder"))?; + *builder_guard = Some(map_builder!( + builder, + b => b.with_host_function_modules(taken) + )); + Ok(self) } - /// Build a `ProtoJSSandbox` from this builder's configuration. + /// Build the sandbox from this builder's configuration. /// - /// This allocates the sandbox VM resources. The builder is consumed - /// and cannot be reused after calling this method. + /// A new builder returns a `ProtoJSSandbox`. A builder from + /// `SandboxBuilder.fromSnapshot()` returns the sandbox type the snapshot + /// captured. The builder is consumed and cannot be reused. /// /// This is an async operation — it returns a `Promise` and does not /// block the Node.js event loop. /// - /// @returns A `Promise` ready to load the JavaScript runtime - /// @throws On resource allocation failure, or if already consumed - #[napi] - pub async fn build(&self) -> napi::Result { - let builder = self.take_inner()?; - let proto_sandbox = - tokio::task::spawn_blocking(move || builder.build().map_err(to_napi_error)) - .await - .map_err(join_error)??; - Ok(ProtoJSSandboxWrapper { - inner: Arc::new(Mutex::new(Some(proto_sandbox))), + /// @throws On resource allocation failure, missing host functions, or if already consumed + #[napi(ts_return_type = "Promise")] + pub async fn build( + &self, + ) -> napi::Result> + { + let state = self.take_inner()?; + tokio::task::spawn_blocking(move || match state { + BuilderState::New(builder) => { + let proto_sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::A(ProtoJSSandboxWrapper { + inner: Arc::new(Mutex::new(Some(proto_sandbox))), + })) + } + BuilderState::JsSandbox(builder) => { + let sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::B(JSSandboxWrapper::from_sandbox(sandbox))) + } + BuilderState::LoadedJsSandbox(builder) => { + let sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::C(LoadedJSSandboxWrapper::from_sandbox(sandbox))) + } }) + .await + .map_err(join_error)? } /// Set a callback that receives guest `console.log` / `print` output. @@ -538,7 +741,7 @@ impl SandboxBuilderWrapper { false, // Not accepting unknown return types >, ) -> napi::Result<&Self> { - self.with_inner(|b| { + self.with_inner(|state| { // Blocking mode ensures the TSFN call is queued even when the // queue is full (it blocks until space is available), preventing // silent message drops that NonBlocking mode would cause. @@ -560,7 +763,7 @@ impl SandboxBuilderWrapper { -1 } }; - b.with_host_print_fn(print_fn.into()) + map_builder!(state, b => b.with_host_print_fn(print_fn.into())) }) } @@ -582,7 +785,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_output_transport_pool_pages(pages as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_output_transport_pool_pages(pages as usize)), + ) } /// Set the total host-to-guest buffer pool size in 4 KiB pages. @@ -600,7 +805,9 @@ impl SandboxBuilderWrapper { return Err(invalid_arg_error("Input pool pages must be greater than 0")); } - self.with_inner(|b| b.with_input_transport_pool_pages(pages as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_input_transport_pool_pages(pages as usize)), + ) } /// Set each large guest-to-host transport buffer's capacity in bytes. @@ -618,7 +825,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_output_transport_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_output_transport_buffer_size(size as usize)), + ) } /// Set each host-to-guest transport buffer's capacity in bytes. @@ -636,7 +845,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_input_transport_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_input_transport_buffer_size(size as usize)), + ) } } @@ -710,6 +921,7 @@ impl ProtoJSSandboxWrapper { .map_err(join_error)??; Ok(JSSandboxWrapper { inner: Arc::new(Mutex::new(Some(js_sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), }) } @@ -734,6 +946,7 @@ impl ProtoJSSandboxWrapper { /// @param name - Module name that guest JS uses in `import * as name from "host:name"` /// @returns A `HostModule` for registering functions /// @throws If the module name is empty + /// @deprecated Use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. #[napi] pub fn host_module(&self, name: String) -> napi::Result { validate_module_name(&name)?; @@ -756,6 +969,7 @@ impl ProtoJSSandboxWrapper { /// @param functionName - Function name within the module /// @param callback - `(...args) => any | Promise` — the host function implementation /// @throws If module name or function name is empty + /// @deprecated Use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. #[napi] #[allow(clippy::type_complexity)] // allow the type complexity here so that index.d.ts is cleaner pub fn register( @@ -1406,12 +1620,120 @@ unsafe fn napi_to_json_with_buffer_extraction( } } -// ── HostModule ─────────────────────────────────────────────────────── +// ── Host functions ────────────────────────────────────────────────── + +type JsHostFunction = ThreadsafeFunction< + Rest>, + Promise>, + Rest>, + Status, + false, + true, +>; + +fn js_host_function( + func: JsHostFunction, +) -> impl Fn(serde_json::Value, Vec>) -> hyperlight_js::Result + + Send + + Sync + + 'static { + move |args: serde_json::Value, + blobs: Vec>| + -> hyperlight_js::Result { + use hyperlight_js::FnReturn; + use ThreadsafeFunctionCallMode::NonBlocking; + + let js_args = JsArg::from_args(args, blobs) + .map_err(|error| HyperlightError::Error(error.to_string()))?; + + let (sender, receiver) = oneshot::channel(); + let status = func.call_with_return_value(Rest(js_args), NonBlocking, move |result, _| { + let _ = sender.send(result); + Ok(()) + }); + if status != Status::Ok { + return Err(HyperlightError::Error(format!( + "Host function call failed: {status:?}" + ))); + } + + tokio::runtime::Handle::current().block_on(async move { + let promise = receiver + .await + .map_err(|_| HyperlightError::Error("Channel closed".into()))? + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + let value = promise + .await + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + + match value { + Some(JsReturn::Buffer(bytes)) => Ok(FnReturn::Binary(bytes)), + Some(JsReturn::Value(value, blobs)) => { + let json = serde_json::to_string(&value)?; + if blobs.is_empty() { + Ok(FnReturn::Json(json)) + } else { + let sidecar = hyperlight_js_common::encode_binaries(&blobs) + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + Ok(FnReturn::JsonWithBinaries(json, sidecar)) + } + } + None => Ok(FnReturn::Json("null".into())), + } + }) + } +} + +/// A named collection of host functions for one sandbox. +/// +/// Host-function modules execute callbacks in the Node.js host process. They +/// are distinct from native modules and JavaScript source modules executing in +/// the guest. Create a fresh module for each sandbox so callback state cannot +/// leak between sandboxes. +#[napi(js_name = "HostFunctionModule")] +pub struct HostFunctionModuleWrapper { + inner: Mutex>, +} + +#[napi] +impl HostFunctionModuleWrapper { + /// Create an empty host-function module for one sandbox. + #[napi(constructor)] + pub fn new(name: String) -> napi::Result { + validate_module_name(&name)?; + Ok(Self { + inner: Mutex::new(Some(HostFunctionModule::new(format!( + "{HOST_MODULE_PREFIX}{name}" + )))), + }) + } + + /// Register a host function in this reusable module. + #[napi] + pub fn register( + &self, + name: String, + #[napi(ts_arg_type = "(...args: any[]) => unknown | Promise")] + func: JsHostFunction, + ) -> napi::Result<&Self> { + if name.is_empty() { + return Err(invalid_arg_error("Function name must not be empty")); + } + let mut guard = self.inner.lock().map_err(|_| lock_error())?; + let module = guard + .as_mut() + .ok_or_else(|| consumed_error("HostFunctionModule"))?; + module.register_js(name, js_host_function(func)); + Ok(self) + } +} + +// ── HostModule compatibility API ──────────────────────────────────── /// A builder for registering host functions in a named module. /// -/// Obtained from `ProtoJSSandbox.hostModule(name)`. Host functions -/// registered here become available to guest JavaScript code via +/// Obtained from the compatibility API `ProtoJSSandbox.hostModule(name)`. +/// Host functions registered here become available to guest JavaScript code via /// `import * as from "host:"` after `loadRuntime()` is called. /// /// The `host:` prefix is added automatically by the NAPI layer to prevent @@ -1438,8 +1760,7 @@ pub struct HostModuleWrapper { /// Module name this builder registers functions under. module_name: String, - /// Reference to the parent `ProtoJSSandboxWrapper`'s inner sandbox, for - /// applying registrations. + /// Fresh sandbox that receives registrations from this compatibility handle. sandbox: ProtoJSSandboxWrapper, } @@ -1487,72 +1808,16 @@ impl HostModuleWrapper { pub fn register( &self, name: String, - func: ThreadsafeFunction< - Rest>, - Promise>, - Rest>, - Status, - false, - true, - >, + #[napi(ts_arg_type = "(...args: any[]) => unknown | Promise")] + func: JsHostFunction, ) -> napi::Result<()> { if name.is_empty() { return Err(invalid_arg_error("Function name must not be empty")); } - // Use binary-capable registration to support Buffer arguments. - // The closure receives parsed JsonValue args (with {"__bin__": N} - // placeholders) and decoded binary blobs. JsArg resolves placeholders - // before dispatch and converts buffers on the JavaScript thread. - let wrapper = move |args: serde_json::Value, - blobs: Vec>| - -> hyperlight_js::Result { - use hyperlight_js::FnReturn; - use ThreadsafeFunctionCallMode::NonBlocking; - - let js_args = JsArg::from_args(args, blobs) - .map_err(|error| HyperlightError::Error(error.to_string()))?; - - let (tx, rx) = oneshot::channel(); - let status = - func.call_with_return_value(Rest(js_args), NonBlocking, move |result, _| { - let _ = tx.send(result); - Ok(()) - }); - if status != Status::Ok { - return Err(HyperlightError::Error(format!( - "Host function call failed: {status:?}" - ))); - } - tokio::runtime::Handle::current().block_on(async move { - let promise = rx - .await - .map_err(|_| HyperlightError::Error("Channel closed".into()))? - .map_err(|err| HyperlightError::Error(format!("{err}")))?; - - let value = promise - .await - .map_err(|err| HyperlightError::Error(format!("{err}")))?; - - // JsReturn extracts nested Buffers into blobs via the - // recursive NAPI walker — no base64 round-trip needed. - match value { - Some(JsReturn::Buffer(bytes)) => Ok(FnReturn::Binary(bytes)), - Some(JsReturn::Value(v, blobs)) => { - let json = serde_json::to_string(&v)?; - if blobs.is_empty() { - Ok(FnReturn::Json(json)) - } else { - let sidecar = hyperlight_js_common::encode_binaries(&blobs) - .map_err(|e| HyperlightError::Error(format!("{e}")))?; - Ok(FnReturn::JsonWithBinaries(json, sidecar)) - } - } - None => Ok(FnReturn::Json("null".into())), - } - }) - }; + let wrapper = js_host_function(func); self.sandbox.with_inner_mut(|sandbox| { + #[allow(deprecated)] sandbox .host_module(&self.module_name) .register_js(name, wrapper); @@ -1581,14 +1846,23 @@ impl HostModuleWrapper { #[napi(js_name = "JSSandbox")] pub struct JSSandboxWrapper { inner: Arc>>, + busy_flag: Arc, } impl JSSandboxWrapper { + fn from_sandbox(sandbox: JSSandbox) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), + } + } + /// Borrow the inner value mutably via Mutex, or error if consumed. fn with_inner_mut(&self, f: F) -> napi::Result where F: FnOnce(&mut JSSandbox) -> napi::Result, { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let mut guard = self.inner.lock().map_err(|_| lock_error())?; let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; f(sandbox) @@ -1599,6 +1873,7 @@ impl JSSandboxWrapper { where F: FnOnce(&JSSandbox) -> napi::Result, { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let guard = self.inner.lock().map_err(|_| lock_error())?; let sandbox = guard.as_ref().ok_or_else(|| consumed_error("JSSandbox"))?; f(sandbox) @@ -1606,6 +1881,7 @@ impl JSSandboxWrapper { /// Take ownership of the inner value via Mutex, or error if consumed. fn take_inner(&self) -> napi::Result { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; self.inner .lock() .map_err(|_| lock_error())? @@ -1785,6 +2061,45 @@ impl JSSandboxWrapper { }) } + /// Capture the runtime state and handlers and modules added to this sandbox + /// but not yet registered in the guest. + #[napi] + pub async fn snapshot(&self) -> napi::Result { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; + let inner = Arc::clone(&self.inner); + let snapshot = tokio::task::spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| lock_error())?; + let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; + sandbox.snapshot().map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(SnapshotWrapper { + inner: AnySnapshot::JsSandbox(snapshot), + }) + } + + /// Restore the runtime state and handlers and modules that were added to the + /// snapshotted sandbox but not yet registered in the guest. + #[napi] + pub async fn restore(&self, snapshot: &SnapshotWrapper) -> napi::Result<()> { + let AnySnapshot::JsSandbox(snapshot) = &snapshot.inner else { + return Err(invalid_arg_error( + "Cannot restore a LoadedJSSandbox snapshot into a JSSandbox", + )); + }; + let snapshot = snapshot.clone(); + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; + let inner = Arc::clone(&self.inner); + tokio::task::spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| lock_error())?; + let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; + sandbox.restore(snapshot).map_err(to_napi_error) + }) + .await + .map_err(join_error)? + } + /// The sandbox lifecycle status. #[napi(getter)] pub fn status(&self) -> napi::Result { @@ -1812,11 +2127,32 @@ impl JSSandboxWrapper { /// Calling `dispose()` on an already-consumed sandbox is a no-op. #[napi] pub fn dispose(&self) -> napi::Result<()> { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let _ = self.inner.lock().map_err(|_| lock_error())?.take(); Ok(()) } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn js_sandbox_busy_guard_is_exclusive_and_resets_on_drop() { + let flag = Arc::new(AtomicBool::new(false)); + let guard = JSSandboxBusyGuard::acquire(Arc::clone(&flag)).unwrap(); + + let error = match JSSandboxBusyGuard::acquire(Arc::clone(&flag)) { + Ok(_) => panic!("a second busy guard was acquired"), + Err(error) => error, + }; + assert!(error.reason.contains("ERR_BUSY")); + + drop(guard); + assert!(JSSandboxBusyGuard::acquire(flag).is_ok()); + } +} + // ── LoadedJSSandbox ────────────────────────────────────────────────── /// An execution-ready sandbox with handlers loaded. @@ -1884,6 +2220,21 @@ pub struct LoadedJSSandboxWrapper { type LoadedJSSandboxGuard = OwnedMappedMutexGuard, LoadedJSSandbox>; impl LoadedJSSandboxWrapper { + fn from_sandbox(sandbox: LoadedJSSandbox) -> Self { + let interrupt = sandbox.interrupt_handle(); + let status = Arc::new(AtomicU8::new( + SandboxStatus::from_hyperlight(sandbox.status()).as_u8(), + )); + Self { + inner: Arc::new(AsyncMutex::new(Some(sandbox))), + interrupt, + status, + last_call_stats: Arc::new(ArcSwapOption::empty()), + disposed_flag: Arc::new(AtomicBool::new(false)), + executing_flag: Arc::new(AtomicBool::new(false)), + } + } + /// Borrow the inner value mutably via Mutex, or error if consumed. /// /// Performs deadlock detection: if `executing_flag` is set (meaning we're @@ -2026,14 +2377,14 @@ impl LoadedJSSandboxWrapper { if let Some(wall_ms) = options.wall_clock_timeout_ms && !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&wall_ms) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "wallClockTimeoutMs must be between {MIN_TIMEOUT_MS}ms and {MAX_TIMEOUT_MS}ms, got {wall_ms}" ))); } if let Some(cpu_ms) = options.cpu_timeout_ms && !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&cpu_ms) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "cpuTimeoutMs must be between {MIN_TIMEOUT_MS}ms and {MAX_TIMEOUT_MS}ms, got {cpu_ms}" ))); } @@ -2046,7 +2397,7 @@ impl LoadedJSSandboxWrapper { // Serialize the JS object to a JSON string for the hypervisor let event_json = serde_json::to_string(&event_data) - .map_err(|e| invalid_arg_error(&format!("Failed to serialize event: {e}")))?; + .map_err(|e| invalid_arg_error(format!("Failed to serialize event: {e}")))?; let result_json = self .with_blocking_inner(move |mut sandbox| { @@ -2134,6 +2485,7 @@ impl LoadedJSSandboxWrapper { .await?; Ok(JSSandboxWrapper { inner: Arc::new(Mutex::new(Some(js_sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), }) } @@ -2257,7 +2609,9 @@ impl LoadedJSSandboxWrapper { result }) .await?; - Ok(SnapshotWrapper { inner: snapshot }) + Ok(SnapshotWrapper { + inner: AnySnapshot::LoadedJsSandbox(snapshot), + }) } /// Restore the sandbox to a previously captured snapshot state. @@ -2271,7 +2625,12 @@ impl LoadedJSSandboxWrapper { /// @throws If the snapshot doesn't match this sandbox, or if consumed #[napi] pub async fn restore(&self, snapshot: &SnapshotWrapper) -> napi::Result<()> { - let snap = snapshot.inner.clone(); + let AnySnapshot::LoadedJsSandbox(snap) = &snapshot.inner else { + return Err(invalid_arg_error( + "Cannot restore a JSSandbox snapshot into a LoadedJSSandbox", + )); + }; + let snap = snap.clone(); let status = self.status.clone(); self.with_blocking_inner(move |mut sandbox| { diff --git a/src/js-host-api/tests/persistent-snapshots.test.js b/src/js-host-api/tests/persistent-snapshots.test.js new file mode 100644 index 00000000..fb87cf4c --- /dev/null +++ b/src/js-host-api/tests/persistent-snapshots.test.js @@ -0,0 +1,265 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { afterEach, describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { URL } from 'node:url'; + +import { + HostFunctionModule, + JSSandbox, + LoadedJSSandbox, + SandboxBuilder, + Snapshot, +} from '../lib.js'; + +const directories = []; + +async function snapshotDirectory() { + const directory = await mkdtemp(join(tmpdir(), 'hyperlight-js-snapshot-')); + directories.push(directory); + return directory; +} + +function stateHostFunctionModules() { + const state = new HostFunctionModule('state'); + state.register('next', () => 1); + return [state]; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ); +}); + +describe('Persistent snapshots', () => { + it('exports the restoration API through ESM', async () => { + const esm = await import('../lib.mjs'); + + expect(esm.HostFunctionModule).toBe(HostFunctionModule); + expect(Object.keys(esm).some((name) => name.endsWith('Wrapper'))).toBe(false); + expect(esm.SandboxBuilder).toBe(SandboxBuilder); + expect(typeof esm.SandboxBuilder.fromSnapshot).toBe('function'); + expect(esm.SandboxStatus).toBeDefined(); + }); + + it('generates self-contained TypeScript declarations', async () => { + const declarations = await readFile(new URL('../index.d.ts', import.meta.url), 'utf8'); + + expect(declarations).not.toContain('JsHostFunction'); + expect(declarations).not.toMatch(/\b\w+Wrapper\b/); + expect(declarations).not.toMatch(/^export type (\w+) = \1$/m); + expect(declarations).toContain('build(): Promise'); + }); + + it('enriches reusable host-function configuration errors', async () => { + expect(() => new HostFunctionModule('')).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG' }) + ); + + const builder = new SandboxBuilder(); + const proto = await builder.build(); + expect(() => builder.setHostFunctionModules([])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + const sandbox = await proto.loadRuntime(); + sandbox.dispose(); + }); + + it('prevents sharing a host-function module across sandbox builders', () => { + const [state] = stateHostFunctionModules(); + const modules = [state]; + + new SandboxBuilder().setHostFunctionModules(modules); + + expect(() => new SandboxBuilder().setHostFunctionModules(modules)).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + expect(() => state.register('another', () => 2)).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + }); + + it('does not consume modules when a host-function batch is rejected', () => { + const available = new HostFunctionModule('available'); + available.register('value', () => 1); + const consumed = new HostFunctionModule('consumed'); + consumed.register('value', () => 2); + new SandboxBuilder().setHostFunctionModules([consumed]); + + const builder = new SandboxBuilder(); + expect(() => builder.setHostFunctionModules([available, consumed])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + + available.register('stillAvailable', () => true); + expect(builder.setHeapSize(8 * 1024 * 1024)).toBe(builder); + }); + + it('rejects duplicate module objects without consuming them', () => { + const duplicate = new HostFunctionModule('duplicate'); + duplicate.register('value', () => 1); + + expect(() => new SandboxBuilder().setHostFunctionModules([duplicate, duplicate])).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG' }) + ); + + duplicate.register('stillAvailable', () => true); + }); + + it('does not consume modules when the sandbox builder is consumed', async () => { + const available = new HostFunctionModule('available'); + available.register('value', () => 1); + const builder = new SandboxBuilder(); + const proto = await builder.build(); + + expect(() => builder.setHostFunctionModules([available])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + available.register('stillAvailable', () => true); + + const sandbox = await proto.loadRuntime(); + sandbox.dispose(); + }); + + it('enriches static snapshot load errors', async () => { + await expect(Snapshot.load('missing-snapshot-layout', 'missing')).rejects.toMatchObject({ + code: 'ERR_INTERNAL', + }); + await expect(Snapshot.checkedLoad('missing-snapshot-layout', '')).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + }); + + it('rejects restoring a snapshot into the other sandbox type', async () => { + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler('handler', 'function handler(event) { return event; }'); + const jsSnapshot = await sandbox.snapshot(); + const loaded = await sandbox.getLoadedSandbox(); + const loadedSnapshot = await loaded.snapshot(); + + await expect(loaded.restore(jsSnapshot)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + const unloaded = await loaded.unload(); + await expect(unloaded.restore(loadedSnapshot)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + unloaded.dispose(); + }); + + it('persists and restores a JSSandbox with handlers not yet registered in the guest', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + ` + ); + + const snapshot = await sandbox.snapshot(); + const digest = await snapshot.save(directory, 'runtime-ready'); + expect(digest).toMatch(/^sha256:/); + sandbox.dispose(); + + const persisted = await Snapshot.load(directory, 'runtime-ready'); + const restoredSandbox = await SandboxBuilder.fromSnapshot(persisted).build(); + expect(restoredSandbox).toBeInstanceOf(JSSandbox); + const loaded = await restoredSandbox.getLoadedSandbox(); + + await expect(loaded.callHandler('counter', {})).resolves.toEqual({ count: 1 }); + await loaded.dispose(); + }); + + it('persists loaded guest state and restores it in a new sandbox', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + await expect(loaded.callHandler('counter', {})).resolves.toEqual({ count: 1 }); + + const snapshot = await loaded.snapshot(); + await snapshot.save(directory, 'application-ready'); + await loaded.dispose(); + + const persisted = await Snapshot.checkedLoad(directory, 'application-ready'); + const restored = await SandboxBuilder.fromSnapshot(persisted).build(); + expect(restored).toBeInstanceOf(LoadedJSSandbox); + + await expect(restored.callHandler('counter', {})).resolves.toEqual({ count: 2 }); + const unloaded = await restored.unload(); + unloaded.addHandler('replacement', 'function handler(event) { return event; }'); + const reloaded = await unloaded.getLoadedSandbox(); + await expect(reloaded.callHandler('replacement', { ok: true })).resolves.toEqual({ + ok: true, + }); + await reloaded.dispose(); + }); + + it('requires host functions used by the persisted sandbox', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder() + .setHostFunctionModules(stateHostFunctionModules()) + .build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + ` + import * as state from "host:state"; + function handler(event) { + event.value = state.next(); + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + const snapshot = await loaded.snapshot(); + await snapshot.save(directory, 'host-functions'); + await loaded.dispose(); + + const persisted = await Snapshot.checkedLoad(directory, 'host-functions'); + await expect(SandboxBuilder.fromSnapshot(persisted).build()).rejects.toThrow( + /requires host module 'host:state'/ + ); + + const restored = await SandboxBuilder.fromSnapshot(persisted) + .setHostFunctionModules(stateHostFunctionModules()) + .build(); + await expect(restored.callHandler('handler', {})).resolves.toEqual({ value: 1 }); + await restored.dispose(); + }); +}); diff --git a/src/js-host-api/vitest.config.js b/src/js-host-api/vitest.config.mjs similarity index 68% rename from src/js-host-api/vitest.config.js rename to src/js-host-api/vitest.config.mjs index f97140f8..428064f9 100644 --- a/src/js-host-api/vitest.config.js +++ b/src/js-host-api/vitest.config.mjs @@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Hyperlight sandboxes use hardware virtualization and must not compete across workers. + fileParallelism: false, // Test files pattern include: ['tests/**/*.test.js'], // Increase timeout for sandbox operations (some involve busy loops)