Skip to content

Commit 535da52

Browse files
NathanFlurryclaude
andcommitted
feat: US-062 - Add stub bridge context and registration for snapshot creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 75e3766 commit 535da52

4 files changed

Lines changed: 226 additions & 4 deletions

File tree

crates/v8-runtime/src/bridge.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,43 @@ fn async_bridge_callback(
410410
rv.set(promise.into());
411411
}
412412

413+
/// Register stub bridge functions on the V8 global for snapshot creation.
414+
///
415+
/// Uses the same sync_bridge_callback / async_bridge_callback as real
416+
/// functions (required for ExternalReferences in snapshot serialization)
417+
/// but WITHOUT v8::External data. If a stub is accidentally called during
418+
/// snapshot creation, the callback gracefully throws a V8 exception
419+
/// (args.data() is not External -> "missing bridge function data" error).
420+
///
421+
/// After snapshot restore, these stubs are replaced with real functions
422+
/// that have proper External data pointing to a session-local BridgeCallContext.
423+
pub fn register_stub_bridge_fns(
424+
scope: &mut v8::HandleScope,
425+
sync_fns: &[&str],
426+
async_fns: &[&str],
427+
) {
428+
let context = scope.get_current_context();
429+
let global = context.global(scope);
430+
431+
// Register sync bridge functions as stubs (no External data)
432+
for &method_name in sync_fns {
433+
let template = v8::FunctionTemplate::builder(sync_bridge_callback)
434+
.build(scope);
435+
let func = template.get_function(scope).unwrap();
436+
let key = v8::String::new(scope, method_name).unwrap();
437+
global.set(scope, key.into(), func.into());
438+
}
439+
440+
// Register async bridge functions as stubs (no External data)
441+
for &method_name in async_fns {
442+
let template = v8::FunctionTemplate::builder(async_bridge_callback)
443+
.build(scope);
444+
let func = template.get_function(scope).unwrap();
445+
let key = v8::String::new(scope, method_name).unwrap();
446+
global.set(scope, key.into(), func.into());
447+
}
448+
}
449+
413450
/// Serialize V8 function arguments into a pre-allocated buffer.
414451
/// The buffer is cleared and reused across calls (grows to high-water mark).
415452
fn serialize_v8_args_into(scope: &mut v8::HandleScope, args: &v8::FunctionCallbackArguments, buf: &mut Vec<u8>) -> Result<(), String> {

crates/v8-runtime/src/host_call.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,41 @@ pub struct BridgeCallContext {
111111
call_id_router: Option<CallIdRouter>,
112112
}
113113

114+
/// No-op FrameSender for snapshot stub functions.
115+
/// Panics if called — stubs must never be invoked during snapshot creation.
116+
struct StubFrameSender;
117+
118+
impl FrameSender for StubFrameSender {
119+
fn send_frame(&self, _frame: &BinaryFrame) -> Result<(), String> {
120+
panic!("stub bridge function called during snapshot creation — bridge IIFE must not call bridge functions at setup time")
121+
}
122+
}
123+
124+
/// No-op ResponseReceiver for snapshot stub functions.
125+
/// Panics if called — stubs must never be invoked during snapshot creation.
126+
struct StubResponseReceiver;
127+
128+
impl ResponseReceiver for StubResponseReceiver {
129+
fn recv_response(&self) -> Result<BinaryFrame, String> {
130+
panic!("stub bridge function called during snapshot creation — bridge IIFE must not call bridge functions at setup time")
131+
}
132+
}
133+
114134
impl BridgeCallContext {
135+
/// Create a no-op BridgeCallContext for snapshot stub functions.
136+
/// Panics if sync_call or async_send is called — stubs exist only for
137+
/// the bridge IIFE to reference (not call) during snapshot creation.
138+
pub fn stub() -> Self {
139+
BridgeCallContext {
140+
sender: Box::new(StubFrameSender),
141+
response_rx: Mutex::new(Box::new(StubResponseReceiver)),
142+
session_id: "stub".into(),
143+
next_call_id: AtomicU64::new(1),
144+
pending_calls: Mutex::new(HashSet::new()),
145+
call_id_router: None,
146+
}
147+
}
148+
115149
/// Create a BridgeCallContext with a byte writer and reader (wraps in WriterFrameSender
116150
/// and ReaderResponseReceiver). Convenient for tests that pre-serialize BridgeResponse bytes.
117151
pub fn new(
@@ -637,4 +671,22 @@ mod tests {
637671
let decoded = ipc_binary::read_frame(&mut Cursor::new(&bytes)).expect("decode");
638672
assert_eq!(decoded, small);
639673
}
674+
675+
#[test]
676+
fn stub_context_panics_on_sync_call() {
677+
let ctx = BridgeCallContext::stub();
678+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
679+
let _ = ctx.sync_call("_fsReadFile", vec![]);
680+
}));
681+
assert!(result.is_err(), "stub sync_call should panic");
682+
}
683+
684+
#[test]
685+
fn stub_context_panics_on_async_send() {
686+
let ctx = BridgeCallContext::stub();
687+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
688+
let _ = ctx.async_send("_asyncFn", vec![]);
689+
}));
690+
assert!(result.is_err(), "stub async_send should panic");
691+
}
640692
}

