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}