diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 6a82443..64281bf 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -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); diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index 977fa51..a8a9678 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -790,6 +790,7 @@ impl JSSandboxWrapper { interrupt, poisoned_flag, last_call_stats: Arc::new(ArcSwapOption::empty()), + disposed_flag: Arc::new(AtomicBool::new(false)), }) } @@ -801,6 +802,20 @@ impl JSSandboxWrapper { pub fn poisoned(&self) -> napi::Result { 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 ────────────────────────────────────────────────── @@ -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>, + + /// 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, } #[napi] @@ -1013,11 +1033,13 @@ impl LoadedJSSandboxWrapper { #[napi] pub async fn unload(&self) -> napi::Result { 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 @@ -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 { + 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. @@ -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 { + 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. @@ -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 { - self.last_call_stats + pub fn last_call_stats(&self) -> napi::Result> { + 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. @@ -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 ─────────────────────────────────────────────── diff --git a/src/js-host-api/tests/sandbox.test.js b/src/js-host-api/tests/sandbox.test.js index 1505224..396ee07 100644 --- a/src/js-host-api/tests/sandbox.test.js +++ b/src/js-host-api/tests/sandbox.test.js @@ -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'); + }); +});