Skip to content

Commit a9d7102

Browse files
NathanFlurryclaude
andcommitted
feat: US-063 - Implement context snapshot creation with fully-initialized bridge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 357d094 commit a9d7102

1 file changed

Lines changed: 223 additions & 6 deletions

File tree

crates/v8-runtime/src/snapshot.rs

Lines changed: 223 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22

33
use std::sync::{Arc, Mutex};
44

5-
use crate::bridge::external_refs;
5+
use crate::bridge::{external_refs, register_stub_bridge_fns};
66
use crate::isolate::init_v8_platform;
7+
use crate::session::{SYNC_BRIDGE_FNS, ASYNC_BRIDGE_FNS};
78

89
/// Maximum allowed snapshot blob size (50MB).
910
/// Prevents resource exhaustion from degenerate bridge code.
1011
const MAX_SNAPSHOT_BLOB_BYTES: usize = 50 * 1024 * 1024;
1112

12-
/// Create a V8 startup snapshot with the given bridge code pre-compiled.
13+
/// Create a V8 startup snapshot with a fully-initialized bridge context.
1314
///
14-
/// Consumes a temporary isolate. The returned StartupData contains the
15-
/// serialized V8 heap with compiled bytecode.
15+
/// Registers stub bridge functions on the global, injects default config
16+
/// globals, then compiles and executes the bridge IIFE. The resulting
17+
/// context — with all bridge infrastructure set up — is snapshotted.
18+
///
19+
/// After restore, stub bridge functions are replaced with real session-local
20+
/// ones, and per-session config is injected via a post-restore script.
1621
///
1722
/// Returns an error if the bridge code fails to compile or the resulting
1823
/// snapshot exceeds MAX_SNAPSHOT_BLOB_BYTES.
@@ -25,7 +30,13 @@ pub fn create_snapshot(bridge_code: &str) -> Result<v8::StartupData, String> {
2530
let context = v8::Context::new(scope, Default::default());
2631
let scope = &mut v8::ContextScope::new(scope, context);
2732

28-
// Compile and run bridge code — bytecode is captured in snapshot
33+
// Register stub bridge functions so the IIFE can reference them
34+
register_stub_bridge_fns(scope, &SYNC_BRIDGE_FNS, &ASYNC_BRIDGE_FNS);
35+
36+
// Inject default config globals for bridge IIFE setup
37+
inject_snapshot_defaults(scope);
38+
39+
// Compile and run bridge code — context captures fully-initialized state
2940
let source = v8::String::new(scope, bridge_code)
3041
.ok_or_else(|| "failed to create V8 string for bridge code".to_string())?;
3142
let script = v8::Script::compile(scope, source, None)
@@ -50,6 +61,50 @@ pub fn create_snapshot(bridge_code: &str) -> Result<v8::StartupData, String> {
5061
Ok(blob)
5162
}
5263

64+
/// Inject default config globals needed by the bridge IIFE during snapshot creation.
65+
///
66+
/// These are placeholder values so bridge code that reads _processConfig or
67+
/// _osConfig at setup time doesn't fail. They're overwritten per-session
68+
/// after snapshot restore via inject_globals_from_payload.
69+
fn inject_snapshot_defaults(scope: &mut v8::HandleScope) {
70+
let context = scope.get_current_context();
71+
let global = context.global(scope);
72+
73+
// _processConfig: default placeholder (overwritten per-session)
74+
let pc_code = r#"({
75+
cwd: "/",
76+
env: {},
77+
timing_mitigation: "off",
78+
frozen_time_ms: null
79+
})"#;
80+
let pc_source = v8::String::new(scope, pc_code).unwrap();
81+
let pc_script = v8::Script::compile(scope, pc_source, None).unwrap();
82+
let pc_val = pc_script.run(scope).unwrap();
83+
if let Some(pc_obj) = pc_val.to_object(scope) {
84+
pc_obj.set_integrity_level(scope, v8::IntegrityLevel::Frozen);
85+
}
86+
let pc_key = v8::String::new(scope, "_processConfig").unwrap();
87+
let attr = v8::PropertyAttribute::READ_ONLY | v8::PropertyAttribute::DONT_DELETE;
88+
global.define_own_property(scope, pc_key.into(), pc_val, attr);
89+
90+
// _osConfig: default placeholder (overwritten per-session)
91+
let oc_code = r#"({
92+
homedir: "/root",
93+
tmpdir: "/tmp",
94+
platform: "linux",
95+
arch: "x64"
96+
})"#;
97+
let oc_source = v8::String::new(scope, oc_code).unwrap();
98+
let oc_script = v8::Script::compile(scope, oc_source, None).unwrap();
99+
let oc_val = oc_script.run(scope).unwrap();
100+
if let Some(oc_obj) = oc_val.to_object(scope) {
101+
oc_obj.set_integrity_level(scope, v8::IntegrityLevel::Frozen);
102+
}
103+
let oc_key = v8::String::new(scope, "_osConfig").unwrap();
104+
let attr2 = v8::PropertyAttribute::READ_ONLY | v8::PropertyAttribute::DONT_DELETE;
105+
global.define_own_property(scope, oc_key.into(), oc_val, attr2);
106+
}
107+
53108
/// Create a V8 isolate restored from a snapshot blob.
54109
///
55110
/// The external references must match those used during snapshot creation
@@ -519,7 +574,6 @@ mod tests {
519574
// resulting context can be snapshotted.
520575
{
521576
use crate::bridge::register_stub_bridge_fns;
522-
use crate::session::{SYNC_BRIDGE_FNS, ASYNC_BRIDGE_FNS};
523577

524578
let mut snapshot_isolate = v8::Isolate::snapshot_creator(Some(external_refs()), None);
525579
{
@@ -605,5 +659,168 @@ mod tests {
605659
);
606660
assert!(blob.unwrap().len() > 0, "snapshot blob should be non-empty");
607661
}
662+
663+
// --- Part 15: create_snapshot() auto-registers stubs and injects defaults ---
664+
// Verifies that create_snapshot() registers all bridge function stubs
665+
// and injects _processConfig/_osConfig defaults before running bridge code.
666+
{
667+
// Bridge IIFE that verifies stubs and config globals exist
668+
let iife_code = r#"
669+
(function() {
670+
// Verify all sync bridge functions are registered as stubs
671+
var syncFns = ['_log', '_error', '_resolveModule', '_loadFile',
672+
'_loadPolyfill', '_cryptoRandomFill', '_cryptoRandomUUID',
673+
'_fsReadFile', '_fsWriteFile', '_fsReadFileBinary',
674+
'_fsWriteFileBinary', '_fsReadDir', '_fsMkdir', '_fsRmdir',
675+
'_fsExists', '_fsStat', '_fsUnlink', '_fsRename', '_fsChmod',
676+
'_fsChown', '_fsLink', '_fsSymlink', '_fsReadlink', '_fsLstat',
677+
'_fsTruncate', '_fsUtimes', '_childProcessSpawnStart',
678+
'_childProcessStdinWrite', '_childProcessStdinClose',
679+
'_childProcessKill', '_childProcessSpawnSync'];
680+
for (var i = 0; i < syncFns.length; i++) {
681+
if (typeof globalThis[syncFns[i]] !== 'function') {
682+
throw new Error('Missing sync stub: ' + syncFns[i] +
683+
' (typeof=' + typeof globalThis[syncFns[i]] + ')');
684+
}
685+
}
686+
687+
// Verify all async bridge functions are registered as stubs
688+
var asyncFns = ['_dynamicImport', '_scheduleTimer',
689+
'_networkFetchRaw', '_networkDnsLookupRaw',
690+
'_networkHttpRequestRaw', '_networkHttpServerListenRaw',
691+
'_networkHttpServerCloseRaw'];
692+
for (var i = 0; i < asyncFns.length; i++) {
693+
if (typeof globalThis[asyncFns[i]] !== 'function') {
694+
throw new Error('Missing async stub: ' + asyncFns[i] +
695+
' (typeof=' + typeof globalThis[asyncFns[i]] + ')');
696+
}
697+
}
698+
699+
// Verify _processConfig default was injected
700+
if (typeof _processConfig !== 'object' || _processConfig === null) {
701+
throw new Error('_processConfig not injected: ' + typeof _processConfig);
702+
}
703+
if (_processConfig.cwd !== '/') {
704+
throw new Error('_processConfig.cwd should be "/", got: ' + _processConfig.cwd);
705+
}
706+
707+
// Verify _osConfig default was injected
708+
if (typeof _osConfig !== 'object' || _osConfig === null) {
709+
throw new Error('_osConfig not injected: ' + typeof _osConfig);
710+
}
711+
if (_osConfig.platform !== 'linux') {
712+
throw new Error('_osConfig.platform should be "linux", got: ' + _osConfig.platform);
713+
}
714+
715+
globalThis.__part15_ok = true;
716+
})();
717+
"#;
718+
let blob = create_snapshot(iife_code).expect(
719+
"create_snapshot should succeed with bridge code that checks stubs and defaults"
720+
);
721+
assert!(blob.len() > 0, "snapshot blob should be non-empty");
722+
723+
// Verify the snapshot can be restored
724+
let mut isolate = create_isolate_from_snapshot(blob, None);
725+
assert_eq!(eval(&mut isolate, "1 + 1"), "2");
726+
}
727+
728+
// --- Part 16: create_snapshot() with getter facade and closures ---
729+
// Verifies that the full bridge pattern (stubs, closures, getter facade,
730+
// config globals) works through create_snapshot() and the context is
731+
// correctly snapshotted via set_default_context.
732+
{
733+
let iife_code = r#"
734+
(function() {
735+
// Set up getter-based fs facade referencing bridge stubs
736+
var _fs = {};
737+
Object.defineProperties(_fs, {
738+
readFile: { get: function() { return globalThis._fsReadFile; }, enumerable: true },
739+
writeFile: { get: function() { return globalThis._fsWriteFile; }, enumerable: true },
740+
});
741+
globalThis._fs = _fs;
742+
743+
// Set up closure wrapping a bridge stub
744+
globalThis.myLog = function() {
745+
return globalThis._log.apply(null, arguments);
746+
};
747+
748+
// Set up a require-like function (doesn't call _loadPolyfill at setup)
749+
globalThis.require = function(name) {
750+
return globalThis._loadPolyfill(name);
751+
};
752+
753+
// Set up a console-like object
754+
globalThis.console = {
755+
log: function() { globalThis._log.apply(null, arguments); },
756+
error: function() { globalThis._error.apply(null, arguments); },
757+
};
758+
759+
// Read _processConfig at setup time (like process.cwd initialization)
760+
globalThis.__initialCwd = _processConfig.cwd;
761+
762+
globalThis.__part16_setup = true;
763+
})();
764+
"#;
765+
let blob = create_snapshot(iife_code).expect(
766+
"create_snapshot should succeed with full bridge IIFE pattern"
767+
);
768+
assert!(blob.len() > 0);
769+
770+
// Restore and verify default context has the bridge infrastructure
771+
let blob_bytes: Vec<u8> = blob.to_vec();
772+
let mut isolate = create_isolate_from_snapshot(blob_bytes, None);
773+
let scope = &mut v8::HandleScope::new(&mut isolate);
774+
let context = v8::Context::new(scope, Default::default());
775+
let scope = &mut v8::ContextScope::new(scope, context);
776+
777+
// Check that bridge infrastructure from the IIFE is in the default context
778+
let check_code = r#"
779+
(function() {
780+
var results = [];
781+
results.push('_fs=' + (typeof _fs === 'object'));
782+
results.push('_fs.readFile=' + (typeof _fs.readFile === 'function'));
783+
results.push('myLog=' + (typeof myLog === 'function'));
784+
results.push('require=' + (typeof require === 'function'));
785+
results.push('console.log=' + (typeof console.log === 'function'));
786+
results.push('console.error=' + (typeof console.error === 'function'));
787+
results.push('__initialCwd=' + __initialCwd);
788+
results.push('__part16_setup=' + __part16_setup);
789+
return results.join(';');
790+
})()
791+
"#;
792+
let source = v8::String::new(scope, check_code).unwrap();
793+
let script = v8::Script::compile(scope, source, None).unwrap();
794+
let result = script.run(scope).unwrap();
795+
let result_str = result.to_rust_string_lossy(scope);
796+
797+
assert_eq!(
798+
result_str,
799+
"_fs=true;_fs.readFile=true;myLog=true;require=true;console.log=true;console.error=true;__initialCwd=/;__part16_setup=true",
800+
"restored context should have all bridge infrastructure from the IIFE"
801+
);
802+
}
803+
804+
// --- Part 17: SnapshotCache works with context-snapshot create_snapshot ---
805+
// Verifies cache hit/miss still works now that create_snapshot registers stubs.
806+
{
807+
let cache = SnapshotCache::new(4);
808+
let code = r#"
809+
(function() {
810+
// Verify stubs are present (create_snapshot registers them)
811+
if (typeof _log !== 'function') throw new Error('no _log stub');
812+
if (typeof _processConfig !== 'object') throw new Error('no _processConfig');
813+
globalThis.__cached_context = true;
814+
})();
815+
"#;
816+
817+
let arc1 = cache.get_or_create(code).expect("first get_or_create");
818+
let arc2 = cache.get_or_create(code).expect("second get_or_create");
819+
assert!(Arc::ptr_eq(&arc1, &arc2), "cache hit should return same Arc");
820+
821+
// Verify blob is usable
822+
let mut isolate = create_isolate_from_snapshot((*arc1).clone(), None);
823+
assert_eq!(eval(&mut isolate, "1 + 1"), "2");
824+
}
608825
}
609826
}

0 commit comments

Comments
 (0)