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
24 changes: 24 additions & 0 deletions src/js-host-api/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,30 @@ for (const method of [
SandboxBuilder.prototype[method] = wrapSync(orig);
}

// setHostPrintFn needs a custom wrapper: the user's callback is wrapped in
// try/catch before it reaches the native layer, because exceptions thrown
// inside a Blocking ThreadsafeFunction escape as unhandled errors.
{
const origSetHostPrintFn = SandboxBuilder.prototype.setHostPrintFn;
if (!origSetHostPrintFn) {
throw new Error('Cannot wrap missing method: SandboxBuilder.setHostPrintFn');
}
SandboxBuilder.prototype.setHostPrintFn = wrapSync(function (callback) {
if (typeof callback !== 'function') {
// Forward non-function values to the native method for consistent
// validation errors (the Rust layer rejects non-callable arguments).
return origSetHostPrintFn.call(this, callback);
Comment on lines +220 to +222
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typeof callback !== 'function' branch is redundant/misleading: because the native signature expects a ThreadsafeFunction, non-callables will be rejected by napi argument conversion before Rust validation can run, so you won’t get a project-specific [ERR_*] code either way. Consider validating and throwing a clear JS error here, or remove the branch/comment to avoid implying the Rust layer will handle non-functions.

Suggested change
// Forward non-function values to the native method for consistent
// validation errors (the Rust layer rejects non-callable arguments).
return origSetHostPrintFn.call(this, callback);
throw new TypeError(
`SandboxBuilder.setHostPrintFn expects a function, received ${typeof callback}`
);

Copilot uses AI. Check for mistakes.
}
return origSetHostPrintFn.call(this, (msg) => {
Promise.resolve()
.then(() => callback(msg))
.catch((e) => {
console.error('Host print callback threw:', e);
});
});
});
}

// ── Re-export ────────────────────────────────────────────────────────

module.exports = native;
50 changes: 50 additions & 0 deletions src/js-host-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,56 @@ impl SandboxBuilderWrapper {
inner: Arc::new(Mutex::new(Some(proto_sandbox))),
})
}

/// Set a callback that receives guest `console.log` / `print` output.
///
/// Without this, guest print output is silently discarded. The callback
/// receives each print message as a string.
///
/// If the callback throws, the exception is caught by the JS wrapper
/// (`lib.js`) and logged to `console.error`. Guest execution continues.
///
/// @param callback - `(message: string) => void` — called for each print
/// @returns this (for chaining)
/// @throws If the builder has already been consumed by `build()`
#[napi]
pub fn set_host_print_fn(
&self,
#[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction<
String, // Rust → JS argument type
(), // JS return type (void)
String, // JS → Rust argument type (same — identity mapping)
Status, // Error status type
false, // Not CallerHandled (napi manages errors)
false, // Not accepting unknown return types
>,
) -> napi::Result<&Self> {
self.with_inner(|b| {
// Blocking mode ensures the TSFN dispatch completes before the
// native call returns, but the JS wrapper defers the user callback
// via a Promise microtask — so user code may run after guest
// execution resumes. From the guest's perspective, print is
// effectively fire-and-forget with no return value to await.
// Unlike host functions (which use NonBlocking + oneshot channel
// for async Promise resolution), print has no result path.
//
// **Reentrancy note:** The print callback ultimately runs while
// the sandbox Mutex is held (inside `call_handler`'s
// `spawn_blocking`). Calling Hyperlight APIs that acquire the
// same lock from within the callback (e.g. `snapshot()`,
// `restore()`, `unload()`) will deadlock. Keep print callbacks
// simple — logging only.
let print_fn = move |msg: String| -> i32 {
let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking);
if status == Status::Ok {
0
} else {
-1
}
};
b.with_host_print_fn(print_fn.into())
})
}
}

// ── ProtoJSSandbox ───────────────────────────────────────────────────
Expand Down
119 changes: 118 additions & 1 deletion src/js-host-api/tests/sandbox.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Basic sandbox functionality tests
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SandboxBuilder } from '../lib.js';
import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js';

Expand Down Expand Up @@ -308,3 +308,120 @@ describe('Calculator example', () => {
expect(result.result).toBe(4);
});
});

// ── Host print function ──────────────────────────────────────────────

describe('setHostPrintFn', () => {
it('should support method chaining', () => {
const builder = new SandboxBuilder();
const returned = builder.setHostPrintFn(() => {});
expect(returned).toBe(builder);
});

it('should receive console.log output from the guest', async () => {
const messages = [];
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
messages.push(msg);
});
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler(
'handler',
`function handler(event) {
console.log("Hello from guest!");
return event;
}`
);
const loaded = await sandbox.getLoadedSandbox();
await loaded.callHandler('handler', {});
// Flush microtasks — the print wrapper defers via Promise
await new Promise((r) => setTimeout(r, 0));

expect(messages.join('')).toContain('Hello from guest!');
});

it('should receive multiple console.log calls', async () => {
const messages = [];
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
messages.push(msg);
});
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler(
'handler',
`function handler(event) {
console.log("first");
console.log("second");
console.log("third");
return event;
}`
);
const loaded = await sandbox.getLoadedSandbox();
await loaded.callHandler('handler', {});
// Flush microtasks
await new Promise((r) => setTimeout(r, 0));

const combined = messages.join('');
expect(combined).toContain('first');
expect(combined).toContain('second');
expect(combined).toContain('third');
});

it('should use the last callback when set multiple times', async () => {
const firstMessages = [];
const secondMessages = [];
const builder = new SandboxBuilder()
.setHostPrintFn((msg) => firstMessages.push(msg))
.setHostPrintFn((msg) => secondMessages.push(msg));
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler(
'handler',
`function handler(event) {
console.log("which callback?");
return event;
}`
);
const loaded = await sandbox.getLoadedSandbox();
await loaded.callHandler('handler', {});

expect(firstMessages.length).toBe(0);
expect(secondMessages.join('')).toContain('which callback?');
});
Comment on lines +385 to +390
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts on secondMessages immediately after await loaded.callHandler(...), but the print wrapper defers the user callback via Promise.resolve().then(...) (see other tests here that yield with setTimeout(0)). Without a yield/flush here, this can be timing-dependent and flaky; add the same flush before the assertions (or make the print wrapper synchronous).

Copilot uses AI. Check for mistakes.

it('should continue guest execution when callback throws', async () => {
const builder = new SandboxBuilder().setHostPrintFn(() => {
throw new Error('print callback exploded');
});
const proto = await builder.build();
const sandbox = await proto.loadRuntime();
sandbox.addHandler(
'handler',
`function handler(event) {
console.log("this will throw in the callback");
return { survived: true };
}`
);
const loaded = await sandbox.getLoadedSandbox();

// Spy on console.error to suppress noise and verify the error is logged
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const result = await loaded.callHandler('handler', {});
// Flush microtasks — the print wrapper defers via Promise
await new Promise((r) => setTimeout(r, 0));

// The JS wrapper catches the throw — guest continues normally
expect(result.survived).toBe(true);
expect(errorSpy).toHaveBeenCalledWith('Host print callback threw:', expect.any(Error));
} finally {
errorSpy.mockRestore();
}
});

it('should throw CONSUMED after build()', async () => {
const builder = new SandboxBuilder();
await builder.build();
expectThrowsWithCode(() => builder.setHostPrintFn(() => {}), 'ERR_CONSUMED');
});
});
Loading