bao_engine 0.3.0

SpiderMonkey engine wrapper for Bao runtime
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
// @trace REQ-ENG-004
use ::std::cell::RefCell;
use ::std::collections::VecDeque;
use ::std::ffi::CString;
use ::std::os::raw::c_void;
use ::std::ptr;
use ::std::sync::atomic::{AtomicUsize, Ordering};
use ::std::sync::OnceLock;

use mozjs::glue::{CreateJobQueue, DeleteJobQueue, JobQueueTraps};
use mozjs::jsapi::*;
use mozjs::jsval::{JSVal, UndefinedValue};
use mozjs::realm::AutoRealm;
use mozjs::rooted;
use mozjs::rust::wrappers2::{RunJobs, SetJobQueue};

static JOB_COUNTER: AtomicUsize = AtomicUsize::new(0);

// ── Uncaught-exception / unhandled-rejection hooks ─────────────────────────
//
// bao_engine cannot depend on bao_runtime (dependency edge is the other way),
// so the runtime registers its exception router here after context init —
// same indirection pattern as `module_loader::set_job_queue_drain`.
//
// `uncaught`: invoked when a job's JS_CallFunctionValue failed — the pending
//             exception has been captured and cleared by the trap; the hook
//             routes it (process.on('uncaughtException') or print + exit 1).
// `flush`:    invoked at the run_jobs tail (job queue drained) so the runtime
//             can dispatch unhandled promise rejections on a clean stack.

pub type UncaughtExceptionHook = unsafe fn(cx: *mut JSContext, reason: JSVal);
pub type FlushRejectionsHook = unsafe fn(cx: *mut JSContext);

static UNCAUGHT_HOOK: OnceLock<UncaughtExceptionHook> = OnceLock::new();
static FLUSH_HOOK: OnceLock<FlushRejectionsHook> = OnceLock::new();

/// Register the runtime's exception router. Idempotent (first registration
/// wins — every bao_runtime context installs the same functions).
///
/// Contract (first-writer-wins by design):
/// - the first registration installs (release semantics unchanged);
/// - re-registration with the **same** fn pointers is an idempotent no-op
///   (the documented multi-context shape: every `bao_runtime` context
///   installs the same zero-capture router, the first one serves the whole
///   process);
/// - re-registration with a **different** fn pointer means the callers
///   genuinely diverged — surfaced fail-closed under `debug_assertions`;
///   the first writer stays installed, so release keeps plain
///   first-writer-wins.
pub fn set_uncaught_hooks(uncaught: UncaughtExceptionHook, flush: FlushRejectionsHook) {
    if let Err(incoming) = UNCAUGHT_HOOK.set(uncaught) {
        // `OnceLock::set` returns the REJECTED value in `Err` (the cell keeps
        // its first writer), so `incoming` is the late registration.
        let diverged = match UNCAUGHT_HOOK.get() {
            ::std::option::Option::Some(installed) => {
                !::std::ptr::fn_addr_eq(*installed, incoming)
            }
            // set() only fails when a value is installed; defensive default.
            ::std::option::Option::None => true,
        };
        debug_assert!(
            !diverged,
            "UNCAUGHT_HOOK re-registration diverged: contract expects every \
             bao_runtime context to install the SAME zero-capture exception \
             router (first-writer-wins by design); a differing fn pointer is \
             real semantic drift, not an idempotent re-register"
        );
    }
    if let Err(incoming) = FLUSH_HOOK.set(flush) {
        let diverged = match FLUSH_HOOK.get() {
            ::std::option::Option::Some(installed) => {
                !::std::ptr::fn_addr_eq(*installed, incoming)
            }
            // set() only fails when a value is installed; defensive default.
            ::std::option::Option::None => true,
        };
        debug_assert!(
            !diverged,
            "FLUSH_HOOK re-registration diverged: contract expects every \
             bao_runtime context to install the SAME zero-capture rejection \
             flusher (first-writer-wins by design); a differing fn pointer \
             is real semantic drift, not an idempotent re-register"
        );
    }
}

