bao_engine 0.3.3

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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
// @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};
use mozjs::rust::{HandleObject, HandleValue};

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()
}

/// ABI-safe [`JS::DequeueNextRegularMicroTask`] — root cause of the W8 深修②
/// teardown AV (`destroyRuntime → GC → RootingContext::traceStackRoots`,
/// dangling exact stack root on every promise-job context, Windows-only).
///
/// The C++ API returns `JS::Value` **by value**. `JS::Value` has
/// user-provided constructors, so under the MSVC x64 ABI (clang-cl follows
/// ms_abi) it is returned through a **hidden sret slot**: the callee expects
/// `(RCX = Value* slot, RDX = cx)`. The bindgen declaration reads as
/// `fn(cx) -> Value`, so Rust passes `cx` in RCX and interprets RAX as the
/// value. Net effect of every call:
///   1. the callee writes the dequeued value through `*RCX == *cx` —
///      `cx + 0` is `RootingContext::stackRoots_[0]`, so the first exact
///      stack-root list head is clobbered with GC-value bits;
///   2. Rust "reads" the value as the sret slot pointer (a dead stack
///      address), and that garbage sits in the `task` root for the drain
///      iteration.
/// The next GC walks the corrupted `stackRoots_` list → AV in
/// `traceStackRoots`. This is the W8 sret class (509761cc:
/// `already_AddRefed<Stencil>` / `JS::PropertyKey`); this family landed with
/// SM153 前移 after that sweep, so it was missed there.
///
/// Fix: re-declare the SAME mangled symbol with the ABI-correct sret shape
/// and route through it. Itanium returns 8-byte trivially-copyable
/// aggregates in RAX, so the plain bindgen declaration stays correct on
/// non-msvc targets — hence the cfg split. (`DequeueNextDebuggerMicroTask` /
/// `PeekNextMicroTask` share the by-value shape but have zero call sites in
/// the bao tree today; they stay on the plain declaration.)
#[cfg(target_env = "msvc")]
unsafe fn dequeue_next_regular_micro_task_abi_safe(cx: *mut JSContext) -> Value {
    unsafe extern "C" {
        #[link_name = "\u{1}?DequeueNextRegularMicroTask@JS@@YA?AVValue@1@PEAUJSContext@@@Z"]
        fn dequeue_next_regular_micro_task_sret(slot: *mut Value, cx: *mut JSContext);
    }
    let mut slot: Value = ::std::mem::zeroed();
    unsafe {
        dequeue_next_regular_micro_task_sret(&mut slot, cx);
    }
    slot
}

