Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 63 additions & 77 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ repository = "https://github.com/hyperlight-dev/hyperlight-js"
readme = "README.md"

[workspace.dependencies]
hyperlight-common = { version = "0.12", default-features = false }
hyperlight-guest-bin = { version = "0.12" }
hyperlight-guest = { version = "0.12" }
hyperlight-host = { version = "0.12", default-features = false, features = ["executable_heap", "init-paging"] }
hyperlight-common = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "620339aa95d508e8cbd1d38b4374f09090aade7b", default-features = false }
hyperlight-guest-bin = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "620339aa95d508e8cbd1d38b4374f09090aade7b" }
hyperlight-guest = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "620339aa95d508e8cbd1d38b4374f09090aade7b" }
hyperlight-host = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "620339aa95d508e8cbd1d38b4374f09090aade7b", default-features = false, features = ["executable_heap", "init-paging"] }
hyperlight-js = { version = "0.1.1", path = "src/hyperlight-js" }
hyperlight-js-runtime = { version = "0.1.1", path = "src/hyperlight-js-runtime" }

Expand Down
2 changes: 1 addition & 1 deletion src/hyperlight-js/benches/benchmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ fn handle_events_benchmark(c: &mut Criterion) {
let start = Instant::now();
let _ =
loaded_js_sandbox.handle_event("function1", event.to_string(), Some(gc));
loaded_js_sandbox.restore(&snapshot).unwrap();
loaded_js_sandbox.restore(snapshot.clone()).unwrap();
elapsed += start.elapsed();
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/hyperlight-js/examples/interrupt/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ fn main() -> Result<()> {

// Demonstrate recovery from poisoned state
println!("\n📸 Restoring sandbox from snapshot...");
loaded_sandbox.restore(&snapshot)?;
loaded_sandbox.restore(snapshot)?;

println!("🔒 Poisoned after restore: {}", loaded_sandbox.poisoned());
assert!(
Expand Down
10 changes: 7 additions & 3 deletions src/hyperlight-js/src/sandbox/js_sandbox.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc;

use hyperlight_host::sandbox::snapshot::Snapshot;
use hyperlight_host::{new_error, MultiUseSandbox, Result};
Expand All @@ -15,7 +16,7 @@ pub struct JSSandbox {
handlers: HashMap<String, Script>,
// Snapshot of state before any handlers are added.
// This is used to restore state back to a neutral JSSandbox.
snapshot: Snapshot,
snapshot: Arc<Snapshot>,
// metric drop guard to manage sandbox metric
_metric_guard: SandboxMetricsGuard<JSSandbox>,
}
Expand All @@ -33,8 +34,11 @@ impl JSSandbox {
}

/// Creates a new `JSSandbox` from a `MultiUseSandbox` and a `Snapshot` of state before any handlers were added.
pub(crate) fn from_loaded(mut loaded: MultiUseSandbox, snapshot: Snapshot) -> Result<Self> {
loaded.restore(&snapshot)?;
pub(crate) fn from_loaded(
mut loaded: MultiUseSandbox,
snapshot: Arc<Snapshot>,
) -> Result<Self> {
loaded.restore(snapshot.clone())?;
Ok(Self {
inner: loaded,
handlers: HashMap::new(),
Expand Down
12 changes: 6 additions & 6 deletions src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub struct LoadedJSSandbox {
inner: MultiUseSandbox,
// 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: Snapshot,
snapshot: Arc<Snapshot>,
// metric drop guard to manage sandbox metric
_metric_guard: SandboxMetricsGuard<LoadedJSSandbox>,
}
Expand All @@ -42,7 +42,7 @@ impl Drop for MonitorTask {

impl LoadedJSSandbox {
#[instrument(err(Debug), skip_all, level=Level::INFO)]
pub(super) fn new(inner: MultiUseSandbox, snapshot: Snapshot) -> Result<LoadedJSSandbox> {
pub(super) fn new(inner: MultiUseSandbox, snapshot: Arc<Snapshot>) -> Result<LoadedJSSandbox> {
metrics::counter!(METRIC_SANDBOX_LOADS).increment(1);
Ok(LoadedJSSandbox {
inner,
Expand Down Expand Up @@ -92,13 +92,13 @@ impl LoadedJSSandbox {
/// 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<Snapshot> {
pub fn snapshot(&mut self) -> Result<Arc<Snapshot>> {
self.inner.snapshot()
}

/// Restore the state of the sandbox to a previous snapshot.
#[instrument(err(Debug), skip_all, level=Level::DEBUG)]
pub fn restore(&mut self, snapshot: &Snapshot) -> Result<()> {
pub fn restore(&mut self, snapshot: Arc<Snapshot>) -> Result<()> {
self.inner.restore(snapshot)?;
Ok(())
}
Expand Down Expand Up @@ -402,7 +402,7 @@ mod tests {
assert_eq!(response_json["count"], 3);

// Restore the snapshot
loaded_js_sandbox.restore(&snapshot).unwrap();
loaded_js_sandbox.restore(snapshot.clone()).unwrap();

// Handle the event again, should reset to initial state
let result = loaded_js_sandbox
Expand Down Expand Up @@ -432,7 +432,7 @@ mod tests {
.unwrap_err();

// restore to snapshot before unload/reload
reloaded_js_sandbox.restore(&snapshot).unwrap();
reloaded_js_sandbox.restore(snapshot.clone()).unwrap();
// handler should be available again
let result = reloaded_js_sandbox
.handle_event("handler", get_static_counter_event(), gc)
Expand Down
44 changes: 32 additions & 12 deletions src/hyperlight-js/src/sandbox/sandbox_builder.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#[cfg(target_os = "linux")]
use std::time::Duration;

use hyperlight_host::sandbox::{is_hypervisor_present, SandboxConfiguration};
use hyperlight_host::{GuestBinary, HyperlightError, Result};
use hyperlight_host::sandbox::SandboxConfiguration;
use hyperlight_host::{is_hypervisor_present, GuestBinary, HyperlightError, Result};

use super::proto_js_sandbox::ProtoJSSandbox;
use crate::HostPrintFn;
Expand All @@ -13,16 +13,35 @@ pub struct SandboxBuilder {
host_print_fn: Option<HostPrintFn>,
}

const MIN_STACK_SIZE: u64 = 256 * 1024;
// The minimum heap size is 4096KB.
/// The minimum scratch size for the JS runtime sandbox.
///
/// The scratch region provides writable physical memory for:
/// - I/O buffers (input + output data)
/// - Page table copies (proportional to snapshot size — our ~13 MB guest
/// binary + heap produce ~72 KiB of page tables)
/// - Dynamically allocated pages (GDT/IDT, stack growth, Copy-on-Write
/// resolution during QuickJS initialisation)
/// - Exception stack and metadata (2 pages at the top)
///
/// Hyperlight's default scratch (288 KiB) is far too small for the JS
/// runtime guest: after fixed overheads there are only ~44 free pages,
/// which are exhausted during init. 1 MiB (0x10_0000) matches
/// hyperlight's own "large guest" test configuration and gives
/// comfortable headroom.
const MIN_SCRATCH_SIZE: usize = 0x10_0000; // 1 MiB

/// The minimum heap size is 4 MiB. The QuickJS engine needs a
/// reasonable amount of heap during initialisation for builtins,
/// global objects, and the bytecode compiler. This lives in the
/// identity-mapped snapshot region (NOT scratch).
const MIN_HEAP_SIZE: u64 = 4096 * 1024;

impl SandboxBuilder {
/// Create a new SandboxBuilder
pub fn new() -> Self {
let mut config = SandboxConfiguration::default();
config.set_stack_size(MIN_STACK_SIZE);
config.set_heap_size(MIN_HEAP_SIZE);
config.set_scratch_size(MIN_SCRATCH_SIZE);

Self {
config,
Expand Down Expand Up @@ -52,13 +71,14 @@ impl SandboxBuilder {
self
}

/// Set the guest stack size
/// This is the size of the stack that code executing in the guest can use.
/// If this value is too small then the guest will fail with a stack overflow error
/// The default value (and minimum) is set to the value of the MIN_STACK_SIZE const.
pub fn with_guest_stack_size(mut self, guest_stack_size: u64) -> Self {
if guest_stack_size > MIN_STACK_SIZE {
self.config.set_stack_size(guest_stack_size);
/// Set the guest scratch size in bytes.
/// The scratch region provides writable memory for the guest, including the
/// dynamically-sized stack. Increase this if your guest code needs deep
/// recursion or large local variables.
/// Values smaller than the default (288KiB) are ignored.
pub fn with_guest_scratch_size(mut self, guest_scratch_size: usize) -> Self {
if guest_scratch_size > MIN_SCRATCH_SIZE {
self.config.set_scratch_size(guest_scratch_size);
}
self
}
Expand Down
6 changes: 3 additions & 3 deletions src/hyperlight-js/tests/monitors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ fn wall_clock_monitor_sandbox_recovers_with_restore() {
assert!(loaded.poisoned(), "Should be poisoned after kill");

// Restore from snapshot
loaded.restore(&snapshot).unwrap();
loaded.restore(snapshot.clone()).unwrap();
assert!(!loaded.poisoned(), "Should not be poisoned after restore");

// Should be able to run again
Expand Down Expand Up @@ -162,7 +162,7 @@ fn cpu_time_monitor_sandbox_recovers_with_restore() {
assert!(loaded.poisoned(), "Should be poisoned after kill");

// Restore from snapshot
loaded.restore(&snapshot).unwrap();
loaded.restore(snapshot.clone()).unwrap();
assert!(!loaded.poisoned(), "Should not be poisoned after restore");

// Should be able to run again
Expand Down Expand Up @@ -237,7 +237,7 @@ fn tuple_monitor_sandbox_recovers_with_restore() {
assert!(loaded.poisoned(), "Should be poisoned after kill");

// Restore and verify recovery
loaded.restore(&snapshot).unwrap();
loaded.restore(snapshot.clone()).unwrap();
assert!(!loaded.poisoned(), "Should not be poisoned after restore");

let monitor2 = (
Expand Down
2 changes: 1 addition & 1 deletion src/hyperlight-js/tests/termination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fn handle_termination() -> Result<()> {
);

// Restore the sandbox from snapshot
loaded_sandbox.restore(&snapshot)?;
loaded_sandbox.restore(snapshot)?;

// Verify sandbox is no longer poisoned after restore
assert!(
Expand Down
11 changes: 5 additions & 6 deletions src/js-host-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ Creates and configures a new sandbox.

**Methods:**
- `setHeapSize(bytes: number)` → `this` — Set guest heap size (must be > 0, chainable)
- `setStackSize(bytes: number)` → `this` — Set guest stack size (must be > 0, chainable)
- `setScratchSize(bytes: number)` → `this` — Set guest scratch size, includes stack (must be > 0, chainable)
- `setInputBufferSize(bytes: number)` → `this` — Set guest input buffer size (must be > 0, chainable)
- `setOutputBufferSize(bytes: number)` → `this` — Set guest output buffer size (must be > 0, chainable)
- `build()` → `Promise<ProtoJSSandbox>` — Builds a proto sandbox ready to load the JavaScript runtime

```javascript
const builder = new SandboxBuilder()
.setHeapSize(8 * 1024 * 1024)
.setStackSize(512 * 1024);
.setScratchSize(1024 * 1024);
const protoSandbox = await builder.build();
```

Expand Down Expand Up @@ -237,9 +237,8 @@ All errors thrown by the API include a `code` property for programmatic handling
|------|---------|
| `ERR_INVALID_ARG` | Bad argument (empty handler name, zero timeout, etc.) |
| `ERR_CONSUMED` | Object already consumed (e.g., calling `loadRuntime()` twice) |
| `ERR_POISONED` | Sandbox is in an inconsistent state (after timeout kill, guest abort, etc.) — restore from snapshot or unload |
| `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_STACK_OVERFLOW` | Guest code caused a stack overflow |
| `ERR_GUEST_ABORT` | Guest code aborted |
| `ERR_INTERNAL` | Unexpected internal error |

Expand All @@ -251,8 +250,8 @@ try {
case 'ERR_CANCELLED':
console.log('Execution was cancelled');
break;
case 'ERR_STACK_OVERFLOW':
console.log('Stack overflow in guest code');
case 'ERR_POISONED':
console.log('Sandbox is poisoned (e.g. stack overflow, timeout)');
break;
default:
console.log(`Unexpected error [${error.code}]: ${error.message}`);
Expand Down
2 changes: 1 addition & 1 deletion src/js-host-api/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const { SandboxBuilder } = require('../lib.js');
async function main() {
const builder = new SandboxBuilder();
builder.setHeapSize(8 * 1024 * 1024); // Set heap size
builder.setStackSize(512 * 1024); // Set stack size
builder.setScratchSize(1024 * 1024); // Set scratch size (includes stack)
const protoSandbox = await builder.build(); // Build sandbox
}
main();
Expand Down
2 changes: 1 addition & 1 deletion src/js-host-api/examples/simple.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function main() {
console.log('1. Creating sandbox builder...');
const builder = new SandboxBuilder();
builder.setHeapSize(8 * 1024 * 1024); // 8MB heap
builder.setStackSize(512 * 1024); // 512KB stack
builder.setScratchSize(1024 * 1024); // 1MB scratch (includes stack)
console.log(' ✓ Builder configured\n');

// Step 2: Build the proto sandbox (async — returns a Promise)
Expand Down
7 changes: 6 additions & 1 deletion src/js-host-api/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@ ProtoJSSandbox.prototype.loadRuntime = wrapAsync(ProtoJSSandbox.prototype.loadRu
// SandboxBuilder — async build + sync setters
SandboxBuilder.prototype.build = wrapAsync(SandboxBuilder.prototype.build);

for (const method of ['setHeapSize', 'setStackSize', 'setInputBufferSize', 'setOutputBufferSize']) {
for (const method of [
'setHeapSize',
'setScratchSize',
'setInputBufferSize',
'setOutputBufferSize',
]) {
const orig = SandboxBuilder.prototype[method];
if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`);
SandboxBuilder.prototype[method] = wrapSync(orig);
Expand Down
27 changes: 11 additions & 16 deletions src/js-host-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ enum ErrorCode {
Poisoned,
/// Execution was cancelled by the host (monitor timeout or manual `kill()`).
Cancelled,
/// Guest stack overflow — increase stack size or reduce recursion depth.
StackOverflow,
/// Guest abort (trap, panic, or fatal error in guest code).
GuestAbort,
/// Invalid arguments (bad types, empty names, zero sizes).
Expand All @@ -98,7 +96,6 @@ impl ErrorCode {
match self {
Self::Poisoned => "ERR_POISONED",
Self::Cancelled => "ERR_CANCELLED",
Self::StackOverflow => "ERR_STACK_OVERFLOW",
Self::GuestAbort => "ERR_GUEST_ABORT",
Self::InvalidArg => "ERR_INVALID_ARG",
Self::Consumed => "ERR_CONSUMED",
Expand Down Expand Up @@ -144,7 +141,6 @@ fn to_napi_error(err: HyperlightError) -> napi::Error {
HyperlightError::PoisonedSandbox => ErrorCode::Poisoned,
HyperlightError::ExecutionCanceledByHost() => ErrorCode::Cancelled,
HyperlightError::JsonConversionFailure(_) => ErrorCode::InvalidArg,
HyperlightError::StackOverflow() => ErrorCode::StackOverflow,
HyperlightError::GuestAborted(_, _) => ErrorCode::GuestAbort,
_ => ErrorCode::Internal,
};
Expand Down Expand Up @@ -211,7 +207,7 @@ pub struct SnapshotWrapper {
/// ```js
/// const proto = await new SandboxBuilder()
/// .setHeapSize(8 * 1024 * 1024)
/// .setStackSize(512 * 1024)
/// .setScratchSize(1024 * 1024)
/// .build();
/// ```
#[napi(js_name = "SandboxBuilder")]
Expand Down Expand Up @@ -296,20 +292,21 @@ impl SandboxBuilderWrapper {
self.with_inner(|b| b.with_guest_input_buffer_size(size as usize))
}

/// Set the guest stack size in bytes.
/// Set the guest scratch size in bytes.
///
/// Controls how much stack space is available for guest code execution.
/// Deep recursion or large local variables need a bigger stack.
/// Controls how much scratch space (which includes the stack) is available
/// for guest code execution. Deep recursion or large local variables need
/// a bigger scratch region.
///
/// @param size - Stack size in bytes (must be > 0)
/// @param size - Scratch size in bytes (must be > 0)
/// @returns this (for chaining)
/// @throws If size is 0
#[napi]
pub fn set_stack_size(&self, size: u32) -> napi::Result<&Self> {
pub fn set_scratch_size(&self, size: u32) -> napi::Result<&Self> {
if size == 0 {
return Err(invalid_arg_error("Stack size must be greater than 0"));
return Err(invalid_arg_error("Scratch size must be greater than 0"));
}
self.with_inner(|b| b.with_guest_stack_size(size as u64))
self.with_inner(|b| b.with_guest_scratch_size(size as usize))
}

/// Set the guest heap size in bytes.
Expand Down Expand Up @@ -819,9 +816,7 @@ impl LoadedJSSandboxWrapper {
})
.await
.map_err(join_error)??;
Ok(SnapshotWrapper {
inner: Arc::new(snapshot),
})
Ok(SnapshotWrapper { inner: snapshot })
}

/// Restore the sandbox to a previously captured snapshot state.
Expand All @@ -843,7 +838,7 @@ impl LoadedJSSandboxWrapper {
let sandbox = guard
.as_mut()
.ok_or_else(|| consumed_error("LoadedJSSandbox"))?;
let result = sandbox.restore(&snap).map_err(to_napi_error);
let result = sandbox.restore(snap).map_err(to_napi_error);
poisoned_flag.store(sandbox.poisoned(), Ordering::Release);
result
})
Expand Down
6 changes: 3 additions & 3 deletions src/js-host-api/tests/sandbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('SandboxBuilder', () => {
const builder = new SandboxBuilder();
const result = builder
.setHeapSize(8 * 1024 * 1024)
.setStackSize(512 * 1024)
.setScratchSize(1024 * 1024)
.setInputBufferSize(4096)
.setOutputBufferSize(4096);
expect(result).toBe(builder);
Expand Down Expand Up @@ -53,9 +53,9 @@ describe('SandboxBuilder', () => {
expectThrowsWithCode(() => builder.setHeapSize(0), 'ERR_INVALID_ARG');
});

it('should reject zero stack size', () => {
it('should reject zero scratch size', () => {
const builder = new SandboxBuilder();
expectThrowsWithCode(() => builder.setStackSize(0), 'ERR_INVALID_ARG');
expectThrowsWithCode(() => builder.setScratchSize(0), 'ERR_INVALID_ARG');
});

it('should reject zero input buffer size', () => {
Expand Down