thread_local! {
    // Track job IDs in order — the actual JSObject* is stored as a global property
    // (keyed by the id) on the global that was current at enqueue time. The
    // global pointer is stored alongside the id because `run_jobs` may run
    // outside any realm (event-loop tick / ConcurrentTask dispatch), where
    // `CurrentGlobalOrNull(cx)` is NULL and the job's backing global cannot
    // be rediscovered (BCE-BUG-ENG-370 companion fix). A realm's global
    // outlives the realm's jobs and is kept alive by its realm (and every
    // live job object is itself rooted as a property of that global).
    static JOB_IDS: RefCell<VecDeque<(usize, *mut mozjs::jsapi::JSObject)>> =
        const { RefCell::new(VecDeque::new()) };
    static QUEUE_PTR: RefCell<*mut mozjs::jsapi::JobQueue> = const { RefCell::new(ptr::null_mut()) };
}

fn job_prop_name(id: usize) -> CString {
    CString::new(format!("__job_{}", id)).unwrap_or_default()
}

pub struct JobQueue;

impl JobQueue {
    pub fn init(cx: &mozjs::context::JSContext) -> bool {
        // SM153: promise reaction jobs enqueue into the engine-owned regular
        // microtask queue (no enqueuePromiseJob trap); runJobs drains both
        // that queue and bao's stored jobs. The interrupt-queue traps must be
        // real functions now — RustJobQueue's destructor and SavedQueue
        // bookkeeping call them unconditionally.
        let traps = JobQueueTraps {
            getHostDefinedData: Some(get_host_defined_data),
            getHostDefinedGlobal: Some(get_host_defined_global),
            runJobs: Some(run_jobs),
            traceNonGCThingMicroTask: Some(trace_non_gc_thing_microtask),
            pushNewInterruptQueue: Some(push_new_interrupt_queue),
            popInterruptQueue: Some(pop_interrupt_queue),
            dropInterruptQueues: Some(drop_interrupt_queues),
        };

        let queue = unsafe { CreateJobQueue(&traps, ptr::null(), ptr::null_mut()) };
        if queue.is_null() {
            return false;
        }

        QUEUE_PTR.with(|p| {
            *p.borrow_mut() = queue;
        });

        unsafe { SetJobQueue(cx, queue) }
        true
    }

    pub fn drain(cx: &mut mozjs::context::JSContext) {
        unsafe { RunJobs(cx) }
    }
}

