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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// @trace TEST-ENG-001-EXECCTRL-ENTRY [req:REQ-ENG-001 REQ-CLI-001] [level:integration]
//
// SM-EVOLUTION #24/#25 S1 — ExecutionControl wired to the REAL bao_runtime
// script/module entries (`BaoRuntime::eval_with_control` /
// `eval_module_with_control` — the exact bodies `bao -e` / `bao run` use) plus
// the #25 scheduler ordering contract.
//
// Engine discipline: one BaoRuntime per #[test] (nextest runs each test in
// its own process; the JSEngine/JSContext are thread/process singletons).
//
// Contracts under test (ledger S1, 2026-09-10):
// 1. Module entry runaway (`while(true)` in module top level) under a
// deadline → deterministic TimedOut terminal state, prompt (<5s),
// deadline-sourced (≥400ms of a 500ms budget), runtime reusable after.
// 2. Script entry runaway killed by an EXTERNAL thread's cancel() →
// deterministic Cancelled terminal state, prompt.
// 3. Runaway inside a timer callback (the post-eval event-loop pump phase)
// is terminated too — the control is armed around the WHOLE entry.
// 4. Exit-code boundary contract: a termination surfaces as a real Err
// from the entry (→ the CLI's unchanged `Err` → exit-code-1 arm,
// bao_cli/src/cli.rs run_file/run_eval) and NOT as a silent
// process.exit(0): should_exit() stays false, exit_code() stays 0.
// 5. #25 scheduler ordering: sync body → microtasks (nextTick via
// queueMicrotask / promise continuations, FIFO by enqueue) → event-loop
// passes (due bao timers fire in deadline order with a microtask
// checkpoint after EACH callback — Node ≥11 / browser task semantics,
// verdict ① landed in the S1-续 round; see the two-timer case below).
use std::time::{Duration, Instant};
use bao_engine::execution_control::TerminalState;
use bao_engine::value::JsValue;
use bun_runtime::BaoRuntime;
fn eval_str(rt: &mut BaoRuntime, code: &str) -> String {
match rt.eval(code, "<execctrl-verify>") {
Ok(JsValue::String(s)) => s,
Ok(JsValue::Number(n)) => format!("{}", n),
Ok(v) => format!("{:?}", v),
Err(e) => format!("ERROR: {}", e.message),
}
}
/// 1. Module entry (the `bao run *.mjs` body) + deadline → TimedOut.
#[test]
fn module_entry_runaway_deadline_deterministic_timeout() {
let mut rt = BaoRuntime::new().expect("BaoRuntime");
let ctrl = rt.execution_control();
let start = Instant::now();
// try/catch inside the loop: the interrupt termination is UNCATCHABLE —
// if the engine let JS rescue it, this test would hang to the cap.
let result = rt.eval_module_with_control(
&ctrl,
"while (true) { try { } catch (e) { } }",
"runaway_timeout.mjs",
Some(Duration::from_millis(500)),
);
let elapsed = start.elapsed();
let err = result.expect_err("runaway module must be terminated, not completed");
assert!(
err.message.contains("deadline"),
"stable termination error expected, got: {}",
err.message
);
assert_eq!(err.filename, "<execution-control>");
assert_eq!(ctrl.terminal_state(), TerminalState::TimedOut);
assert!(
elapsed < Duration::from_secs(5),
"termination must be prompt, took {:?}",
elapsed
);
assert!(
elapsed >= Duration::from_millis(400),
"termination must come from the deadline, not an early abort: {:?}",
elapsed
);
// The runtime (context + persistent realm) survives the termination and
// is reusable — a killed entry must not poison the next one.
let after = rt.eval("'alive-' + (40 + 2)", "<after_termination.js>");
let val = after.expect("post-termination eval must succeed");
assert_eq!(val.as_string(), Some("alive-42"));
}
/// 2. Script entry (the `bao -e` / CJS `bao run` body) + external-thread
/// cancel() → Cancelled.
#[test]
fn script_entry_runaway_external_thread_cancel() {
let mut rt = BaoRuntime::new().expect("BaoRuntime");
let ctrl = rt.execution_control();
let remote = ctrl.clone();
// External thread may ONLY submit cancellation (atomic flag + the
// documented thread-safe interrupt request) — never a JSObject.
let canceller = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
remote.cancel();
});
let start = Instant::now();
// Generous deadline: if cancel were broken this would hang ~30s and the
// elapsed assertion below would fail long before that.
let result = rt.eval_with_control(
&ctrl,
"while (true) {}",
"cancel_runaway.js",
Some(Duration::from_secs(30)),
);
let elapsed = start.elapsed();
canceller.join().expect("canceller thread must not panic");
let err = result.expect_err("cancelled runaway script must terminate");
assert!(
err.message.contains("cancelled"),
"stable cancellation error expected, got: {}",
err.message
);
assert_eq!(ctrl.terminal_state(), TerminalState::Cancelled);
assert!(
elapsed < Duration::from_secs(5),
"cancellation must be prompt, took {:?}",
elapsed
);
// 4. Exit-code boundary: the termination is a real error out of the
// entry — NOT a silent process.exit. The CLI maps any entry Err to exit
// code 1 (its unchanged `Err(_) => Err(1)` arm); asserting
// should_exit()==false + exit_code()==0 here proves the failure flows
// through that error arm instead of masquerading as a clean exit 0.
assert!(
!bun_runtime::should_exit(),
"termination must surface as an entry error, not process.exit"
);
assert_eq!(
bun_runtime::exit_code(),
0,
"termination must not steer the process exit code away from the CLI error arm"
);
}
/// 3. Runaway inside a timer callback — killed during the post-eval
/// event-loop pump (drain_bao_timers' JS dispatch), proving the control
/// is armed around the WHOLE module entry, not just ModuleEvaluate.
#[test]
fn module_entry_timer_callback_runaway_terminated() {
let mut rt = BaoRuntime::new().expect("BaoRuntime");
let ctrl = rt.execution_control();
let start = Instant::now();
let result = rt.eval_module_with_control(
&ctrl,
r#"
globalThis.__fired = 'armed';
setTimeout(function () {
globalThis.__fired = 'in-callback';
while (true) { }
}, 25);
"#,
"timer_runaway.mjs",
Some(Duration::from_millis(600)),
);
let elapsed = start.elapsed();
let err = result.expect_err("runaway timer callback must be terminated");
assert!(
err.message.contains("deadline"),
"stable termination error expected, got: {}",
err.message
);
assert_eq!(ctrl.terminal_state(), TerminalState::TimedOut);
assert!(
elapsed < Duration::from_secs(5),
"termination must be prompt, took {:?}",
elapsed
);
// The timer really fired and really entered the runaway loop before the
// deadline killed it (distinguishes a mid-pump kill from the timer never
// having run).
assert_eq!(eval_str(&mut rt, "globalThis.__fired"), "in-callback");
}
/// Normal controlled module entry: completes fast with the right value and
/// does NOT block until its (unused) deadline; the same control is reusable
/// afterwards (reset-on-arm semantics).
#[test]
fn module_entry_controlled_normal_and_control_reuse() {
let mut rt = BaoRuntime::new().expect("BaoRuntime");
let ctrl = rt.execution_control();
let start = Instant::now();
let val = rt
.eval_module_with_control(
&ctrl,
"globalThis.__x = 6 * 7;",
"normal.mjs",
Some(Duration::from_secs(5)),
)
.expect("normal module under control must succeed");
let elapsed = start.elapsed();
assert_eq!(ctrl.terminal_state(), TerminalState::Completed);
assert!(
elapsed < Duration::from_secs(1),
"fast module must not block until its unused deadline, took {:?}",
elapsed
);
assert_eq!(eval_str(&mut rt, "String(globalThis.__x)"), "42");
// Reuse the SAME control for a second (timed-out, then normal) run:
// arm-time reset must clear the previous Completed latch, and a
// termination must not leak into a subsequent entry.
let r1 = rt.eval_module_with_control(
&ctrl,
"while (true) {}",
"reuse_runaway.mjs",
Some(Duration::from_millis(300)),
);
assert!(r1.is_err());
assert_eq!(ctrl.terminal_state(), TerminalState::TimedOut);
let r2 = rt.eval_module_with_control(
&ctrl,
"globalThis.__x = 'second';",
"reuse_normal.mjs",
Some(Duration::from_secs(5)),
);
r2.expect("post-timeout module must not be polluted by the previous termination");
assert_eq!(ctrl.terminal_state(), TerminalState::Completed);
assert_eq!(eval_str(&mut rt, "String(globalThis.__x)"), "second");
}
/// 5. #25 scheduler ordering contract (locked 2026-09-10, ledger S1;
/// timer interleaving updated by verdict ① in the S1-续 round):
///
/// sync body → microtask checkpoint (RunJobs right after script evaluate:
/// nextTick [enqueued via queueMicrotask], promise continuation,
/// queueMicrotask — FIFO by enqueue time) → event-loop passes
/// (drain_and_check: due bao timers fire in deadline order, with a
/// microtask checkpoint after EACH fired callback — Node ≥11 / browser
/// task semantics).
///
/// The two-timer case locks the per-callback checkpoint: t1's promise
/// continuation must run BEFORE t2 fires, even when both are due in the
/// same pass (heap order fires t1 first; the checkpoint is inside the
/// fire loop, so the assertion is timing-robust). Before verdict ① the
/// whole due batch fired first ("t1","t2","t1-cont" — Node ≤10 legacy).
///
/// Remaining recorded divergence (verdict ②, plan in the ledger, not
/// implemented): nextTick shares the microtask FIFO — Node runs a separate,
/// strictly-prior next-tick queue (drained fully before the microtask
/// queue at each checkpoint).
#[test]
fn scheduler_ordering_contract_microtasks_before_timers() {
let mut rt = BaoRuntime::new().expect("BaoRuntime");
rt.eval(
r#"
globalThis.__order = [];
const o = globalThis.__order;
process.nextTick(() => o.push('nextTick'));
Promise.resolve().then(() => o.push('microtask'));
queueMicrotask(() => o.push('qmt'));
setTimeout(() => {
o.push('timer');
Promise.resolve().then(() => o.push('timer-cont'));
}, 50);
setTimeout(() => {
o.push('t1');
Promise.resolve().then(() => o.push('t1-cont'));
}, 20);
setTimeout(() => {
o.push('t2');
}, 45);
o.push('sync');
"#,
"<ordering.js>",
)
.expect("ordering script must evaluate");
let order = eval_str(&mut rt, "JSON.stringify(globalThis.__order)");
assert_eq!(
order,
concat!(
"[\"sync\",\"nextTick\",\"microtask\",\"qmt\",",
"\"t1\",\"t1-cont\",\"t2\",",
"\"timer\",\"timer-cont\"]"
),
"scheduler ordering contract violated"
);
}