Skip to main content

rivet/preempt/
lifecycle.rs

1//! Task lifecycle: exit, join, stop (plan.md §5).
2//!
3//! A preemptive task whose entry *returns* lands in
4//! [`rivet_task_exit_core`] (the arch trampoline jumps there): the return
5//! value (≤ 8 bytes, carried in a0/a1 — larger returns need the hidden
6//! sret pointer which the trampoline cannot provide, so sizes > 8 are
7//! rejected at spawn) is stored type-erased in the TCB, the task is marked
8//! `exited`, and its joiner (if any) is woken. `TaskHandle::join` blocks
9//! until then and recovers the value, or reports [`JoinError::Faulted`]
10//! when the task was isolated by the fault policy (plan.md §3.4), or
11//! [`JoinError::Stale`] when the handle's generation no longer matches
12//! (the slot was recycled).
13
14use core::sync::atomic::Ordering;
15
16use super::sched;
17use super::tcb::{self, NO_TASK};
18
19/// Errors from [`super::TaskHandle::join`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum JoinError {
22    /// The task slot was recycled since this handle was created (stale
23    /// generation — ABA detection, plan.md §5.1).
24    Stale,
25    /// A task cannot join itself.
26    SelfJoin,
27    /// Another task is already joined to this one (one joiner per task).
28    AlreadyJoined,
29    /// The task was isolated by the fault policy before exiting (plan.md
30    /// §3.4); it produced no result.
31    Faulted,
32}
33
34/// Arch trampoline target: the task's entry returned; `val_lo`/`val_hi`
35/// carry the return value (or, for >8-byte results, `val_lo` is a pointer
36/// into this task's own stack). Stores the result, marks the task exited,
37/// wakes its joiner, and parks forever.
38#[no_mangle]
39pub extern "C" fn rivet_task_exit_core(val_lo: usize, val_hi: usize) -> ! {
40    if let Some(id) = sched::current() {
41        if let Some(t) = tcb::get(id) {
42            let size = t.result_size.load(Ordering::Acquire) as usize;
43            // SAFETY: the result buffer is only written here (once) and
44            // read by join() after `exited` is published; the task itself
45            // is the sole writer.
46            let buf = unsafe { &mut *t.result_buf.get() };
47            if size > 0 && size <= buf.len() {
48                if size <= 8 {
49                    let lo = val_lo.to_le_bytes();
50                    let hi = val_hi.to_le_bytes();
51                    for (i, b) in lo.iter().chain(hi.iter()).take(size).enumerate() {
52                        buf[i] = *b;
53                    }
54                } else {
55                    // val_lo points into this task's own stack.
56                    // SAFETY: own stack, `size` bytes initialized by the
57                    // caller before returning.
58                    let src = val_lo as *const u8;
59                    for (i, slot) in buf.iter_mut().take(size).enumerate() {
60                        // SAFETY: `i < size` and `src` is a valid pointer
61                        // into this task's own stack (the caller's sret
62                        // area), initialized before the entry returned.
63                        *slot = unsafe { core::ptr::read_volatile(src.add(i)) };
64                    }
65                }
66            }
67            // Publish the result, then the exited flag (join reads both),
68            // then wake the joiner — all under one critical section so a
69            // tick can't observe `exited` set but the joiner not yet
70            // woken (or worse, `unblock`'s `ready_add` interrupted
71            // mid-update, tearing `READY_BITMAP`/`QUEUES`; see
72            // `PriorityMutexGuard::drop`'s identical reasoning).
73            crate::critical::enter(|| {
74                t.exited.store(true, Ordering::Release);
75                let joiner = t.joiner.swap(NO_TASK, Ordering::AcqRel);
76                if joiner != NO_TASK {
77                    sched::unblock(joiner);
78                }
79            });
80        }
81    }
82    // Park forever (the slot stays used until explicitly despawned).
83    loop {
84        sched::block_current();
85        crate::port::arch::request_reschedule();
86    }
87}
88
89/// Cooperative cancellation (plan.md §5.4): poll this from the task's main
90/// loop; returns true once [`super::TaskHandle::request_stop`] was called
91/// on the current task.
92pub fn should_stop() -> bool {
93    sched::current()
94        .and_then(tcb::get)
95        .map(|t| t.stop_requested.load(Ordering::Acquire))
96        .unwrap_or(false)
97}
98
99/// Implementation of [`super::TaskHandle::join`].
100pub fn join_task<T: 'static + Send>(handle: &super::TaskHandle) -> Result<T, JoinError> {
101    let id = handle.id as usize;
102    let Some(t) = tcb::get(id) else {
103        return Err(JoinError::Stale);
104    };
105    if t.generation.load(Ordering::Acquire) != handle.generation {
106        return Err(JoinError::Stale);
107    }
108    let me = sched::current().unwrap_or(NO_TASK);
109    if Some(id) == sched::current() {
110        return Err(JoinError::SelfJoin);
111    }
112
113    // Register as the joiner (single-joiner support, documented).
114    //
115    // plan.md Phase 17 (found via soak testing at scale): the exit path's
116    // own `joiner.swap(NO_TASK)` is only a *correctness optimization* for
117    // the common case where the joiner registers before the target
118    // exits — it must not be the sole owner of clearing this field. If
119    // the target (often spawned at a *higher* priority, so it can run to
120    // completion before this task even reaches the CAS below) exits
121    // first, its swap drains a `joiner` that's still `NO_TASK` — a
122    // no-op — and this CAS then lands on a slot nobody will ever clear
123    // again, permanently poisoning it for the *next* occupant after a
124    // respawn. `register_full` now resets `joiner` unconditionally on
125    // every respawn (the real fix for that specific symptom), but this
126    // function still needs to release its *own* registration on every
127    // return path below — relying on the target's exit path to do it is
128    // exactly the assumption that was wrong.
129    if t.joiner
130        .compare_exchange(NO_TASK, me, Ordering::AcqRel, Ordering::Acquire)
131        .is_err()
132    {
133        return Err(JoinError::AlreadyJoined);
134    }
135
136    // Wait for exit (the exit path wakes us). The check-then-block
137    // sequence must be atomic w.r.t. a tick landing in between — same
138    // [B1] pattern as `PriorityMutex::lock_timeout` — or a tick that
139    // lands after the load but before `block_current()` marks this task
140    // Blocked *after* the target has already exited and called
141    // `unblock` on a still-Ready task (a no-op), producing a lost
142    // wakeup: this task then blocks itself with nothing left to ever
143    // wake it.
144    loop {
145        let must_wait = crate::critical::enter(|| {
146            if t.exited.load(Ordering::Acquire) {
147                false
148            } else {
149                sched::block_current();
150                true
151            }
152        });
153        if !must_wait {
154            break;
155        }
156        crate::port::arch::request_reschedule();
157    }
158
159    // Release our own registration now that we've observed the exit —
160    // `compare_exchange(me, NO_TASK)`, not a blind store: if the slot
161    // was somehow already recycled and re-registered by a new joiner
162    // underneath us (shouldn't happen given the checks above, but this
163    // makes it a no-op instead of clobbering someone else's claim).
164    let _ = t
165        .joiner
166        .compare_exchange(me, NO_TASK, Ordering::AcqRel, Ordering::Acquire);
167
168    // Recover the result. The buffer holds the bytes of a `T` written by
169    // the exit path; T's size was validated at spawn (≤ 8 bytes).
170    // SAFETY: the buffer is initialized (exited is published after the
171    // write, and we read after observing `exited` with Acquire); reading it
172    // as `T` matches the type written at spawn.
173    let size = t.result_size.load(Ordering::Acquire) as usize;
174    if size != core::mem::size_of::<T>() {
175        return Err(JoinError::Faulted);
176    }
177    // SAFETY: as above.
178    Ok(unsafe { core::ptr::read(t.result_buf.get() as *const T) })
179}