impl Drop for JobQueue {
    fn drop(&mut self) {
        QUEUE_PTR.with(|p| {
            let ptr = *p.borrow();
            if !ptr.is_null() {
                unsafe { DeleteJobQueue(ptr) };
                *p.borrow_mut() = ptr::null_mut();
            }
        });
    }
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn enqueue_job(
    _queue: *const c_void,
    cx: *mut JSContext,
    _promise: Handle<*mut JSObject>,
    job: Handle<*mut JSObject>,
    _allocation_site: Handle<*mut JSObject>,
    _host_defined_data: Handle<*mut JSObject>,
) -> bool {
    let job_obj = *job.ptr;
    if job_obj.is_null() {
        return true;
    }

    let id = JOB_COUNTER.fetch_add(1, Ordering::Relaxed);
    let global = unsafe { CurrentGlobalOrNull(cx) };
    if global.is_null() {
        return true;
    }

    // Store job as a property on the global object — GC-safe
    let prop = job_prop_name(id);
    let mut wrapped_cx =
        mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
    rooted!(&in(wrapped_cx) let job_root = mozjs::jsval::ObjectValue(job_obj));
    rooted!(&in(wrapped_cx) let global_root = global);
    unsafe {
        JS_DefineProperty(
            cx,
            global_root.handle().into(),
            prop.as_ptr(),
            job_root.handle().into(),
            0,
        );
    }

    JOB_IDS.with(|q| {
        q.borrow_mut().push_back((id, global));
    });
    true
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn run_jobs(_queue: *const c_void, cx: *mut JSContext) {
    // SM153: fixpoint-drain BOTH sources — the engine's regular microtask
    // queue (promise reactions, engine jobs) and bao's stored jobs — running
    // one kind can enqueue more of the other. Ordering parity with SM140:
    // jobs run FIFO per source, interleaved to fixpoint, then the rejection
    // flush fires on a clean stack.
    loop {
        let mut progress = false;

        // (a) engine regular microtasks — promise reactions etc.
        while JS::HasRegularMicroTasks(cx) {
            progress = true;
            rooted!(in(cx) let task = JS::DequeueNextRegularMicroTask(cx));
            let task_val: Value = task.handle().get();
            let job = JS::ToMaybeWrappedJSMicroTask(&task_val);
            if job.is_null() {
                continue;
            }
            let global = JS::GetExecutionGlobalFromJSMicroTask(job);
            if global.is_null() {
                continue;
            }
            let mut wrapped_cx =
                mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
            let mut realm = AutoRealm::new(
                &mut wrapped_cx,
                ::std::ptr::NonNull::new_unchecked(global),
            );
            let realm_cx: &mut mozjs::context::JSContext = &mut realm;
            rooted!(&in(realm_cx) let job_root = job);
            unsafe {
                if !JS::RunJSMicroTask(cx, job_root.handle().into())
                    && JS_IsExceptionPending(cx)
                {
                    // A job threw. Capture the pending exception, clear it,
                    // and hand it to the runtime's uncaught-exception router
                    // (same contract as bao's stored-job throws below).
                    let mut exn = UndefinedValue();
                    JS_GetPendingException(
                        cx,
                        MutableHandle::<Value> {
                            _phantom_0: ::std::marker::PhantomData,
                            ptr: &mut exn,
                        },
                    );
                    JS_ClearPendingException(cx);
                    rooted!(&in(realm_cx) let reason_root = exn);
                    if !exn.is_undefined() {
                        if let Some(&hook) = UNCAUGHT_HOOK.get() {
                            hook(cx, exn);
                        }
                    }
                }
            }
        }

        // (b) one of bao's own stored jobs (queueMicrotask closures kept as
        // global properties).
        if run_one_bao_job(cx) {
            progress = true;
        }

        if !progress {
            break;
        }
    }

    // Job queue drained — dispatch unhandled promise rejections recorded by
    // the runtime's rejection tracker. Runs after every drain (all pump
    // paths funnel through this trap), on a clean JS stack.
    if let Some(&hook) = FLUSH_HOOK.get() {
        // SAFETY: cx is live (trap contract).
        unsafe { hook(cx) };
    }
}

/// Run a single job from bao's stored-job queue. Returns true when a job ran.
#[allow(unsafe_op_in_unsafe_fn)]
unsafe fn run_one_bao_job(cx: *mut JSContext) -> bool {
    {
        let job_entry = JOB_IDS.with(|q| q.borrow_mut().pop_front());
        let Some((id, global)) = job_entry else {
            return false;
        };

        if global.is_null() {
            return true;
        }

        // `run_jobs` is invoked from js::RunJobs which may fire outside any
        // realm (event-loop tick, ConcurrentTask dispatch) — cx->realm_ is
        // NULL there, so property access on `global` requires entering its
        // realm first. AutoRealm restores the (possibly NULL) previous realm
        // on drop.
        let prop = job_prop_name(id);
        let mut wrapped_cx =
            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
        let mut realm = AutoRealm::new(
            &mut wrapped_cx,
            ::std::ptr::NonNull::new_unchecked(global),
        );
        let realm_cx: &mut mozjs::context::JSContext = &mut realm;
        rooted!(&in(realm_cx) let global_root = global);
        let mut job_val = UndefinedValue();
        unsafe {
            // BCE (P0 browser startup panic, servo error.rs:74): the job pump
            // probes the per-thread global (servo Window in browser mode) for
            // the queued job closure. A failed JS_GetProperty (throwing
            // accessor / proxy hook) returns false WITH the exception
            // pending; the old code ignored the return, so the stale
            // exception leaked onto the ScriptThread context and detonated
            // servo's `assert!(!JS_IsExceptionPending)` in
            // `throw_dom_exception` on the next error path. Consume it — the
            // job reads as absent and is skipped.
            if !JS_GetProperty(
                cx,
                global_root.handle().into(),
                prop.as_ptr(),
                MutableHandle::<Value> {
                    _phantom_0: ::std::marker::PhantomData,
                    ptr: &mut job_val,
                },
            ) {
                JS_ClearPendingException(cx);
                return true;
            }
        }

        if !job_val.is_object() {
            return true;
        }

        let mut rval = UndefinedValue();
        rooted!(&in(realm_cx) let obj_root = global);
        rooted!(&in(realm_cx) let fval_root = job_val);
        let empty_args = HandleValueArray::empty();
        let rval_handle = MutableHandle::<Value> {
            _phantom_0: ::std::marker::PhantomData,
            ptr: &mut rval,
        };

        unsafe {
            let ok = JS_CallFunctionValue(
                cx,
                obj_root.handle().into(),
                fval_root.handle().into(),
                &empty_args,
                rval_handle,
            );
            if !ok {
                // The job threw. Capture the pending exception, clear it, and
                // hand it to the runtime's uncaught-exception router (Node:
                // a queueMicrotask/job throw is an uncaught exception — NOT
                // silently swallowed). `reason_root` keeps the value alive
                // across the hook's JS dispatch.
                let mut exn = UndefinedValue();
                JS_GetPendingException(
                    cx,
                    MutableHandle::<Value> {
                        _phantom_0: ::std::marker::PhantomData,
                        ptr: &mut exn,
                    },
                );
                JS_ClearPendingException(cx);
                rooted!(&in(realm_cx) let reason_root = exn);
                if !exn.is_undefined() {
                    if let Some(&hook) = UNCAUGHT_HOOK.get() {
                        // SAFETY: cx is live (trap contract); hook roots its
                        // argument before running JS.
                        unsafe { hook(cx, exn) };
                    }
                }
            }
        }

        // Clean up the property after execution
        unsafe {
            JS_DeleteProperty1(cx, global_root.handle().into(), prop.as_ptr());
        }

        true
    }
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn get_host_defined_data(
    _cx: *mut JSContext,
    incumbent_global: MutableHandle<*mut JSObject>,
    optional_host_defined_data: MutableHandle<*mut JSObject>,
) -> bool {
    incumbent_global.set(ptr::null_mut());
    optional_host_defined_data.set(ptr::null_mut());
    true
}

/// SM153 new trap: the host-defined global for the current execution.
/// bao mirrors its stored-job global semantics: the realm's own global.
#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn get_host_defined_global(
    cx: *mut JSContext,
    data: MutableHandle<*mut JSObject>,
) -> bool {
    data.set(unsafe { CurrentGlobalOrNull(cx) });
    true
}

/// SM153 new trap: GC tracing for non-GC-thing microtask values. bao's
/// microtask values are all GC-things (JS objects/closures), so there is
/// nothing non-GC to trace — the SM140 face had no counterpart at all.
#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn trace_non_gc_thing_microtask(
    _trc: *mut JSTracer,
    _value_ptr: *mut Value,
) {
}

// ── Debugger interrupt-queue stack (SM153 requires real traps) ──────────────
//
// SM140 let bao leave these traps as None; 153's RustJobQueue destructor and
// SavedQueue bookkeeping call them unconditionally. bao runs no debugger
// interrupt queues, so the stack hands out unique well-formed tokens and
// keeps the pop-matches-push contract the C++ SavedQueue asserts.
thread_local! {
    static INTERRUPT_QUEUES: ::std::cell::RefCell<Vec<*const c_void>> =
        const { ::std::cell::RefCell::new(::std::vec::Vec::new()) };
}
static INTERRUPT_TOKEN: ::std::sync::atomic::AtomicUsize = ::std::sync::atomic::AtomicUsize::new(1);

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn push_new_interrupt_queue(_a: *mut c_void) -> *const c_void {
    let token = INTERRUPT_TOKEN.fetch_add(1, Ordering::Relaxed) as *const c_void;
    INTERRUPT_QUEUES.with(|q| q.borrow_mut().push(token));
    token
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn pop_interrupt_queue(_a: *mut c_void) -> *const c_void {
    INTERRUPT_QUEUES.with(|q| q.borrow_mut().pop()).unwrap_or(ptr::null())
}

#[allow(unsafe_op_in_unsafe_fn)]
unsafe extern "C" fn drop_interrupt_queues(_a: *mut c_void) {
    INTERRUPT_QUEUES.with(|q| q.borrow_mut().clear());
}