crates/v8-runtime/src/session.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,8 +515,7 @@ fn session_thread(
515515
///
516516
/// Sync functions block V8 while the host processes the call (applySync/applySyncPromise).
517517
/// Async functions return a Promise to V8, resolved when the host responds (apply).
518-
#[cfg(not(test))]
519-
const SYNC_BRIDGE_FNS: [&str; 31] = [
518+
pub(crate) const SYNC_BRIDGE_FNS: [&str; 31] = [
520519
// Console
521520
"_log",
522521
"_error",
@@ -555,8 +554,7 @@ const SYNC_BRIDGE_FNS: [&str; 31] = [
555554
"_childProcessSpawnSync",
556555
];
557556

558-
#[cfg(not(test))]
559-
const ASYNC_BRIDGE_FNS: [&str; 7] = [
557+
pub(crate) const ASYNC_BRIDGE_FNS: [&str; 7] = [
560558
// Module loading (async)
561559
"_dynamicImport",
562560
// Timer

crates/v8-runtime/src/snapshot.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,5 +470,140 @@ mod tests {
470470
"_testAsync should be a function on restored isolate"
471471
);
472472
}
473+
474+
// --- Part 13: Register stub bridge functions on V8 global ---
475+
// Verifies that register_stub_bridge_fns places functions on the global
476+
// and that they have the correct typeof without calling them.
477+
{
478+
use crate::bridge::register_stub_bridge_fns;
479+
480+
// Use a snapshot-based isolate (consistent with other parts)
481+
let bridge_code = "/* stub test */";
482+
let blob = create_snapshot(bridge_code).expect("snapshot creation");
483+
let mut isolate = create_isolate_from_snapshot(blob, None);
484+
485+
let scope = &mut v8::HandleScope::new(&mut isolate);
486+
let context = v8::Context::new(scope, Default::default());
487+
let scope = &mut v8::ContextScope::new(scope, context);
488+
489+
register_stub_bridge_fns(
490+
scope,
491+
&["_log", "_error", "_fsReadFile", "_loadPolyfill"],
492+
&["_scheduleTimer", "_dynamicImport"],
493+
);
494+
495+
let check = v8::String::new(scope, r#"
496+
(function() {
497+
var names = ['_log', '_error', '_fsReadFile', '_loadPolyfill',
498+
'_scheduleTimer', '_dynamicImport'];
499+
for (var i = 0; i < names.length; i++) {
500+
if (typeof globalThis[names[i]] !== 'function') {
501+
return 'FAIL: ' + names[i] + ' is ' + typeof globalThis[names[i]];
502+
}
503+
}
504+
return 'OK';
505+
})()
506+
"#).unwrap();
507+
let script = v8::Script::compile(scope, check, None).unwrap();
508+
let result = script.run(scope).unwrap();
509+
assert_eq!(
510+
result.to_rust_string_lossy(scope),
511+
"OK",
512+
"all stub bridge functions should be registered as functions"
513+
);
514+
}
515+
516+
// --- Part 14: Bridge IIFE executes against stubs + snapshot creation ---
517+
// Verifies that setup-time code can reference stub functions (typeof,
518+
// closure wrapping, getter facade) without calling them, and that the
519+
// resulting context can be snapshotted.
520+
{
521+
use crate::bridge::register_stub_bridge_fns;
522+
use crate::session::{SYNC_BRIDGE_FNS, ASYNC_BRIDGE_FNS};
523+
524+
let mut snapshot_isolate = v8::Isolate::snapshot_creator(Some(external_refs()), None);
525+
{
526+
let scope = &mut v8::HandleScope::new(&mut snapshot_isolate);
527+
let context = v8::Context::new(scope, Default::default());
528+
let scope = &mut v8::ContextScope::new(scope, context);
529+
530+
// Register all 38 bridge functions as stubs (no External data)
531+
register_stub_bridge_fns(
532+
scope,
533+
&SYNC_BRIDGE_FNS,
534+
&ASYNC_BRIDGE_FNS,
535+
);
536+
537+
// Simulate bridge IIFE: reference all bridge functions, set up
538+
// closures and getter facade, but never call any bridge function
539+
let iife_code = r#"
540+
(function() {
541+
// Verify bridge functions exist (like ivm-compat shim)
542+
var syncKeys = ['_log', '_error', '_resolveModule', '_loadFile',
543+
'_cryptoRandomFill', '_fsReadFile', '_fsWriteFile',
544+
'_childProcessSpawnStart', '_childProcessSpawnSync'];
545+
var asyncKeys = ['_dynamicImport', '_scheduleTimer',
546+
'_networkFetchRaw', '_networkHttpServerListenRaw'];
547+
548+
for (var i = 0; i < syncKeys.length; i++) {
549+
if (typeof globalThis[syncKeys[i]] !== 'function') {
550+
throw new Error('Missing sync: ' + syncKeys[i]);
551+
}
552+
}
553+
for (var i = 0; i < asyncKeys.length; i++) {
554+
if (typeof globalThis[asyncKeys[i]] !== 'function') {
555+
throw new Error('Missing async: ' + asyncKeys[i]);
556+
}
557+
}
558+
559+
// Simulate getter-based fs facade (setup only, no calls)
560+
var _fs = {};
561+
Object.defineProperties(_fs, {
562+
readFile: { get: function() { return globalThis._fsReadFile; }, enumerable: true },
563+
writeFile: { get: function() { return globalThis._fsWriteFile; }, enumerable: true },
564+
});
565+
globalThis._fs = _fs;
566+
567+
// Verify getter returns function reference without calling it
568+
if (typeof _fs.readFile !== 'function') {
569+
throw new Error('Getter should return function, got ' + typeof _fs.readFile);
570+
}
571+
572+
// Simulate closure wrapping (setup only, no calls)
573+
globalThis.__wrappedLog = function() {
574+
return globalThis._log.apply(null, arguments);
575+
};
576+
577+
globalThis.__bridge_setup_complete = true;
578+
})();
579+
"#;
580+
let source = v8::String::new(scope, iife_code).unwrap();
581+
let script = v8::Script::compile(scope, source, None).unwrap();
582+
let result = script.run(scope);
583+
assert!(
584+
result.is_some(),
585+
"bridge IIFE should execute without error against stub functions"
586+
);
587+
588+
// Verify setup completed
589+
let check = v8::String::new(scope, "String(globalThis.__bridge_setup_complete)").unwrap();
590+
let script = v8::Script::compile(scope, check, None).unwrap();
591+
let val = script.run(scope).unwrap();
592+
assert_eq!(
593+
val.to_rust_string_lossy(scope),
594+
"true",
595+
"bridge setup should complete with stub functions"
596+
);
597+
598+
scope.set_default_context(context);
599+
}
600+
601+
let blob = snapshot_isolate.create_blob(v8::FunctionCodeHandling::Keep);
602+
assert!(
603+
blob.is_some(),
604+
"snapshot creation should succeed with stub bridge functions"
605+
);
606+
assert!(blob.unwrap().len() > 0, "snapshot blob should be non-empty");
607+
}
473608
}
474609
}

0 commit comments

Comments
 (0)