From befbc0146c4603f640e3d449a67c3ade88563074 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:17:11 -0800 Subject: [PATCH 1/2] Skip redundant host function registration on reload get_loaded_sandbox and unload each called RegisterHostModules, although the base snapshot already holds that registration. A load/unload cycle made four guest calls. Only register_handler and RunHandler are needed. Register host functions only when a restored snapshot records different ones than the builder supplies. On add_load_handle_unload this cuts 415 to 315 us on MSHV and 248 to 203 us on KVM. Bump the snapshot metadata schema to 2, since 0.5.0 snapshots can record host functions their base never registered. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- docs/persistent-snapshots.md | 8 + src/hyperlight-js/src/sandbox/host_fn.rs | 3 + src/hyperlight-js/src/sandbox/js_sandbox.rs | 53 +++- .../src/sandbox/loaded_js_sandbox.rs | 11 +- .../src/sandbox/sandbox_builder.rs | 3 + src/hyperlight-js/src/sandbox/snapshot.rs | 17 +- src/hyperlight-js/tests/host_functions.rs | 251 +++++++++++++++++- 7 files changed, 333 insertions(+), 13 deletions(-) diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md index bcec6eb8..fc2f2f9f 100644 --- a/docs/persistent-snapshots.md +++ b/docs/persistent-snapshots.md @@ -155,6 +155,12 @@ 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. +If the builder supplies host functions the snapshot does not record, +hyperlight-js registers them in the guest and copies the guest state. A +`JSSandbox` does this in `build()` and in each `restore()` of such a snapshot. +A `LoadedJSSandbox` does it in `unload()` after such a restore. Supply exactly +the recorded functions to avoid this cost. + 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 @@ -239,6 +245,8 @@ the same output routing is required. version for diagnostics. It also records the guest runtime ABI version. Unsupported metadata schema or guest ABI versions are rejected before starting guest execution. +- Snapshots saved by hyperlight-js 0.5.0 use metadata schema 1 and are + rejected. Recreate them. - 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. diff --git a/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index 69282208..4fd831dd 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -68,6 +68,8 @@ pub(crate) fn register_host_function_manifest( } /// Return the registered host functions grouped by module. +/// +/// Modules without functions are omitted because the guest has nothing to register for them. pub(crate) fn host_function_manifest( modules: &HashMap, ) -> HostFunctionManifest { @@ -81,6 +83,7 @@ pub(crate) fn host_function_manifest( functions.sort(); (module.clone(), functions) }) + .filter(|(_, functions)| !functions.is_empty()) .collect() } diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 12f37718..3ed2a6ca 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -74,6 +74,23 @@ pub fn validate_namespace_not_reserved(namespace: &str) -> Result<()> { Ok(()) } +/// Return a base snapshot whose guest state registers `host_functions`. +/// +/// `inner` must hold the guest state of `base`, which registers the host +/// functions recorded in `metadata`. +fn base_with_host_functions( + inner: &mut Sandbox, + base: Arc, + metadata: &SnapshotMetadata, + host_functions: &HostFunctionManifest, +) -> Result> { + if metadata.host_functions() == host_functions { + return Ok(base); + } + register_host_function_manifest(inner, host_functions)?; + inner.snapshot() +} + /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub struct JSSandbox { pub(super) inner: Sandbox, @@ -84,7 +101,8 @@ pub struct JSSandbox { modules: HashMap, // Host functions selected by SandboxBuilder. host_functions: HostFunctionManifest, - // Snapshot of state before any handlers are added. + // Snapshot of state before any handlers are added. Its guest state has + // `host_functions` registered and matches the current guest state. // This is used to restore state back to a neutral JSSandbox. snapshot: Arc, // metric drop guard to manage sandbox metric @@ -109,10 +127,11 @@ impl JSSandbox { pub(crate) fn from_loaded( mut loaded: Sandbox, snapshot: Arc, + metadata: &SnapshotMetadata, host_functions: HostFunctionManifest, ) -> Result { loaded.restore(snapshot.clone())?; - register_host_function_manifest(&mut loaded, &host_functions)?; + let snapshot = base_with_host_functions(&mut loaded, snapshot, metadata, &host_functions)?; Ok(Self { inner: loaded, handlers: HashMap::new(), @@ -124,16 +143,22 @@ impl JSSandbox { } pub(crate) fn from_snapshot( - inner: Sandbox, + mut inner: Sandbox, snapshot: &Snapshot, host_functions: HostFunctionManifest, ) -> Result { + let base = base_with_host_functions( + &mut inner, + snapshot.inner(), + snapshot.metadata(), + &host_functions, + )?; Ok(Self { inner, handlers: snapshot.metadata().handlers().collect(), modules: snapshot.metadata().modules().collect(), host_functions, - snapshot: snapshot.inner(), + snapshot: base, _metric_guard: SandboxMetricsGuard::new(), }) } @@ -164,9 +189,23 @@ impl JSSandbox { .metadata() .validate_host_functions(&self.host_functions)?; self.inner.restore(snapshot.inner())?; + let base = base_with_host_functions( + &mut self.inner, + snapshot.inner(), + snapshot.metadata(), + &self.host_functions, + ); + self.snapshot = match base { + Ok(base) => base, + Err(error) => { + // Return the guest to the state the unchanged fields describe. + // Report the original error even if that fails. + let _ = self.inner.restore(self.snapshot.clone()); + return Err(error); + } + }; self.handlers = snapshot.metadata().handlers().collect(); self.modules = snapshot.metadata().modules().collect(); - self.snapshot = snapshot.inner(); Ok(()) } @@ -377,10 +416,6 @@ impl JSSandbox { 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 diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index ac86c4b0..1bb47cae 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -38,6 +38,8 @@ use crate::sandbox::metrics::SandboxMetricsGuard; pub struct LoadedJSSandbox { inner: Sandbox, // Snapshot of state before the sandbox was loaded and before any handlers were added. + // Its guest state has the host functions in `snapshot_metadata` registered, + // which `unload` relies on. // This is used to restore state back to a JSSandbox. snapshot: Arc, // Metadata for the currently captured loaded guest state. Its host-function @@ -205,8 +207,13 @@ 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, self.available_host_functions) - .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) + JSSandbox::from_loaded( + self.inner, + self.snapshot, + &self.snapshot_metadata, + self.available_host_functions, + ) + .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) } /// Take a snapshot of the the current state of the sandbox. diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index d57ca096..a563d168 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -424,6 +424,9 @@ impl SandboxBuilder> { /// /// The snapshot's memory layout overrides heap, scratch, and transport /// settings. Supply every host function and module loader the snapshot uses. + /// Host functions the snapshot does not record are registered in the guest, + /// which costs one guest call and a copy of guest state. A `JSSandbox` pays + /// this in `build()`, and a `LoadedJSSandbox` in `unload()`. pub fn from_snapshot(snapshot: Snapshot) -> Self { Self::with_source(snapshot) } diff --git a/src/hyperlight-js/src/sandbox/snapshot.rs b/src/hyperlight-js/src/sandbox/snapshot.rs index 71c43c86..2965ca4f 100644 --- a/src/hyperlight-js/src/sandbox/snapshot.rs +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -30,7 +30,8 @@ use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reser use crate::{new_error, JSSandbox, LoadedJSSandbox, Result, Script}; const METADATA_NAMESPACE: &str = "dev.hyperlight.hyperlight-js.snapshot.v1"; -const METADATA_SCHEMA_VERSION: u32 = 1; +// Version 2 guarantees that a snapshot's base registers its recorded host functions. +const METADATA_SCHEMA_VERSION: u32 = 2; 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(); @@ -119,6 +120,10 @@ impl SnapshotMetadata { self.kind } + pub(crate) fn host_functions(&self) -> &HostFunctionManifest { + &self.host_functions + } + pub(crate) fn handlers(&self) -> impl Iterator + '_ { self.handlers .clone() @@ -543,6 +548,16 @@ mod tests { .contains("produced by hyperlight-js 9.8.7")); } + #[test] + fn metadata_rejects_schema_version_1() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.schema_version = 1; + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("metadata version 1 ")); + } + #[test] fn metadata_rejects_other_guest_abi_version() { let mut metadata = metadata(SnapshotKind::JsSandbox); diff --git a/src/hyperlight-js/tests/host_functions.rs b/src/hyperlight-js/tests/host_functions.rs index b2a71c0a..50e4518c 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -19,7 +19,16 @@ limitations under the License. // Most tests here cover the deprecated ProtoJSSandbox registration API. #![allow(deprecated)] -use hyperlight_js::{HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; +use std::cell::RefCell; +use std::sync::Once; + +use hyperlight_js::{ + HostFunctionModule, JSSandbox, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, +}; +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id}; +use tracing::{Metadata, Subscriber}; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; #[test] fn can_call_host_functions() { @@ -1243,3 +1252,243 @@ fn restored_js_sandbox_records_supplied_host_functions() { let error = insufficient.restore(complete_snapshot).unwrap_err(); assert!(error.to_string().contains("extra")); } + +fn extra_handler() -> Script { + Script::from_content( + r#" + import * as extra from "extra"; + function handler(event) { return { value: extra.value(0) }; } + "#, + ) +} + +fn assert_extra_handler_runs(mut sandbox: JSSandbox) -> JSSandbox { + sandbox.add_handler("extra", extra_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded.handle_event("extra", "{}".to_owned(), None).unwrap(); + assert_eq!(result, r#"{"value":2}"#); + loaded.unload().unwrap() +} + +fn both_host_function_modules() -> [HostFunctionModule; 2] { + [ + required_host_function_module(), + extra_host_function_module(), + ] +} + +#[test] +fn handlers_call_host_functions_across_reload_cycles() { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + for _ in 0..5 { + sandbox = assert_extra_handler_runs(sandbox); + } +} + +#[test] +fn restored_js_sandbox_can_call_supplied_host_functions() { + let mut source = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + for _ in 0..3 { + restored = assert_extra_handler_runs(restored); + } + + let snapshot = restored.snapshot().unwrap(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + assert_extra_handler_runs(restored); +} + +#[test] +fn restore_into_sandbox_with_more_host_functions_can_call_them() { + let snapshot = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap() + .snapshot() + .unwrap(); + + let mut destination = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination.restore(snapshot).unwrap(); + for _ in 0..3 { + destination = assert_extra_handler_runs(destination); + } +} + +#[test] +fn loaded_restore_into_sandbox_with_more_host_functions_can_call_them_after_unload() { + let mut destination = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return event; }"), + ) + .unwrap(); + let mut loaded = destination.get_loaded_sandbox().unwrap(); + loaded + .restore(loaded_snapshot_requiring_host_function()) + .unwrap(); + let result = loaded + .handle_event("source", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":1}"#); + + let mut unloaded = loaded.unload().unwrap(); + for _ in 0..2 { + unloaded = assert_extra_handler_runs(unloaded); + } +} + +#[test] +fn restored_loaded_sandbox_snapshot_after_unload_can_call_supplied_host_functions() { + let snapshot = loaded_snapshot_requiring_host_function(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + let mut unloaded = assert_extra_handler_runs(restored.unload().unwrap()); + + let snapshot = unloaded.snapshot().unwrap(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + assert_extra_handler_runs(restored); +} + +thread_local! { + static GUEST_CALLS: RefCell>> = const { RefCell::new(None) }; +} + +// Records the `func_name` of hyperlight-host's instrumented `Sandbox::call` spans +// on threads inside `guest_calls`. It is a global subscriber because tracing caches +// callsite interest per process, so thread-scoped subscribers can miss spans. +struct GuestCalls; + +impl Layer for GuestCalls { + fn enabled(&self, metadata: &Metadata<'_>, _: Context<'_, S>) -> bool { + metadata.name() == "call" && metadata.target().starts_with("hyperlight_host") + } + + fn on_new_span(&self, attrs: &Attributes<'_>, _: &Id, _: Context<'_, S>) { + struct FuncName(Option); + impl Visit for FuncName { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "func_name" { + self.0 = Some(value.to_owned()); + } + } + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "func_name" { + self.0 = Some(format!("{value:?}").trim_matches('"').to_owned()); + } + } + } + let mut name = FuncName(None); + attrs.record(&mut name); + GUEST_CALLS.with_borrow_mut(|calls| { + if let Some(calls) = calls { + calls.extend(name.0); + } + }); + } +} + +fn guest_calls(f: impl FnOnce() -> R) -> (R, Vec) { + static INIT: Once = Once::new(); + INIT.call_once(|| { + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(GuestCalls)) + .unwrap(); + }); + // Replace interest a callsite may have cached before the subscriber was installed. + tracing::callsite::rebuild_interest_cache(); + GUEST_CALLS.set(Some(Vec::new())); + let result = f(); + (result, GUEST_CALLS.take().unwrap()) +} + +#[test] +fn reload_cycle_makes_two_guest_calls() { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + + let (_, calls) = guest_calls(|| { + for _ in 0..3 { + sandbox = assert_extra_handler_runs(sandbox); + } + }); + + assert_eq!(calls.len(), 6, "guest calls: {calls:?}"); + assert!(calls + .chunks(2) + .all(|cycle| cycle == ["register_handler", "RunHandler"])); +} + +#[test] +fn restore_registers_host_functions_only_when_they_differ() { + let snapshot = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap() + .snapshot() + .unwrap(); + let restore = |modules: Vec| { + guest_calls(|| { + SandboxBuilder::from_snapshot(snapshot.clone()) + .with_host_function_modules(modules) + .build() + .unwrap() + }) + }; + + let (_, calls) = restore(vec![ + required_host_function_module(), + HostFunctionModule::new("empty"), + ]); + assert!(calls.is_empty(), "guest calls: {calls:?}"); + + let (mut sandbox, calls) = restore(both_host_function_modules().into()); + assert_eq!(calls, ["RegisterHostModules"]); + + let (_, calls) = guest_calls(|| { + for _ in 0..2 { + sandbox = assert_extra_handler_runs(sandbox); + } + }); + assert_eq!(calls.len(), 4, "guest calls: {calls:?}"); +} From 34f930ddee6b6ffc0fb762d73e47469e26dfefa8 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:17:15 -0800 Subject: [PATCH 2/2] Rename base_with_host_functions to snapshot_base_with_host_functions Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight-js/src/sandbox/js_sandbox.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 3ed2a6ca..b158e4a4 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -78,7 +78,7 @@ pub fn validate_namespace_not_reserved(namespace: &str) -> Result<()> { /// /// `inner` must hold the guest state of `base`, which registers the host /// functions recorded in `metadata`. -fn base_with_host_functions( +fn snapshot_base_with_host_functions( inner: &mut Sandbox, base: Arc, metadata: &SnapshotMetadata, @@ -131,7 +131,8 @@ impl JSSandbox { host_functions: HostFunctionManifest, ) -> Result { loaded.restore(snapshot.clone())?; - let snapshot = base_with_host_functions(&mut loaded, snapshot, metadata, &host_functions)?; + let snapshot = + snapshot_base_with_host_functions(&mut loaded, snapshot, metadata, &host_functions)?; Ok(Self { inner: loaded, handlers: HashMap::new(), @@ -147,7 +148,7 @@ impl JSSandbox { snapshot: &Snapshot, host_functions: HostFunctionManifest, ) -> Result { - let base = base_with_host_functions( + let base = snapshot_base_with_host_functions( &mut inner, snapshot.inner(), snapshot.metadata(), @@ -189,7 +190,7 @@ impl JSSandbox { .metadata() .validate_host_functions(&self.host_functions)?; self.inner.restore(snapshot.inner())?; - let base = base_with_host_functions( + let base = snapshot_base_with_host_functions( &mut self.inner, snapshot.inner(), snapshot.metadata(),