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
9 changes: 5 additions & 4 deletions src/js-host-api/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,19 @@ function wrapGetter(cls, prop) {
}

// LoadedJSSandbox — async methods
// Note: `poisoned` (AtomicBool read) and `interruptHandle` (Arc clone)
// are infallible getters — no wrapping needed.
for (const method of ['callHandler', 'unload', 'snapshot', 'restore']) {
for (const method of ['callHandler', 'unload', 'snapshot', 'restore', 'dispose']) {
const orig = LoadedJSSandbox.prototype[method];
if (!orig) throw new Error(`Cannot wrap missing method: LoadedJSSandbox.${method}`);
LoadedJSSandbox.prototype[method] = wrapAsync(orig);
}
wrapGetter(LoadedJSSandbox, 'poisoned');
wrapGetter(LoadedJSSandbox, 'interruptHandle');
wrapGetter(LoadedJSSandbox, 'lastCallStats');

// JSSandbox — async + sync methods + getters
JSSandbox.prototype.getLoadedSandbox = wrapAsync(JSSandbox.prototype.getLoadedSandbox);

for (const method of ['addHandler', 'removeHandler', 'clearHandlers']) {
for (const method of ['addHandler', 'removeHandler', 'clearHandlers', 'dispose']) {
const orig = JSSandbox.prototype[method];
if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`);
JSSandbox.prototype[method] = wrapSync(orig);
Expand Down
77 changes: 69 additions & 8 deletions src/js-host-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ impl JSSandboxWrapper {
interrupt,
poisoned_flag,
last_call_stats: Arc::new(ArcSwapOption::empty()),
disposed_flag: Arc::new(AtomicBool::new(false)),
})
}

Expand All @@ -801,6 +802,20 @@ impl JSSandboxWrapper {
pub fn poisoned(&self) -> napi::Result<bool> {
self.with_inner_ref(|sandbox| Ok(sandbox.poisoned()))
}

/// Eagerly release the underlying sandbox resources.
///
/// After calling `dispose()`, the sandbox is consumed and all
/// subsequent method calls will throw an `ERR_CONSUMED` error.
/// This is useful when you want deterministic cleanup rather than
/// waiting for garbage collection.
///
/// Calling `dispose()` on an already-consumed sandbox is a no-op.
#[napi]
pub fn dispose(&self) -> napi::Result<()> {
let _ = self.inner.lock().map_err(|_| lock_error())?.take();
Ok(())
}
}

// ── LoadedJSSandbox ──────────────────────────────────────────────────
Expand Down Expand Up @@ -853,6 +868,11 @@ pub struct LoadedJSSandboxWrapper {
/// closures (which require `'static + Send`). `ArcSwapOption` alone is
/// not `Clone` — the `Arc` provides cheap shared ownership across threads.
last_call_stats: Arc<ArcSwapOption<CallStats>>,

/// Tracks whether this wrapper has been consumed (via `dispose()` or
/// `unload()`), for lock-free checks in sync getters that don't
/// consult the inner Mutex.
disposed_flag: Arc<AtomicBool>,
}

#[napi]
Expand Down Expand Up @@ -1013,11 +1033,13 @@ impl LoadedJSSandboxWrapper {
#[napi]
pub async fn unload(&self) -> napi::Result<JSSandboxWrapper> {
let inner = self.inner.clone();
let disposed = self.disposed_flag.clone();
let js_sandbox = tokio::task::spawn_blocking(move || {
let mut guard = inner.lock().map_err(|_| lock_error())?;
let loaded = guard
.take()
.ok_or_else(|| consumed_error("LoadedJSSandbox"))?;
disposed.store(true, Ordering::Release);
loaded.unload().map_err(to_napi_error)
})
.await
Expand All @@ -1043,11 +1065,15 @@ impl LoadedJSSandboxWrapper {
/// the sandbox lock — it is always available instantly.
///
/// @returns An `InterruptHandle` with a `kill()` method
/// @throws `ERR_CONSUMED` if the sandbox has been consumed via `dispose()` or `unload()`
#[napi(getter)]
pub fn interrupt_handle(&self) -> InterruptHandleWrapper {
InterruptHandleWrapper {
inner: self.interrupt.clone(),
pub fn interrupt_handle(&self) -> napi::Result<InterruptHandleWrapper> {
if self.disposed_flag.load(Ordering::Acquire) {
return Err(consumed_error("LoadedJSSandbox"));
}
Ok(InterruptHandleWrapper {
inner: self.interrupt.clone(),
})
}

/// Whether the sandbox is in a poisoned (inconsistent) state.
Expand All @@ -1063,9 +1089,14 @@ impl LoadedJSSandboxWrapper {
/// Recovery options:
/// - `restore(snapshot)` — revert to a captured state
/// - `unload()` — discard handlers and start fresh
///
/// @throws `ERR_CONSUMED` if the sandbox has been consumed via `dispose()` or `unload()`
#[napi(getter)]
pub fn poisoned(&self) -> bool {
self.poisoned_flag.load(Ordering::Acquire)
pub fn poisoned(&self) -> napi::Result<bool> {
if self.disposed_flag.load(Ordering::Acquire) {
return Err(consumed_error("LoadedJSSandbox"));
}
Ok(self.poisoned_flag.load(Ordering::Acquire))
}

/// Execution statistics from the most recent `callHandler()` call.
Expand All @@ -1089,12 +1120,18 @@ impl LoadedJSSandboxWrapper {
/// if (stats.terminatedBy) console.log(`Killed by: ${stats.terminatedBy}`);
/// }
/// ```
///
/// @throws `ERR_CONSUMED` if the sandbox has been consumed via `dispose()` or `unload()`
#[napi(getter)]
pub fn last_call_stats(&self) -> Option<CallStats> {
self.last_call_stats
pub fn last_call_stats(&self) -> napi::Result<Option<CallStats>> {
if self.disposed_flag.load(Ordering::Acquire) {
return Err(consumed_error("LoadedJSSandbox"));
}
Ok(self
.last_call_stats
.load()
.as_ref()
.map(|arc| (**arc).clone())
.map(|arc| (**arc).clone()))
}

/// Capture the current sandbox state as a snapshot.
Expand Down Expand Up @@ -1150,6 +1187,30 @@ impl LoadedJSSandboxWrapper {
.await
.map_err(join_error)?
}

/// Eagerly release the underlying sandbox resources.
///
/// After calling `dispose()`, the sandbox is consumed and all
/// subsequent method calls will throw an `ERR_CONSUMED` error.
/// This is useful when you want deterministic cleanup rather than
/// waiting for garbage collection.
///
/// Calling `dispose()` on an already-consumed sandbox is a no-op.
#[napi]
pub async fn dispose(&self) -> napi::Result<()> {
if self.disposed_flag.load(Ordering::Acquire) {
return Ok(());
}
let inner = self.inner.clone();
let disposed = self.disposed_flag.clone();
tokio::task::spawn_blocking(move || {
let _ = inner.lock().map_err(|_| lock_error())?.take();
disposed.store(true, Ordering::Release);
Ok(())
})
.await
.map_err(join_error)?
}
}

// ── CallHandlerOptions ───────────────────────────────────────────────
Expand Down
137 changes: 137 additions & 0 deletions src/js-host-api/tests/sandbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,140 @@ describe('Calculator example', () => {
expect(result.result).toBe(4);
});
});

// ── dispose() ────────────────────────────────────────────────────────

describe('JSSandbox.dispose()', () => {
it('should make subsequent method calls throw ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();

sandbox.dispose();

expectThrowsWithCode(
() => sandbox.addHandler('h', 'function handler() {}'),
'ERR_CONSUMED'
);
});

it('should be a no-op when called twice', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();

sandbox.dispose();
sandbox.dispose(); // should not throw
});

it('should make poisoned getter throw ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();

sandbox.dispose();

expectThrowsWithCode(() => sandbox.poisoned, 'ERR_CONSUMED');
});
});

describe('LoadedJSSandbox.dispose()', () => {
it('should make subsequent callHandler reject with ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return { ok: true }; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.dispose();

await expectRejectsWithCode(loaded.callHandler('handler', {}), 'ERR_CONSUMED');
});

it('should be a no-op when called twice', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.dispose();
await loaded.dispose(); // should not throw
});

it('should make poisoned getter throw ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.dispose();

expectThrowsWithCode(() => loaded.poisoned, 'ERR_CONSUMED');
});

it('should make interruptHandle getter throw ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.dispose();

expectThrowsWithCode(() => loaded.interruptHandle, 'ERR_CONSUMED');
});

it('should make lastCallStats getter throw ERR_CONSUMED', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.dispose();

expectThrowsWithCode(() => loaded.lastCallStats, 'ERR_CONSUMED');
});
});

// ── unload() consumption ─────────────────────────────────────────────

describe('LoadedJSSandbox.unload()', () => {
it('should make poisoned getter throw ERR_CONSUMED after unload', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.unload();

expectThrowsWithCode(() => loaded.poisoned, 'ERR_CONSUMED');
});

it('should make interruptHandle getter throw ERR_CONSUMED after unload', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.unload();

expectThrowsWithCode(() => loaded.interruptHandle, 'ERR_CONSUMED');
});

it('should make lastCallStats getter throw ERR_CONSUMED after unload', async () => {
const builder = new SandboxBuilder();
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler('handler', 'function handler() { return {}; }');
const loaded = await sandbox.getLoadedSandbox();

await loaded.unload();

expectThrowsWithCode(() => loaded.lastCallStats, 'ERR_CONSUMED');
});
});
Loading