#[cfg(not(target_env = "msvc"))]
unsafe fn dequeue_next_regular_micro_task_abi_safe(cx: *mut JSContext) -> Value {
    unsafe { JS::DequeueNextRegularMicroTask(cx) }
}

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;

        // (a0) ISSUE #25②: Node's nextTick queue — drains BEFORE the
        // microtask sources at every checkpoint (Node semantics: the
        // nextTick queue precedes the promise microtask queue, and
        // nextTick callbacks enqueued from within the phase drain in the
        // same phase). Capped per checkpoint (Node's tickDepth bound):
        // the remainder stays queued for the next checkpoint, so a
        // self-re-enqueueing callback cannot wedge the pump.
        drain_next_ticks(cx);

        // (a) engine regular microtasks — promise reactions etc.
        while JS::HasRegularMicroTasks(cx) {
            progress = true;
            rooted!(in(cx) let task = dequeue_next_regular_micro_task_abi_safe(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());
}

// ── Node nextTick queue (ISSUE #25②) ────────────────────────────────────────
//
// `process.nextTick` previously degraded to `queueMicrotask` (a
// `queueMicrotask(__nextTickCb)` eval), which put nextTick callbacks INTO
// the promise microtask queue — breaking Node's ordering contract
// (nextTick must precede Promise reactions within a checkpoint). This is
// the real form: an INDEPENDENT queue drained at the head of every
// checkpoint (the `run_jobs` (a0) arm above), recursive enqueues draining
// within the same phase, capped per checkpoint at Node's tickDepth bound
// (1000) so a self-re-enqueueing callback leaves the remainder for the
// next checkpoint instead of wedging the pump.
//
// Entries are rooted through the SM raw-root table (see below); a callback
// whose object was collected (its creation realm was discarded) is skipped
// at drain. Same thread as the owning JSContext —
// the queue is thread_local, never sent across threads.

const NEXT_TICK_DEPTH_CAP: usize = 1000;

// GC rooting contract: the queue entries hold their JS values in a
// RawValueRootGuard (the SM raw-root TABLE, traced by Nursery::traceRoots),
// NOT in bare Heap slots. A bare Heap slot on the Rust heap registers a
// store-buffer edge for its address but has no trace channel of its own —
// once the entry is popped and dropped, GC's tenuring pass dereferences the
// freed slot (SIGSEGV in StoreBuffer::CellPtrEdge::trace, observed live).
// The raw root table updates slots in place and keeps them alive until the
// guard drops (with the entry), which is the same contract RootedPromise /
// the fetch tasklet's promise_root use. Slot layout: 0 = the realm global
// (as a Value), 1 = the callback function (as a Value), 2.. = the extra
// arguments.
struct NextTickEntry {
    root: Option<crate::context::RawValueRootGuard>,
    n_args: usize,
}

thread_local! {
    static NEXT_TICK_QUEUE: ::std::cell::RefCell<VecDeque<NextTickEntry>> =
        const { ::std::cell::RefCell::new(VecDeque::new()) };
}

/// Enqueue a `process.nextTick(cb, ...args)` callback. Runs before the
/// promise microtask queue at the next checkpoint (see `run_jobs` (a0)).
/// Returns false when the raw-root registration failed (degraded host):
/// the callback is NOT queued (fail-closed, no unrooted entry).
pub fn next_tick_enqueue(
    cx: &mut mozjs::context::JSContext,
    global: ::mozjs::jsapi::Handle<*mut JSObject>,
    cb: ::mozjs::jsapi::Handle<*mut JSObject>,
    args: &[::mozjs::jsapi::Handle<JSVal>],
) -> bool {
    let mut vals: Vec<JSVal> = Vec::with_capacity(2 + args.len());
    vals.push(unsafe { ::mozjs::jsval::ObjectValue(*global.ptr) });
    vals.push(unsafe { ::mozjs::jsval::ObjectValue(*cb.ptr) });
    for arg in args {
        vals.push(unsafe { *arg.ptr });
    }
    let root = unsafe {
        crate::context::RawValueRootGuard::new(
            cx.raw_cx(),
            &vals,
            c"NextTickQueue.entry",
        )
    };
    let Some(root) = root else {
        return false;
    };
    NEXT_TICK_QUEUE.with(|q| {
        q.borrow_mut().push_back(NextTickEntry {
            root: Some(root),
            n_args: args.len(),
        })
    });
    true
}

/// Number of callbacks waiting in the nextTick queue.
pub fn next_tick_queue_len() -> usize {
    NEXT_TICK_QUEUE.with(|q| q.borrow().len())
}

/// Drain the nextTick queue NOW (the `process._tickCallback` face). The
/// checkpoint path goes through [`drain_next_ticks`] inside `run_jobs`.
/// Returns the number of callbacks that ran.
pub fn next_tick_drain(cx: &mut mozjs::context::JSContext) -> usize {
    unsafe { drain_next_ticks(cx.raw_cx()) }
}

/// Checkpoint drain (the `run_jobs` (a0) arm). Runs up to
/// [`NEXT_TICK_DEPTH_CAP`] callbacks; each callback runs in its captured
/// realm; a throw routes to the uncaught-exception hook (Node: a nextTick
/// throw is an uncaught exception), and a callback whose object was
/// GC-collected (dead realm) is skipped.
#[allow(unsafe_op_in_unsafe_fn)]
unsafe fn drain_next_ticks(cx: *mut JSContext) -> usize {
    let mut ran = 0usize;
    loop {
        if ran >= NEXT_TICK_DEPTH_CAP {
            return ran;
        }
        let Some(mut entry) = NEXT_TICK_QUEUE.with(|q| q.borrow_mut().pop_front()) else {
            return ran;
        };
        ran += 1;

        // The raw-root slots ARE the live copies (a moving GC updates them
        // in place); read the global/callback/args back out of the guard.
        let (global, cb, arg_vals) = match entry.root.as_ref() {
            Some(guard) => {
                let gval = guard.get(0);
                let cval = guard.get(1);
                let mut args_v = Vec::with_capacity(entry.n_args);
                for i in 0..entry.n_args {
                    args_v.push(guard.get(2 + i));
                }
                (
                    if gval.is_object() && !gval.is_null() {
                        unsafe { gval.to_object() }
                    } else {
                        ptr::null_mut()
                    },
                    if cval.is_object() && !cval.is_null() {
                        unsafe { cval.to_object() }
                    } else {
                        ptr::null_mut()
                    },
                    args_v,
                )
            },
            None => (ptr::null_mut(), ptr::null_mut(), Vec::new()),
        };
        if cb.is_null() || global.is_null() {
            // The callback or its realm was GC-collected (discarded realm):
            // nothing to run, nothing to report.
            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 global_root = global);
        rooted!(&in(realm_cx) let cb_root = cb);
        let arg_roots: Vec<::mozjs::jsapi::Heap<JSVal>> = arg_vals
            .iter()
            .copied()
            .map(|v| {
                let h = ::mozjs::jsapi::Heap::default();
                h.set(v);
                h
            })
            .collect();
        // HandleValueArray::elements_ is a plain `const Value*`: it must
        // point at the JSVal BODIES, contiguous. Heap<JSVal> is repr(C) over
        // UnsafeCell<JSVal> — its slot array IS the value array. (Routing it
        // through the Handle wrappers' bytes would feed SM pointer bit
        // patterns as Values — the args arrive as garbage doubles.)
        let argv = JS::HandleValueArray {
            length_: arg_roots.len(),
            elements_: arg_roots.as_ptr() as *const JSVal,
        };

        rooted!(&in(realm_cx) let cb_val = ::mozjs::jsval::ObjectValue(cb_root.get()));
        let mut rval = UndefinedValue();
        let ok = JS_CallFunctionValue(
            cx,
            global_root.handle().into(),
            cb_val.handle().into(),
            &argv,
            MutableHandle::<Value> {
                _phantom_0: ::std::marker::PhantomData,
                ptr: &mut rval,
            },
        );
        if !ok {
            // Same throw contract as the stored-job path above: capture,
            // clear, route to the uncaught-exception hook.
            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) };
                }
            }
        }
    }
}