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;