Skip to main content

rssn_advanced/runtime/
mod.rs

1//! Fiber-based task runtime built on `dtact`.
2//!
3//! `plan.md` §4.3 mandates an async-fiber interface, and the review found
4//! every async path (`ffi::async_bridge`, `parallel::solver`) still using
5//! heavyweight `std::thread::spawn`. This module funnels all task dispatch
6//! through `dtact`'s lock-free fiber pool, exposing three primitives:
7//!
8//! * [`ensure_runtime`](crate::runtime::ensure_runtime) — idempotent one-shot init of the global fiber pool.
9//! * [`spawn_task`](crate::runtime::spawn_task)     — fire-and-forget fiber for a `FnOnce() + Send`.
10//! * [`parallel_for_each`](crate::runtime::parallel_for_each) — fan-out / fan-in over an iterator of closures.
11//!
12//! ## Task-envelope memory strategy
13//!
14//! Each spawned fiber needs a heap allocation to carry its closure across
15//! thread boundaries (the spawning thread owns the closure; a dtact worker
16//! thread will read and execute it).  Routing every spawn through the global
17//! allocator (malloc / HeapAlloc) costs ~20–100 ns per task — enough to
18//! negate the scheduling advantage of fibers for small, rapid fan-outs.
19//!
20//! ### Lock-free pool (fast path)
21//!
22//! When a closure fits in `POOL_INLINE_CAPACITY` bytes and has alignment ≤
23//! 16, the closure is written into a pre-allocated `PoolNode` drawn from a
24//! global **ABA-safe Treiber stack** (`POOL_HEAD`).  The worker returns the
25//! node to the stack after moving the closure out — one `LOCK CMPXCHG8B`
26//! instead of a malloc + free.
27//!
28//! #### ABA safety
29//!
30//! A plain `AtomicPtr` Treiber stack suffers from ABA: if a node is popped,
31//! used for a task that completes and returns the node, all before the
32//! original thread's CAS fires, the stale `next` pointer silently wins and
33//! can corrupt the list.  We prevent this with a **tagged pointer**:
34//! `POOL_HEAD` is an `AtomicU64` whose top 16 bits hold a 16-bit generation
35//! tag (bottom 48 bits = pointer, always ≤ 48 bits on x86-64 without LA57).
36//! Each successful CAS increments the tag; a stale observer always sees a
37//! different tag and retries.  `AtomicU64::new(0)` is a stable `const fn`
38//! with no software-lock fallback on any 64-bit target.
39//!
40//! ### TaskEnvelope fallback (large / over-aligned closures)
41//!
42//! Closures that exceed `POOL_INLINE_CAPACITY` or require alignment > 16
43//! fall back to the monomorphised `TaskEnvelope<F>` + `Box::into_raw` path.
44//! These are rare in practice (typical captures: a few `Arc` / `usize` values,
45//! all ≤ 8-byte aligned).
46//!
47//! ### Shared trampoline
48//!
49//! Both paths store the monomorphised `invoke` pointer at **byte offset 0**
50//! (`#[repr(C)]` for `TaskEnvelope`, `repr(C, align(16))` with `word0` at
51//! offset 0 for `PoolNode`).  The single `task_trampoline` reads those bytes
52//! as `unsafe fn(*mut ())` and dispatches without knowing which path produced
53//! the pointer.
54
55#![allow(unsafe_code)]
56
57use std::os::raw::c_void;
58use std::ptr;
59use std::sync::OnceLock;
60use std::sync::atomic::{AtomicU64, Ordering};
61
62use dtact::c_ffi::{dtact_default_config, dtact_fiber_launch, dtact_handle_t, dtact_init};
63use dtact::dtact_await;
64
65use crate::error::{FfiError, cold_ffi_error_runtime_uninitialized};
66
67/// Marker returned by [`ensure_runtime`] so callers can prove the pool is
68/// alive without re-checking. Stored once in `RUNTIME_GATE` and copied
69/// freely thereafter.
70#[derive(Clone, Copy)]
71pub struct RuntimeGate {
72    _private: (),
73}
74
75/// Initialization sentinel. `dtact_init` itself uses `OnceLock` internally,
76/// but we wrap it again so that callers from this crate share a single
77/// thread-safe init path and never race on the `dtact_default_config()`
78/// argument construction.
79static RUNTIME_GATE: OnceLock<RuntimeGate> = OnceLock::new();
80
81/// Initializes the global `dtact` runtime on first call; subsequent calls
82/// are O(1) and return the same `RuntimeGate`.
83///
84/// Safe to call from any thread, including pre-`main` static init paths.
85pub fn ensure_runtime() -> RuntimeGate {
86    *RUNTIME_GATE.get_or_init(|| {
87        // SAFETY: `dtact_default_config` returns a fully-initialized config
88        // on every call, and `dtact_init` reads from the pointer only for
89        // the duration of the call.
90        let cfg = dtact_default_config();
91        let cfg_ptr: *const _ = &raw const cfg;
92        unsafe {
93            let _ = dtact_init(cfg_ptr);
94        }
95        // `dtact_init` only constructs the pool; worker threads are not
96        // launched until `Runtime::start()` is called. Without this the
97        // fiber pool would accept submissions but never schedule them
98        // and any `dtact_await` would block forever.
99        if let Some(rt) = dtact::GLOBAL_RUNTIME.get() {
100            rt.start();
101        }
102        RuntimeGate { _private: () }
103    })
104}
105
106/// Returns the active runtime gate if the pool is initialized.
107///
108/// FFI entry points use this to refuse work rather than implicitly start
109/// the runtime.
110///
111/// # Errors
112///
113/// Returns [`FfiError::RuntimeUninitialized`] if [`ensure_runtime`] has not
114/// been called yet on this process.
115pub fn runtime_gate() -> Result<RuntimeGate, FfiError> {
116    RUNTIME_GATE
117        .get()
118        .copied()
119        .map_or_else(cold_ffi_error_runtime_uninitialized, Ok)
120}
121
122// =========================================================================
123// Common C-ABI trampoline
124// =========================================================================
125//
126// Both dispatch paths (`PoolNode` and `TaskEnvelope<F>`) guarantee that the
127// `invoke` function pointer lives at byte offset 0 of the allocation that
128// `arg` points to.  The trampoline reads those bytes without knowing which
129// path produced `arg`, then calls the monomorphised handler.
130
131/// C-ABI trampoline for `dtact_fiber_launch`.
132///
133/// `arg` is either a `*mut PoolNode` (fast path) or a `*mut TaskEnvelope<F>`
134/// (fallback path).  In both cases `invoke` sits at byte offset 0 of the
135/// pointed-to memory (`word0` at offset 0 for `PoolNode`, `invoke` at offset
136/// 0 via `#[repr(C)]` for `TaskEnvelope`).  The called function takes
137/// ownership of the full allocation and frees or recycles it.
138extern "C" fn task_trampoline(arg: *mut c_void) {
139    // SAFETY: Both pool and envelope layouts place the invoke fn-ptr at
140    // offset 0.  The pointed-to memory remains valid until `invoke` frees it.
141    let invoke: unsafe fn(*mut ()) = unsafe { *arg.cast::<unsafe fn(*mut ())>() };
142    unsafe { invoke(arg.cast::<()>()) };
143}
144
145// =========================================================================
146// Fast path — ABA-safe lock-free pool via tagged-pointer Treiber stack
147// =========================================================================
148
149/// Maximum closure size (bytes) that uses the pooled fast path.
150///
151/// 64 bytes covers common captures: three `Arc<T>` fat pointers (24 B),
152/// a `usize` index (8 B), and a `*const` data pointer (8 B), with room to
153/// spare.  Closures larger than this fall back to [`TaskEnvelope`].
154const POOL_INLINE_CAPACITY: usize = 64;
155
156/// A reusable memory node for task dispatch.
157///
158/// **While on the free list** ([`POOL_HEAD`]):
159///   * `word0` holds the raw `*mut PoolNode` "next" pointer (null = tail).
160///   * `_word1` and `data` are logically uninitialized.
161///
162/// **While in-flight** (handed to a dtact fiber):
163///   * `word0` holds the monomorphised `invoke` trampoline pointer.
164///   * `_word1` is unused padding.
165///   * `data[0..size_of::<F>()]` holds the closure `F` written via
166///     `ptr::write`; the rest is logically uninitialized.
167///
168/// `#[repr(C, align(16))]` ensures:
169///   * `word0` is at byte offset 0 — the shared trampoline reads it as a
170///     function pointer without knowing whether this is a pool node or a
171///     `TaskEnvelope`.
172///   * `data` starts at byte offset 16, which is 16-byte aligned — meeting
173///     the alignment requirement of any closure capturing `Arc`, `usize`,
174///     `*const T`, or SIMD-compatible types up to 16-byte alignment.
175#[repr(C, align(16))]
176struct PoolNode {
177    /// Free-list `next` (null = tail) **or** in-flight `invoke` trampoline.
178    word0: usize,
179    /// Padding so `data` is at a 16-byte offset within the struct.
180    _word1: usize,
181    /// Inline closure storage (up to [`POOL_INLINE_CAPACITY`] bytes).
182    data: [u8; POOL_INLINE_CAPACITY],
183}
184
185// ── Tagged-pointer helpers ────────────────────────────────────────────────
186//
187// Layout of the u64 stored in POOL_HEAD:
188//
189//   bits 63..48  ┃  generation counter (u16, wraps at 65 536)
190//   bits 47.. 0  ┃  raw pointer (48 bits, canonical x86-64 user-space)
191//
192// `AtomicU128::new` is not yet a stable const fn, so it cannot appear in a
193// `static` initializer without nightly.  More critically, `AtomicU128` often
194// degrades to a software-lock fallback on platforms without `CMPXCHG16B`,
195// cancelling the lock-free guarantee we need.
196//
197// Instead we use a plain `AtomicU64` — always lock-free, always const-stable.
198// On x86-64 (without LA57 5-level paging), user-space canonical addresses
199// occupy ≤ 48 bits; the top 16 bits are always zero.  Windows does not yet
200// expose LA57 to user-space (as of 2026).  Those 16 free bits carry a
201// generation counter that defeats ABA.
202//
203// ABA analysis: 65 536 pop-use-push cycles must complete inside the ~3 ns
204// window between our `load` and `compare_exchange_weak`.  At 10⁶ cycles/s
205// that would take ≥ 65 ms.  Probability ≈ 3 ns / 65 ms ≈ 5 × 10⁻⁸ per
206// CAS — negligible.
207
208/// Head of the global ABA-resistant lock-free pool (tagged Treiber stack).
209///
210/// Zero-initialized → `ptr = null, gen = 0` → empty pool.
211/// `AtomicU64::new(0)` is a stable `const fn`; the CAS compiles to
212/// `LOCK CMPXCHG8B` on x86-64 — one instruction, no software lock.
213static POOL_HEAD: AtomicU64 = AtomicU64::new(0);
214
215/// Packs a pointer and 16-bit generation counter into one `u64`.
216///
217/// # Safety (caller contract)
218/// `ptr` must be a canonical user-space address whose top 16 bits are zero
219/// (standard x86-64 without LA57).  A `debug_assert` fires otherwise.
220#[inline(always)]
221fn pack(ptr: *mut PoolNode, tag: u16) -> u64 {
222    let bits = ptr as u64;
223    debug_assert_eq!(bits >> 48, 0, "pool ptr exceeds 48 bits — LA57 unsupported");
224    bits | (u64::from(tag) << 48)
225}
226
227/// Unpacks a `u64` CAS word into a `(pointer, generation)` pair.
228#[inline(always)]
229const fn unpack(val: u64) -> (*mut PoolNode, u16) {
230    let ptr = (val & 0x0000_FFFF_FFFF_FFFF) as *mut PoolNode;
231    let tag = (val >> 48) as u16;
232    (ptr, tag)
233}
234
235/// Allocates a fresh [`PoolNode`] via the global allocator.
236///
237/// Cold path: called only when the pool is empty.
238///
239/// The `Box` is intentional: callers immediately call `Box::into_raw` to
240/// produce a `*mut PoolNode` that the C trampoline owns; `Box::from_raw`
241/// reconstructs it after the fiber completes. A plain `PoolNode` return
242/// would force callers to perform their own `Box::new` anyway.
243#[cold]
244#[allow(clippy::unnecessary_box_returns)]
245fn alloc_pool_node() -> Box<PoolNode> {
246    Box::new(PoolNode {
247        word0: 0,
248        _word1: 0,
249        data: [0u8; POOL_INLINE_CAPACITY],
250    })
251}
252
253/// Pops a [`PoolNode`] from the global free list, or allocates one.
254///
255/// Uses a `compare_exchange_weak` loop on the tagged `POOL_HEAD`.  The
256/// 16-bit tag in bits 63..48 is incremented on every successful CAS, so a
257/// stale load (ABA) always causes the CAS to fail and retry.
258///
259/// The `Box` return is intentional: `spawn_task` immediately calls
260/// `Box::into_raw` to hand the raw pointer to `dtact_fiber_launch`; the
261/// C trampoline later reconstructs it with `Box::from_raw` in
262/// `invoke_and_drop_pooled`. A plain `PoolNode` return would only push
263/// the `Box::new` call into the caller.
264#[inline]
265#[allow(clippy::unnecessary_box_returns)]
266fn pool_acquire() -> Box<PoolNode> {
267    let mut head_val = POOL_HEAD.load(Ordering::Acquire);
268    loop {
269        let (head, tag) = unpack(head_val);
270        if head.is_null() {
271            return alloc_pool_node();
272        }
273        // SAFETY: `head` is non-null and was placed here by `pool_release`
274        // under Release ordering (visible under the Acquire load above).
275        // `word0` holds the free-list `next` pointer while the node is on
276        // the list — it was written before the Release CAS that added it.
277        let next = unsafe { (*head).word0 as *mut PoolNode };
278
279        // CAS: set head to (next, tag+1).  The tag bump ensures that if
280        // another thread cycles this node (pop → use → push) between our
281        // `load` and this CAS, the tag will have advanced and the CAS fails.
282        let new_val = pack(next, tag.wrapping_add(1));
283        match POOL_HEAD.compare_exchange_weak(
284            head_val,
285            new_val,
286            Ordering::AcqRel,
287            Ordering::Acquire,
288        ) {
289            // SAFETY: we won the CAS → exclusive ownership of `head`.
290            Ok(_) => return unsafe { Box::from_raw(head) },
291            Err(current) => head_val = current,
292        }
293    }
294}
295
296/// Pushes a [`PoolNode`] back onto the global free list.
297///
298/// The tag is incremented so that any thread holding a stale `head_val`
299/// cannot win a CAS against the new state.
300#[inline]
301fn pool_release(node: Box<PoolNode>) {
302    let node_ptr = Box::into_raw(node);
303    let mut head_val = POOL_HEAD.load(Ordering::Relaxed);
304    loop {
305        let (head, tag) = unpack(head_val);
306        // Write `next` *before* the CAS: the acquiring thread reads `word0`
307        // under Acquire ordering after winning its CAS, so this write must
308        // be visible via the Release fence on our CAS success.
309        // SAFETY: `node_ptr` is exclusively owned by this thread until the
310        // CAS succeeds.
311        unsafe { (*node_ptr).word0 = head as usize };
312
313        let new_val = pack(node_ptr, tag.wrapping_add(1));
314        match POOL_HEAD.compare_exchange_weak(
315            head_val,
316            new_val,
317            Ordering::Release,
318            Ordering::Relaxed,
319        ) {
320            Ok(_) => return,
321            Err(current) => head_val = current,
322        }
323    }
324}
325
326/// Monomorphised trampoline for the pooled fast path.
327///
328/// # Safety
329///
330/// `raw` must be a valid `*mut PoolNode` whose `data[0..size_of::<F>()]`
331/// holds an initialized `F` written by `ptr::write`.  After this call the
332/// node has been returned to the pool and `raw` must not be used again.
333unsafe fn invoke_and_drop_pooled<F: FnOnce() + Send + 'static>(raw: *mut ()) {
334    let node_ptr = raw.cast::<PoolNode>();
335    // Move the closure out of the inline storage BEFORE returning the node.
336    // After `ptr::read`, the bytes in `node.data` are owned by `f`; the node
337    // itself contains no live data and can be safely recycled.
338    //
339    // SAFETY: `data[0..size_of::<F>()]` was initialized via `ptr::write` in
340    // `spawn_task` and has not been touched since.
341    let f = unsafe { ptr::read((*node_ptr).data.as_ptr().cast::<F>()) };
342    // Return the (now-empty) node to the pool.  Another thread may claim and
343    // reuse it immediately — safe because `f` no longer refers to the node.
344    pool_release(unsafe { Box::from_raw(node_ptr) });
345    // Run the closure on the worker fiber.  Its destructor fires here.
346    f();
347}
348
349// =========================================================================
350// Fallback path — TaskEnvelope<F> (oversized / over-aligned closures)
351// =========================================================================
352
353/// Typed envelope for an oversized or over-aligned closure.
354///
355/// Avoids double-boxing (`Box<Box<dyn FnOnce()>>`): `TaskEnvelope<F>`
356/// monomorphises the trampoline, stores the closure inline, and uses a
357/// single `Box` (global allocator, thread-safe) for the one allocation.
358///
359/// `#[repr(C)]` ensures `invoke` is at offset 0 — the trampoline reads it
360/// from a type-erased `*mut c_void` without knowing `F`.
361#[repr(C)]
362struct TaskEnvelope<F: FnOnce() + Send + 'static> {
363    /// Monomorphised trampoline — must remain at offset 0 (`#[repr(C)]`).
364    invoke: unsafe fn(*mut ()),
365    /// The closure itself, stored inline after the function pointer.
366    closure: core::mem::ManuallyDrop<F>,
367}
368
369impl<F: FnOnce() + Send + 'static> TaskEnvelope<F> {
370    /// Reconstitutes the envelope from a type-erased pointer, frees the
371    /// `Box` allocation (global allocator — thread-safe), then runs the
372    /// closure.
373    ///
374    /// # Safety
375    ///
376    /// `raw` must be a valid `*mut TaskEnvelope<F>` produced by
377    /// `Box::into_raw`.  After this call the allocation is freed; `raw` must
378    /// not be used again.
379    unsafe fn invoke_and_drop(raw: *mut ()) {
380        let env_ptr = raw.cast::<Self>();
381        let f = unsafe { core::mem::ManuallyDrop::take(&mut (*env_ptr).closure) };
382        unsafe { drop(Box::from_raw(env_ptr)) };
383        f();
384    }
385}
386
387// =========================================================================
388// Public API — spawn / join
389// =========================================================================
390
391/// Opaque handle for a spawned task. Returned by [`spawn_task`] and
392/// consumed by [`join`].
393#[derive(Clone, Copy)]
394pub struct TaskHandle(dtact_handle_t);
395
396impl TaskHandle {
397    /// Returns the raw numeric id of the underlying fiber handle.
398    ///
399    /// Used by the async FFI bridge to stash the handle in a C-visible struct.
400    #[must_use]
401    pub const fn raw_id(self) -> u64 {
402        self.0.0
403    }
404
405    /// Reconstructs a `TaskHandle` from a raw id previously obtained via
406    /// [`Self::raw_id`].  The caller must ensure the id is still valid (i.e.,
407    /// the fiber has not been joined yet).
408    #[must_use]
409    pub const fn from_raw(id: u64) -> Self {
410        Self(dtact_handle_t(id))
411    }
412}
413
414/// Spawns `f` onto the fiber pool and returns a joinable [`TaskHandle`].
415///
416/// **Fast path** — closures ≤ `POOL_INLINE_CAPACITY` bytes and ≤ 16-byte
417/// alignment: drawn from the lock-free `POOL_HEAD` pool (one `LOCK CMPXCHG8B`
418/// pair, no malloc).
419///
420/// **Fallback path** — larger or over-aligned closures: one `Box` allocation
421/// via the global allocator, same as before the pool existed.
422///
423/// `dtact` dispatches the closure to whichever worker is currently coldest.
424/// Drop the handle if you don't need to wait — fibers run to completion
425/// regardless.  Call [`join`] to block until the fiber finishes.
426pub fn spawn_task<F: FnOnce() + Send + 'static>(_gate: RuntimeGate, f: F) -> TaskHandle {
427    if core::mem::size_of::<F>() <= POOL_INLINE_CAPACITY && core::mem::align_of::<F>() <= 16 {
428        // ── Fast path: lock-free pool ────────────────────────────────────
429        let mut node = pool_acquire();
430
431        // Write the monomorphised trampoline at word0 (offset 0).
432        // After this, `task_trampoline` can dispatch without knowing `F`.
433        node.word0 = invoke_and_drop_pooled::<F> as *const () as usize;
434
435        // Write the closure into the inline data buffer.
436        // SAFETY: `data` is at a 16-byte offset within `PoolNode`
437        // (`#[repr(C, align(16))]`), so it is 16-byte aligned.
438        // `align_of::<F>() ≤ 16` (checked above) means this satisfies `F`'s
439        // alignment requirement.  `size_of::<F>() ≤ POOL_INLINE_CAPACITY`
440        // (also checked above) means there is room for the closure.
441        unsafe {
442            ptr::write(node.data.as_mut_ptr().cast::<F>(), f);
443        }
444
445        let arg = Box::into_raw(node).cast::<c_void>();
446        // SAFETY: `task_trampoline` reads `word0` (== invoke) and calls it;
447        // `invoke_and_drop_pooled` moves out the closure then returns the node.
448        let handle = unsafe { dtact_fiber_launch(task_trampoline, arg) };
449        TaskHandle(handle)
450    } else {
451        // ── Fallback path: single Box allocation ─────────────────────────
452        let arg: *mut TaskEnvelope<F> = Box::into_raw(Box::new(TaskEnvelope {
453            invoke: TaskEnvelope::<F>::invoke_and_drop,
454            closure: core::mem::ManuallyDrop::new(f),
455        }));
456        // SAFETY: `task_trampoline` reads `arg.invoke` and calls it;
457        // `invoke_and_drop` takes back ownership and frees via `Box::from_raw`.
458        let handle = unsafe { dtact_fiber_launch(task_trampoline, arg.cast::<c_void>()) };
459        TaskHandle(handle)
460    }
461}
462
463/// Blocks the calling thread (or yields the calling fiber) until the task
464/// behind `handle` finishes.
465pub fn join(handle: TaskHandle) {
466    dtact_await(handle.0);
467}
468
469// =========================================================================
470// Fan-out / fan-in
471// =========================================================================
472
473/// Runs each closure in `tasks` on its own fiber and waits for all of them
474/// to finish before returning.  Closures produce a `T` which is collected
475/// into the returned `Vec` in input order.
476///
477/// Panics inside individual tasks are caught via [`std::panic::catch_unwind`]
478/// and mapped to `None` in the output; the returned `Vec` contains all slots
479/// including `None` values for panicking tasks, preserving input order.
480///
481/// Uses a lock-free write path: each fiber writes directly into its own
482/// pre-allocated slot in an `UnsafeCell<Vec<Option<T>>>` using the slot
483/// index as the exclusive key — no `Mutex` contention between workers.
484/// The fan-in join barrier (`dtact_await`) provides the happens-before
485/// edge that makes the final read of all slots safe.
486///
487/// This is the workhorse used by `parallel::solver` and `ffi::async_bridge`
488/// to replace the `std::thread::spawn` pattern.
489pub fn parallel_for_each<I, F, T>(gate: RuntimeGate, tasks: I) -> Vec<Option<T>>
490where
491    I: IntoIterator<Item = F>,
492    F: FnOnce() -> T + Send + 'static,
493    T: Send + 'static,
494{
495    use std::cell::UnsafeCell;
496    use std::sync::Arc;
497
498    let tasks: Vec<F> = tasks.into_iter().collect();
499    let n = tasks.len();
500
501    // SAFETY: `UnsafeCell<Vec<Option<T>>>` is not `Sync` by default.
502    // We assert it here because:
503    //   (a) fibers write to disjoint indices (no aliased mutable refs), and
504    //   (b) the join loop below provides the happens-before fence before
505    //       the caller ever reads from `slots`.
506    struct SendSync<T>(T);
507    unsafe impl<T: Send> Send for SendSync<T> {}
508    unsafe impl<T: Send> Sync for SendSync<T> {}
509
510    // Pre-allocate one slot per task. Each fiber owns exactly one index
511    // and writes to it without touching any other slot — no locking needed.
512    let slots: Arc<SendSync<UnsafeCell<Vec<Option<T>>>>> =
513        Arc::new(SendSync(UnsafeCell::new((0..n).map(|_| None).collect())));
514
515    let mut handles: Vec<TaskHandle> = Vec::with_capacity(n);
516    for (i, task) in tasks.into_iter().enumerate() {
517        let slots_arc = Arc::clone(&slots);
518        handles.push(spawn_task(gate, move || {
519            // Catch panics so one failing task doesn't abort the whole fan-out.
520            let value = std::panic::catch_unwind(std::panic::AssertUnwindSafe(task)).ok();
521            // SAFETY: `i` is unique per fiber; no two fibers share an index.
522            unsafe {
523                let vec_ptr: *mut Vec<Option<T>> = slots_arc.0.get();
524                (&mut *vec_ptr)[i] = value;
525            }
526        }));
527    }
528
529    for h in handles {
530        join(h);
531    }
532
533    // SAFETY: all fibers have been joined; we hold the only live reference
534    // to `slots`. Unwrapping the Arc gives exclusive access to the inner Vec.
535    let inner = Arc::try_unwrap(slots)
536        .unwrap_or_else(|_| unreachable!("all fibers have been joined; Arc is unique"));
537    inner.0.into_inner()
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use std::sync::Arc;
544    use std::sync::atomic::{AtomicUsize, Ordering};
545
546    #[test]
547    fn ensure_runtime_is_idempotent() {
548        let g1 = ensure_runtime();
549        let g2 = ensure_runtime();
550        let _ = (g1, g2);
551        assert!(runtime_gate().is_ok());
552    }
553
554    #[test]
555    fn spawn_task_runs_closure() {
556        let gate = ensure_runtime();
557        let counter = Arc::new(AtomicUsize::new(0));
558        let c = Arc::clone(&counter);
559        let h = spawn_task(gate, move || {
560            c.fetch_add(7, Ordering::Release);
561        });
562        join(h);
563        assert_eq!(counter.load(Ordering::Acquire), 7);
564    }
565
566    #[test]
567    fn parallel_for_each_preserves_order_and_runs_all() {
568        let gate = ensure_runtime();
569        let results = parallel_for_each(gate, (0u32..8).map(|i| move || i * i));
570        assert_eq!(
571            results,
572            alloc::vec![
573                Some(0),
574                Some(1),
575                Some(4),
576                Some(9),
577                Some(16),
578                Some(25),
579                Some(36),
580                Some(49)
581            ]
582        );
583    }
584
585    #[test]
586    fn runtime_gate_reports_uninit_only_before_init() {
587        let _ = ensure_runtime();
588        assert!(runtime_gate().is_ok());
589    }
590
591    /// Verifies pool recycling: two waves of tasks must produce correct results.
592    ///
593    /// A double-free or use-after-free in the pool would manifest as a wrong
594    /// counter value or a panic inside the task (Rust's allocator detects
595    /// double-frees in debug builds).
596    #[test]
597    fn pool_recycles_nodes_across_waves() {
598        let gate = ensure_runtime();
599        let counter = Arc::new(AtomicUsize::new(0));
600
601        let wave1: Vec<_> = (0..16)
602            .map(|_| {
603                let c = Arc::clone(&counter);
604                spawn_task(gate, move || {
605                    c.fetch_add(1, Ordering::Release);
606                })
607            })
608            .collect();
609        for h in wave1 {
610            join(h);
611        }
612        assert_eq!(
613            counter.load(Ordering::Acquire),
614            16,
615            "wave 1 must run all tasks"
616        );
617
618        let wave2: Vec<_> = (0..16)
619            .map(|_| {
620                let c = Arc::clone(&counter);
621                spawn_task(gate, move || {
622                    c.fetch_add(1, Ordering::Release);
623                })
624            })
625            .collect();
626        for h in wave2 {
627            join(h);
628        }
629        assert_eq!(
630            counter.load(Ordering::Acquire),
631            32,
632            "wave 2 must run all tasks"
633        );
634    }
635
636    /// Verifies the fallback path for closures that exceed the inline
637    /// capacity (a large array forces `size_of::<F>() > POOL_INLINE_CAPACITY`).
638    #[test]
639    fn oversized_closure_uses_fallback_path() {
640        let gate = ensure_runtime();
641        // `[u8; 256]` exceeds `POOL_INLINE_CAPACITY` (64), forcing the
642        // `TaskEnvelope` / `Box` fallback path.
643        let big: [u8; 256] = [42u8; 256];
644        let counter = Arc::new(AtomicUsize::new(0));
645        let c = Arc::clone(&counter);
646        let h = spawn_task(gate, move || {
647            // Access the capture so the compiler includes it in the closure.
648            c.fetch_add(big[0] as usize, Ordering::Release);
649        });
650        join(h);
651        assert_eq!(counter.load(Ordering::Acquire), 42);
652    }
653}
654
655#[cfg(test)]
656extern crate alloc;