1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// @trace REQ-ENG-011 [api:vm.createContext / vm.runInContext] [level:integration]
//
// GC rooting regression (SM-EVOLUTION #29; BCE class of the R2 opt-profile
// SIGSEVP / bao_browser `trace_node_realm_roots`): the vm context registry
// used to hold the sandbox realm's global as a raw `*mut JSObject` in a
// thread-local Vec — plain Rust memory invisible to SpiderMonkey's tracer.
// A full GC between `vm.createContext` and the next `vm.runInContext` could
// sweep the whole NewCompartmentAndZone realm; the next AutoRealm then
// dereferenced a freed zone (dev survives only by GC-timing luck, the
// opt-only SIGSEGV family). node_vm now carries the GC edge as a
// registered-symbol property on the sandbox object.
//
// This test drives the REAL production path across forced FULL GCs
// (JS_GC API reason), asserting: realms survive, realm identity persists
// (state set before the GC is readable after it), write-through still
// lands, fresh objects never false-positive isContext, and dropping the
// last sandbox reference keeps later contexts healthy (no permanent pin
// crash on collection).
use bao_engine::context::JsContext;
use bao_engine::value::JsValue;
fn eval_string(ctx: &mut JsContext, source: &str) -> String {
match ctx.eval(source, "<vm-gc-test>") {
Ok(JsValue::String(s)) => s,
Ok(JsValue::Number(n)) => format!("{}", n),
Ok(JsValue::Bool(b)) => if b { "true" } else { "false" }.to_string(),
Ok(other) => panic!("unexpected eval result: {:?}", other),
Err(e) => panic!("eval failed: {} ({}:{}:{})", e.message, e.filename, e.line, e.column),
}
}
/// Force a FULL GC on the context's runtime (mark + sweep across zones).
fn force_full_gc(ctx: &mut JsContext) {
unsafe { mozjs::jsapi::JS_GC(ctx.raw_cx(), mozjs::jsapi::JS::GCReason::API) };
}
#[test]
fn test_vm_context_survives_full_gc() {
bun_runtime::install_exit_handler();
bun_runtime::bun_api::init_process_start();
let mut ctx = JsContext::for_test().expect("JsContext");
ctx.set_global_setup(bun_runtime::globals::install_all);
// Phase 1: create three contexts and stamp realm-side state.
eval_string(
&mut ctx,
r#"
var vm = require('vm');
globalThis.sbs = [{a:1}, {b:2}, {c:3}];
for (var sb of globalThis.sbs) vm.createContext(sb);
vm.runInContext('globalThis.marker = "alive"; 1', globalThis.sbs[1]);
"#,
);
// The hazard window: full GCs with the sandboxes referenced only from
// JS (globalThis.sbs) and the realm global referenced only through the
// context edge. Without the edge, the realm zone was swept here.
force_full_gc(&mut ctx);
force_full_gc(&mut ctx);
// Phase 2: realms alive AND identical (marker survives) — a swept or
// re-created realm could not answer this.
let out = eval_string(
&mut ctx,
r#"
var ids = [
vm.runInContext('globalThis.marker', globalThis.sbs[1]),
vm.runInContext('a+10', globalThis.sbs[0]),
vm.runInContext('b+20', globalThis.sbs[1]),
vm.runInContext('c+30', globalThis.sbs[2]),
];
ids.join(',')
"#,
);
assert_eq!(out, "alive,11,22,33", "realm state must survive full GC");
// Phase 3: write-through (global → sandbox) still lands post-GC.
let out = eval_string(
&mut ctx,
r#"
vm.runInContext('var w = 7', globalThis.sbs[0]);
globalThis.sbs[0].w
"#,
);
assert_eq!(out, "7", "write-through must reach the sandbox after GC");
// Phase 4: sandbox → global sync still lands post-GC (properties added
// to the sandbox after createContext become realm globals).
let out = eval_string(
&mut ctx,
r#"
globalThis.sbs[2].late = 5;
vm.runInContext('late * 2', globalThis.sbs[2])
"#,
);
assert_eq!(out, "10", "sandbox→global sync must work after GC");
// Phase 5: fresh objects never false-positive isContext (the registry
// key used to be a raw address that dead entries could shadow).
let out = eval_string(
&mut ctx,
r#"
var junk = [];
for (var i = 0; i < 64; i++) junk.push({});
junk.every(function (o) { return !vm.isContext(o); })
"#,
);
assert_eq!(out, "true", "plain objects must not be reported as contexts");
// Phase 6: dropping the last sandbox reference releases the realms
// (Node vm lifetime: context lives as long as the sandbox). Later
// contexts stay healthy — collection must not corrupt the edge or the
// runtime.
let out = eval_string(
&mut ctx,
r#"
globalThis.sbs = null;
globalThis.junk = null;
var fresh = {};
vm.createContext(fresh);
vm.runInContext('41 + 1', fresh)
"#,
);
assert_eq!(out, "42", "fresh context after dropping dead ones must work");
// Final full GCs: collected realms must not crash tracing or later eval.
force_full_gc(&mut ctx);
force_full_gc(&mut ctx);
let out = eval_string(&mut ctx, "'post-gc:' + (6 * 7)");
assert_eq!(out, "post-gc:42");
}
#[test]
fn test_vm_gc_churn_many_contexts() {
bun_runtime::install_exit_handler();
bun_runtime::bun_api::init_process_start();
let mut ctx = JsContext::for_test().expect("JsContext");
ctx.set_global_setup(bun_runtime::globals::install_all);
// Churn: interleaved create/use/GC over many contexts — the shape a
// long-running process hits (per-request sandboxes). Every context
// must stay usable across the GCs that follow its creation.
for round in 0..4 {
let src = format!(
r#"
var vm = require('vm');
var round = {};
var sbs = [];
for (var i = 0; i < 8; i++) {{
var sb = {{ n: round * 100 + i }};
vm.createContext(sb);
sbs.push(sb);
}}
var acc = [];
for (var i = 0; i < sbs.length; i++)
acc.push(vm.runInContext('n + 1', sbs[i]));
acc.join(',')
"#,
round
);
let expected: Vec<String> = (0..8).map(|i| format!("{}", round * 100 + i + 1)).collect();
let out = eval_string(&mut ctx, &src);
assert_eq!(out, expected.join(","), "round {} pre-GC", round);
// The eval script's `var` bindings are script-scoped and gone with
// it — both the sandboxes and their realms are now unreachable, so
// this full GC collects all 8 realms (lifetime = sandbox lifetime,
// no permanent pin).
force_full_gc(&mut ctx);
let out = eval_string(&mut ctx, &format!("'r{}:' + {}", round, round + 1));
assert_eq!(out, format!("r{}:{}", round, round + 1));
}
}