Compare commits
21
Commits
7746dca69b
...
arm-port
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fb13e29a | ||
|
|
d908eb3f95 | ||
|
|
024abc2e73 | ||
|
|
518103750b | ||
|
|
a7832f4a8c | ||
|
|
55221e9e98 | ||
|
|
02f55fa0a0 | ||
|
|
2dd61d4a19 | ||
|
|
70bd237277 | ||
|
|
8ac11a57ac | ||
|
|
59998ce79e | ||
|
|
68c7c96749 | ||
|
|
c6136b2553 | ||
|
|
d9a6520a24 | ||
|
|
09236b8bf4 | ||
|
|
461de2c451 | ||
|
|
14dc7a79cf | ||
|
|
7d44b20baf | ||
|
|
6d17254ae3 | ||
|
|
594392a5ae | ||
|
|
389ddec56d |
@@ -0,0 +1,2 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "smarm"
|
name = "smarm"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Tokio Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -25,7 +25,10 @@ convenience wrapper around `runtime::init(Config::exact(1)).run(f)`.
|
|||||||
| `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread |
|
| `mutex` | `Mutex<T>` with mandatory timeout; FIFO waiters; parks the green thread |
|
||||||
| `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons |
|
| `timer` | Min-heap of `(deadline, reason)`; `Sleep` and `WaitTimeout` reasons |
|
||||||
| `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll |
|
| `io` | `block_on_io` for blocking work; `wait_readable`/`wait_writable` + `read`/`write` via epoll |
|
||||||
| `supervisor` | `Signal::Exit` / `Signal::Panic` delivered to a parent actor's mailbox |
|
| `supervisor` | `Signal::Exit`/`Panic`/`Stopped` funnelled to a parent; `OneForOne`/`OneForAll`/`RestForOne` strategies + restart-intensity cap |
|
||||||
|
| `monitor` | `monitor(pid)` → `Monitor { id, target, rx }`; one-shot `Down` via `rx`; `demonitor(&m)` tears one registration down; unidirectional death notice |
|
||||||
|
| `link` | bidirectional `link`/`unlink`; abnormal death propagates (cooperative stop, or an `ExitSignal` message under `trap_exit`) |
|
||||||
|
| `gen_server` | `call` (sync request-reply) / `cast` (async) over one inbox; `ServerRef` + `init`/`terminate` hooks; server-down via channel closure |
|
||||||
|
|
||||||
## Quick taste
|
## Quick taste
|
||||||
|
|
||||||
@@ -52,8 +55,9 @@ run(|| {
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
stack.rs context.rs preempt.rs pid.rs actor.rs
|
stack.rs context.rs preempt.rs pid.rs actor.rs
|
||||||
scheduler.rs channel.rs mutex.rs timer.rs io.rs supervisor.rs
|
scheduler.rs channel.rs mutex.rs timer.rs io.rs
|
||||||
lib.rs
|
supervisor.rs monitor.rs link.rs runtime.rs
|
||||||
|
gen_server.rs lib.rs
|
||||||
tests/
|
tests/
|
||||||
per-module integration tests
|
per-module integration tests
|
||||||
benches/
|
benches/
|
||||||
@@ -63,8 +67,10 @@ benches/
|
|||||||
## Building and running
|
## Building and running
|
||||||
|
|
||||||
Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable
|
Standard Cargo. Requires Rust 1.95 or newer (the `#[naked]` attribute went stable
|
||||||
in 1.88; we use a few unrelated post-1.88 features). x86-64 Linux only —
|
in 1.88; we use a few unrelated post-1.88 features). `master` is x86-64 Linux
|
||||||
ARM64 and macOS are on the deferred list because of the assembly shim and the
|
only. An experimental, **untested** aarch64 context-switch backend lives on the
|
||||||
|
`arm-port` branch (extracted into a `target_arch`-gated `src/arch/`); it has not
|
||||||
|
been validated on hardware yet. macOS remains on the deferred list because of the
|
||||||
epoll dependency.
|
epoll dependency.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -76,7 +82,7 @@ cargo bench # primes benchmark vs tokio
|
|||||||
## What's not here
|
## What's not here
|
||||||
|
|
||||||
See the **Defer** section of `Architecture.md`.
|
See the **Defer** section of `Architecture.md`.
|
||||||
restart-intensity caps, `join!` for handle groups, stack growth via remap,
|
`join!` for handle groups, stack growth via remap,
|
||||||
hierarchical timer wheel, fd-wait timeouts, `Signal::Timeout`. Each is
|
hierarchical timer wheel, fd-wait timeouts, `Signal::Timeout`. Each is
|
||||||
mechanism we know how to add; none belongs in this iteration.
|
mechanism we know how to add; none belongs in this iteration.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! A deliberately small, deterministic smarm program for inspecting the
|
||||||
|
//! naked-asm context switch under a debugger (llmdbg).
|
||||||
|
//!
|
||||||
|
//! Design goals that make it debugger-friendly:
|
||||||
|
//! - Exactly one scheduler thread (`Config::exact(1)`) so the whole run is
|
||||||
|
//! single-OS-threaded — llmdbg is single-threaded-debuggee only today.
|
||||||
|
//! - A known, small number of voluntary yields, so the number of
|
||||||
|
//! `switch_to_actor` / `switch_to_scheduler` transitions is predictable.
|
||||||
|
//! - Frame pointers preserved (see the [profile] note in how we build it),
|
||||||
|
//! so llmdbg's `finish()` and frame-pointer assumptions hold.
|
||||||
|
//! - No I/O, no timers, no channels — just the scheduler and the context
|
||||||
|
//! switch, to keep the instruction stream centered on the asm we care
|
||||||
|
//! about.
|
||||||
|
//!
|
||||||
|
//! Two actors each yield `N_YIELDS` times, printing a counter. Run normally
|
||||||
|
//! it just prints an interleaving. Run under llmdbg with breakpoints on
|
||||||
|
//! `smarm::context::switch_to_actor_asm` and
|
||||||
|
//! `smarm::context::switch_to_scheduler` to watch the stack pointer hand off
|
||||||
|
//! between the scheduler stack and each actor's mmap'd stack.
|
||||||
|
|
||||||
|
use smarm::{init, Config};
|
||||||
|
|
||||||
|
const N_YIELDS: usize = 3;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Single scheduler thread: one OS thread, cooperative only.
|
||||||
|
let rt = init(Config::exact(1));
|
||||||
|
rt.run(|| {
|
||||||
|
let a = smarm::spawn(|| {
|
||||||
|
for i in 0..N_YIELDS {
|
||||||
|
println!("actor A tick {i}");
|
||||||
|
smarm::yield_now();
|
||||||
|
}
|
||||||
|
println!("actor A done");
|
||||||
|
});
|
||||||
|
let b = smarm::spawn(|| {
|
||||||
|
for i in 0..N_YIELDS {
|
||||||
|
println!("actor B tick {i}");
|
||||||
|
smarm::yield_now();
|
||||||
|
}
|
||||||
|
println!("actor B done");
|
||||||
|
});
|
||||||
|
a.join().expect("A joined");
|
||||||
|
b.join().expect("B joined");
|
||||||
|
println!("root done");
|
||||||
|
});
|
||||||
|
}
|
||||||
+35
-1
@@ -24,8 +24,22 @@ use std::panic;
|
|||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
Exit,
|
Exit,
|
||||||
Panic(Box<dyn Any + Send>),
|
Panic(Box<dyn Any + Send>),
|
||||||
|
/// The actor was cooperatively cancelled via `request_stop`: a sentinel
|
||||||
|
/// panic unwound its stack (running Drop) and the trampoline recognised
|
||||||
|
/// the sentinel. Distinct from a user `Panic` — there is no payload to
|
||||||
|
/// propagate.
|
||||||
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The payload of the sentinel panic raised at an observation point when an
|
||||||
|
/// actor has been flagged for cooperative cancellation. Zero-sized; its only
|
||||||
|
/// job is to be recognisable in the trampoline's `catch_unwind` so the
|
||||||
|
/// teardown is reported as `Outcome::Stopped` rather than `Outcome::Panic`.
|
||||||
|
///
|
||||||
|
/// User code that wraps its own `catch_unwind` can swallow this (cf. Erlang's
|
||||||
|
/// `catch`); the stop flag stays set, so the next observation point re-raises.
|
||||||
|
pub(crate) struct StopSentinel;
|
||||||
|
|
||||||
// Thread-locals that the scheduler writes immediately before `switch_to_actor`.
|
// Thread-locals that the scheduler writes immediately before `switch_to_actor`.
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// The closure for the actor we're about to resume *for the first time*.
|
/// The closure for the actor we're about to resume *for the first time*.
|
||||||
@@ -84,7 +98,13 @@ pub extern "C-unwind" fn trampoline() {
|
|||||||
|
|
||||||
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
|
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
|
||||||
Ok(()) => Outcome::Exit,
|
Ok(()) => Outcome::Exit,
|
||||||
Err(payload) => Outcome::Panic(payload),
|
Err(payload) => {
|
||||||
|
if payload.is::<StopSentinel>() {
|
||||||
|
Outcome::Stopped
|
||||||
|
} else {
|
||||||
|
Outcome::Panic(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome));
|
LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome));
|
||||||
@@ -107,4 +127,18 @@ pub struct Actor {
|
|||||||
pub sp: usize,
|
pub sp: usize,
|
||||||
/// The PID of this actor's supervisor. Used to deliver `Signal` on death.
|
/// The PID of this actor's supervisor. Used to deliver `Signal` on death.
|
||||||
pub supervisor: Pid,
|
pub supervisor: Pid,
|
||||||
|
/// Cooperative-cancellation flag. `request_stop` sets it (and unparks a
|
||||||
|
/// parked actor); the actor observes it lock-free at its next observation
|
||||||
|
/// point — see `preempt::check_cancelled`. Shared via `Arc` so the running
|
||||||
|
/// actor can poll it without taking the shared mutex. Lives on the `Actor`
|
||||||
|
/// (constructed fresh at spawn), so unlike a `Slot` field it needs no reset
|
||||||
|
/// in the slot-recycling paths.
|
||||||
|
pub stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
/// Trap-exit inbox (roadmap #3). `None` until the actor calls
|
||||||
|
/// `link::trap_exit()`; `Some(tx)` means an abnormal death of a linked
|
||||||
|
/// peer is delivered here as an `ExitSignal` *message* instead of
|
||||||
|
/// cooperatively stopping this actor. Lives on the `Actor` (fresh per
|
||||||
|
/// spawn) for the same reason as `stop`: no slot-recycling reset needed,
|
||||||
|
/// and a restarted child correctly starts out *not* trapping.
|
||||||
|
pub trap: Option<crate::channel::Sender<crate::link::ExitSignal>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Cooperative context switching and cycle counter, aarch64 (AAPCS64).
|
||||||
|
//!
|
||||||
|
//! The aarch64 mirror of the x86-64 backend. The protocol is identical — save
|
||||||
|
//! callee-saved state, swap stack pointers via the shared `*_SP` thread-locals,
|
||||||
|
//! restore, return — but the mechanics differ from x86 in two ways worth
|
||||||
|
//! stating up front, because they drive the stack layout:
|
||||||
|
//!
|
||||||
|
//! 1. **Return is via the link register, not the stack.** x86 `ret` pops the
|
||||||
|
//! return address off the stack; aarch64 `ret` jumps to whatever is in
|
||||||
|
//! `x30` (lr). So the entry point is parked in the saved-`x30` slot, and
|
||||||
|
//! the shim restores `x30` and `ret`s to it. There is no "ret target word"
|
||||||
|
//! sitting on the stack the way there is on x86.
|
||||||
|
//!
|
||||||
|
//! 2. **sp must be 16-byte aligned at all times**, not just at call entry.
|
||||||
|
//! We save 12 registers (x19–x28, x29, x30) = 96 bytes, already a multiple
|
||||||
|
//! of 16, so the frame is aligned by construction.
|
||||||
|
//!
|
||||||
|
//! FP/SIMD registers (v8–v15, whose low 64 bits are callee-saved under AAPCS64)
|
||||||
|
//! are NOT saved, for the same reason the x86 backend skips XMM: every yield
|
||||||
|
//! goes through a Rust call boundary, so the compiler has already spilled any
|
||||||
|
//! live vector state. If we ever yield from a non-call-boundary, this breaks.
|
||||||
|
|
||||||
|
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initial stack layout
|
||||||
|
//
|
||||||
|
// Twelve 8-byte slots, all zero except the x30 slot which holds `entry`. The
|
||||||
|
// first `switch_to_actor` loads x19–x28/x29/x30 back and `ret`s to x30,
|
||||||
|
// landing at the top of `entry` with sp 16-aligned (the AAPCS64 requirement).
|
||||||
|
//
|
||||||
|
// Layout (high → low), relative to aligned_top = top & ~15:
|
||||||
|
//
|
||||||
|
// aligned_top - 8 : x30 = entry ← `ret` jumps here.
|
||||||
|
// aligned_top - 16 : x29 = 0 (fp)
|
||||||
|
// aligned_top - 24 : x28 = 0
|
||||||
|
// aligned_top - 32 : x27 = 0
|
||||||
|
// aligned_top - 40 : x26 = 0
|
||||||
|
// aligned_top - 48 : x25 = 0
|
||||||
|
// aligned_top - 56 : x24 = 0
|
||||||
|
// aligned_top - 64 : x23 = 0
|
||||||
|
// aligned_top - 72 : x22 = 0
|
||||||
|
// aligned_top - 80 : x21 = 0
|
||||||
|
// aligned_top - 88 : x20 = 0
|
||||||
|
// aligned_top - 96 : x19 = 0 ← initial sp (16-aligned)
|
||||||
|
//
|
||||||
|
// The restore order in the shim (ldp pairs, low→high) must match this exactly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
||||||
|
unsafe {
|
||||||
|
let aligned_top = top as usize & !15;
|
||||||
|
let mut sp = aligned_top;
|
||||||
|
sp -= 8; (sp as *mut usize).write(entry as usize); // x30 (lr) → entry
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x29 (fp)
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x28
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x27
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x26
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x25
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x24
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x23
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x22
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x21
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x20
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // x19 ← initial sp
|
||||||
|
sp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context switch shims
|
||||||
|
//
|
||||||
|
// Each shim:
|
||||||
|
// 1. Pushes x19–x28, x29, x30 as six 16-byte stp pairs (sp pre-decrement).
|
||||||
|
// 2. Moves sp into x0 and calls the Rust helper that stores it.
|
||||||
|
// 3. Calls the Rust helper that returns the *other* side's saved sp.
|
||||||
|
// 4. Moves that into sp.
|
||||||
|
// 5. Restores the twelve registers and rets (to the restored x30).
|
||||||
|
//
|
||||||
|
// The helper calls clobber x30 themselves, but that's fine: the outgoing x30
|
||||||
|
// was already saved to the stack in step 1, and step 5 restores the incoming
|
||||||
|
// side's x30 before `ret`. The push/pop register order is paired so that the
|
||||||
|
// `ldp` sequence reverses the `stp` sequence exactly.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[unsafe(naked)]
|
||||||
|
unsafe extern "C" fn switch_to_actor_asm() {
|
||||||
|
core::arch::naked_asm!(
|
||||||
|
"stp x19, x20, [sp, #-96]!",
|
||||||
|
"stp x21, x22, [sp, #16]",
|
||||||
|
"stp x23, x24, [sp, #32]",
|
||||||
|
"stp x25, x26, [sp, #48]",
|
||||||
|
"stp x27, x28, [sp, #64]",
|
||||||
|
"stp x29, x30, [sp, #80]",
|
||||||
|
"mov x0, sp",
|
||||||
|
"bl {set_sched_sp}",
|
||||||
|
"bl {get_actor_sp}",
|
||||||
|
"mov sp, x0",
|
||||||
|
"ldp x21, x22, [sp, #16]",
|
||||||
|
"ldp x23, x24, [sp, #32]",
|
||||||
|
"ldp x25, x26, [sp, #48]",
|
||||||
|
"ldp x27, x28, [sp, #64]",
|
||||||
|
"ldp x29, x30, [sp, #80]",
|
||||||
|
"ldp x19, x20, [sp], #96",
|
||||||
|
"ret",
|
||||||
|
set_sched_sp = sym set_scheduler_sp,
|
||||||
|
get_actor_sp = sym get_actor_sp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||||
|
pub unsafe fn switch_to_actor() {
|
||||||
|
unsafe { switch_to_actor_asm() };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(naked)]
|
||||||
|
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||||
|
core::arch::naked_asm!(
|
||||||
|
"stp x19, x20, [sp, #-96]!",
|
||||||
|
"stp x21, x22, [sp, #16]",
|
||||||
|
"stp x23, x24, [sp, #32]",
|
||||||
|
"stp x25, x26, [sp, #48]",
|
||||||
|
"stp x27, x28, [sp, #64]",
|
||||||
|
"stp x29, x30, [sp, #80]",
|
||||||
|
"mov x0, sp",
|
||||||
|
"bl {set_actor_sp}",
|
||||||
|
"bl {get_sched_sp}",
|
||||||
|
"mov sp, x0",
|
||||||
|
"ldp x21, x22, [sp, #16]",
|
||||||
|
"ldp x23, x24, [sp, #32]",
|
||||||
|
"ldp x25, x26, [sp, #48]",
|
||||||
|
"ldp x27, x28, [sp, #64]",
|
||||||
|
"ldp x29, x30, [sp, #80]",
|
||||||
|
"ldp x19, x20, [sp], #96",
|
||||||
|
"ret",
|
||||||
|
set_actor_sp = sym set_actor_sp,
|
||||||
|
get_sched_sp = sym get_scheduler_sp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cycle counter
|
||||||
|
//
|
||||||
|
// CNTVCT_EL0 is the virtual count register, readable from EL0 on Linux. `isb`
|
||||||
|
// serialises so we don't sample the counter before prior instructions retire
|
||||||
|
// (the aarch64 analogue of x86's `lfence` before rdtsc).
|
||||||
|
//
|
||||||
|
// Unit: CNTVCT ticks, whose frequency is CNTFRQ_EL0 — typically 1–50 MHz,
|
||||||
|
// NOT the CPU clock. This is a different and much coarser unit than the x86
|
||||||
|
// TSC, which is why `TIMESLICE_CYCLES` must be tuned separately for aarch64.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn read_cycle_counter() -> u64 {
|
||||||
|
let cnt: u64;
|
||||||
|
unsafe {
|
||||||
|
core::arch::asm!(
|
||||||
|
"isb",
|
||||||
|
"mrs {cnt}, cntvct_el0",
|
||||||
|
cnt = out(reg) cnt,
|
||||||
|
options(nostack, nomem),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cnt
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Architecture abstraction layer.
|
||||||
|
//!
|
||||||
|
//! Everything that depends on the target ISA lives behind this module: the
|
||||||
|
//! cooperative context-switch shims, the initial-stack layout, and the
|
||||||
|
//! cycle counter used by the preemption timeslice. The rest of the runtime
|
||||||
|
//! talks only to the API re-exported here and never names a register.
|
||||||
|
//!
|
||||||
|
//! Each backend (`x86_64`, `aarch64`) exposes the same four items:
|
||||||
|
//!
|
||||||
|
//! - `init_actor_stack(top, entry) -> usize`
|
||||||
|
//! Build a fresh actor stack so the first `switch_to_actor` lands
|
||||||
|
//! inside `entry` with the ABI-correct stack alignment.
|
||||||
|
//! - `switch_to_actor()`
|
||||||
|
//! Resume the actor whose sp is in `ACTOR_SP`; returns when it yields.
|
||||||
|
//! - `switch_to_scheduler()`
|
||||||
|
//! Yield from the current actor back to its scheduler thread.
|
||||||
|
//! - `read_cycle_counter() -> u64`
|
||||||
|
//! Monotonic per-core cycle counter for the timeslice clock. The unit
|
||||||
|
//! is ISA-defined (x86 TSC ticks vs. aarch64 virtual-counter ticks),
|
||||||
|
//! so `TIMESLICE_CYCLES` must be tuned per-arch — see `preempt`.
|
||||||
|
//!
|
||||||
|
//! The `SCHEDULER_SP` / `ACTOR_SP` thread-locals are shared across backends
|
||||||
|
//! and live here, since the saved-stack-pointer protocol is identical
|
||||||
|
//! regardless of which registers the shim happens to push.
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
|
||||||
|
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by the naked shims (via `sym`) and by the scheduler/tests through the
|
||||||
|
// re-exports below. `pub` because the asm references them as symbols.
|
||||||
|
pub(crate) fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
|
||||||
|
pub(crate) fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
|
||||||
|
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
|
||||||
|
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
|
||||||
|
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
mod x86_64;
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
pub use x86_64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
mod aarch64;
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
pub use aarch64::{init_actor_stack, read_cycle_counter, switch_to_actor, switch_to_scheduler};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||||
|
compile_error!(
|
||||||
|
"smarm's context-switch and cycle-counter layer supports only x86_64 and \
|
||||||
|
aarch64. Add a backend in src/arch/ for this target."
|
||||||
|
);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Cooperative context switching and cycle counter, x86-64 (SysV AMD64).
|
||||||
|
//!
|
||||||
|
//! Two naked-asm functions move execution between a scheduler thread and an
|
||||||
|
//! actor running on its own mmap'd stack. The compiler cannot do this; the
|
||||||
|
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
|
||||||
|
//!
|
||||||
|
//! `init_actor_stack` builds the initial stack so that the first
|
||||||
|
//! `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
|
||||||
|
//! (the x86-64 ABI requirement at function entry).
|
||||||
|
|
||||||
|
use super::{get_actor_sp, get_scheduler_sp, set_actor_sp, set_scheduler_sp};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initial stack layout
|
||||||
|
//
|
||||||
|
// We start from aligned_top = top & ~15, then bias by 8 and push seven 8-byte
|
||||||
|
// slots (downward). The first `switch_to_actor` pops r15..rbx and `ret`s —
|
||||||
|
// landing in `entry` with rsp % 16 == 8 (the x86-64 ABI state at the point
|
||||||
|
// just after a `call`, which is what `entry` is compiled to expect).
|
||||||
|
//
|
||||||
|
// Layout (high → low), relative to aligned_top = top & ~15. Note the entry
|
||||||
|
// slot is at aligned_top - 16, NOT aligned_top - 8: the function does
|
||||||
|
// `(top & ~15) - 8` and *then* a `-= 8` before the first write, so the first
|
||||||
|
// stored word lands at aligned_top - 16. Verified by single-stepping the
|
||||||
|
// `ret` under llmdbg: entry sits at an address with %16 == 0, so the post-ret
|
||||||
|
// rsp is %16 == 8.
|
||||||
|
//
|
||||||
|
// aligned_top - 8 : (unused padding; keeps entry's slot %16 == 0)
|
||||||
|
// aligned_top - 16 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
|
||||||
|
// aligned_top - 24 : rbx = 0
|
||||||
|
// aligned_top - 32 : rbp = 0
|
||||||
|
// aligned_top - 40 : r12 = 0
|
||||||
|
// aligned_top - 48 : r13 = 0
|
||||||
|
// aligned_top - 56 : r14 = 0
|
||||||
|
// aligned_top - 64 : r15 = 0 ← initial rsp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
||||||
|
unsafe {
|
||||||
|
let mut sp = (top as usize & !15) - 8;
|
||||||
|
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // rbx
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // rbp
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // r12
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // r13
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // r14
|
||||||
|
sp -= 8; (sp as *mut usize).write(0); // r15
|
||||||
|
sp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context switch shims
|
||||||
|
//
|
||||||
|
// Each shim:
|
||||||
|
// 1. Pushes the six callee-saved integer registers.
|
||||||
|
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
|
||||||
|
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
|
||||||
|
// 4. Moves that into rsp.
|
||||||
|
// 5. Pops the six registers and rets.
|
||||||
|
//
|
||||||
|
// XMM registers are NOT saved here. We rely on every yield happening through
|
||||||
|
// a Rust call site, which means the compiler has spilled any live XMM state
|
||||||
|
// to the stack before we get here. (This is the same argument the compiler
|
||||||
|
// uses internally — callee-saved regs are what survive a `call`, and the
|
||||||
|
// SysV AMD64 ABI says XMM0–15 are all caller-saved.) If we ever yield from
|
||||||
|
// a place that isn't a Rust call boundary, this assumption breaks.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[unsafe(naked)]
|
||||||
|
unsafe extern "C" fn switch_to_actor_asm() {
|
||||||
|
core::arch::naked_asm!(
|
||||||
|
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||||
|
"mov rdi, rsp",
|
||||||
|
"call {set_sched_sp}",
|
||||||
|
"call {get_actor_sp}",
|
||||||
|
"mov rsp, rax",
|
||||||
|
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||||
|
"ret",
|
||||||
|
set_sched_sp = sym set_scheduler_sp,
|
||||||
|
get_actor_sp = sym get_actor_sp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
||||||
|
pub unsafe fn switch_to_actor() {
|
||||||
|
unsafe { switch_to_actor_asm() };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(naked)]
|
||||||
|
pub unsafe extern "C" fn switch_to_scheduler() {
|
||||||
|
core::arch::naked_asm!(
|
||||||
|
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
||||||
|
"mov rdi, rsp",
|
||||||
|
"call {set_actor_sp}",
|
||||||
|
"call {get_sched_sp}",
|
||||||
|
"mov rsp, rax",
|
||||||
|
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
||||||
|
"ret",
|
||||||
|
set_actor_sp = sym set_actor_sp,
|
||||||
|
get_sched_sp = sym get_scheduler_sp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cycle counter
|
||||||
|
//
|
||||||
|
// `lfence` serialises the instruction stream so we don't measure time before
|
||||||
|
// prior instructions retire. Unit: TSC ticks (≈ CPU base clock).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn read_cycle_counter() -> u64 {
|
||||||
|
unsafe {
|
||||||
|
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
|
||||||
|
core::arch::x86_64::_rdtsc()
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-1
@@ -69,7 +69,13 @@ impl<T> Drop for Sender<T> {
|
|||||||
let unpark = {
|
let unpark = {
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock().unwrap();
|
||||||
g.senders -= 1;
|
g.senders -= 1;
|
||||||
if g.senders == 0 && g.queue.is_empty() {
|
// Wake the parked receiver on the last sender drop regardless of
|
||||||
|
// whether the queue is empty. A plain `recv` only ever parks on an
|
||||||
|
// empty queue (so this is unchanged for it), but a selective
|
||||||
|
// `recv_match` may be parked on a *non-empty* queue holding only
|
||||||
|
// non-matching messages — it must wake to observe closure and
|
||||||
|
// return Err rather than sleep forever.
|
||||||
|
if g.senders == 0 {
|
||||||
g.parked_receiver.take()
|
g.parked_receiver.take()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -134,6 +140,64 @@ impl<T> Receiver<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selective receive: remove and return the first queued message for which
|
||||||
|
/// `pred` holds, leaving the rest in arrival order. If no queued message
|
||||||
|
/// matches, parks and re-scans on every send (a selective receiver may park
|
||||||
|
/// on a *non-empty* queue). Returns `Err(RecvError)` only once the channel
|
||||||
|
/// is closed and no queued message matches.
|
||||||
|
///
|
||||||
|
/// `pred` is run while the channel lock is held: keep it cheap and pure,
|
||||||
|
/// and do not call back into this channel from inside it. It is modelled as
|
||||||
|
/// `Fn` (not `FnMut`) deliberately — it is re-run from scratch on every
|
||||||
|
/// scan, so a stateful predicate would observe surprising re-counting.
|
||||||
|
pub fn recv_match<F>(&self, pred: F) -> Result<T, RecvError>
|
||||||
|
where
|
||||||
|
F: Fn(&T) -> bool,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||||
|
// position() found it, so remove() returns Some.
|
||||||
|
return Ok(g.queue.remove(i).unwrap());
|
||||||
|
}
|
||||||
|
if g.senders == 0 {
|
||||||
|
// Closed and nothing queued can ever match.
|
||||||
|
return Err(RecvError);
|
||||||
|
}
|
||||||
|
let me = crate::actor::current_pid()
|
||||||
|
.expect("recv_match() called outside an actor");
|
||||||
|
debug_assert!(
|
||||||
|
g.parked_receiver.is_none(),
|
||||||
|
"channel has more than one receiver"
|
||||||
|
);
|
||||||
|
g.parked_receiver = Some(me);
|
||||||
|
crate::te!(crate::trace::Event::RecvPark(me));
|
||||||
|
}
|
||||||
|
// Release the lock before parking — the unparker will need it.
|
||||||
|
crate::scheduler::park_current();
|
||||||
|
crate::te!(crate::trace::Event::RecvWake(crate::actor::current_pid().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking selective receive. `Ok(Some(v))` if a queued message
|
||||||
|
/// matched `pred` (removed, rest left in order), `Ok(None)` if the channel
|
||||||
|
/// is open but nothing matched, `Err(RecvError)` if closed and nothing
|
||||||
|
/// matched. Same predicate contract as [`recv_match`](Self::recv_match).
|
||||||
|
pub fn try_recv_match<F>(&self, pred: F) -> Result<Option<T>, RecvError>
|
||||||
|
where
|
||||||
|
F: Fn(&T) -> bool,
|
||||||
|
{
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
if let Some(i) = g.queue.iter().position(|v| pred(v)) {
|
||||||
|
return Ok(Some(g.queue.remove(i).unwrap()));
|
||||||
|
}
|
||||||
|
if g.senders == 0 {
|
||||||
|
return Err(RecvError);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
|
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
|
||||||
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
|
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
|
||||||
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
|
||||||
|
|||||||
+11
-103
@@ -1,106 +1,14 @@
|
|||||||
//! Cooperative context switching, x86-64.
|
//! Cooperative context switching — public façade over the arch backend.
|
||||||
//!
|
//!
|
||||||
//! Two naked-asm functions move execution between a scheduler thread and an
|
//! The real implementation lives in `crate::arch`, selected per `target_arch`.
|
||||||
//! actor running on its own mmap'd stack. The compiler cannot do this; the
|
//! This module re-exports the stable surface (`init_actor_stack`, the two
|
||||||
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
|
//! `switch_to_*` shims, and the `*_actor_sp` accessors) so the scheduler,
|
||||||
|
//! `preempt`, and the `tests/context.rs` integration tests keep importing
|
||||||
|
//! `smarm::context::{...}` unchanged regardless of which ISA is built.
|
||||||
//!
|
//!
|
||||||
//! `SCHEDULER_SP` and `ACTOR_SP` are thread-locals holding each side's saved
|
//! See `crate::arch` for the contract every backend implements, and
|
||||||
//! stack pointer. `init_actor_stack` builds the initial stack so that the
|
//! `crate::arch::{x86_64, aarch64}` for the per-ISA register choreography.
|
||||||
//! first `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
|
|
||||||
//! (the x86-64 ABI requirement at function entry).
|
|
||||||
|
|
||||||
use std::cell::Cell;
|
pub use crate::arch::{
|
||||||
|
get_actor_sp, init_actor_stack, set_actor_sp, switch_to_actor, switch_to_scheduler,
|
||||||
thread_local! {
|
};
|
||||||
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
|
|
||||||
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
|
|
||||||
fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
|
|
||||||
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
|
|
||||||
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Initial stack layout
|
|
||||||
//
|
|
||||||
// After alignment, sp = top & ~15 - 8. Then we push (downward) six callee-
|
|
||||||
// saved register slots and a return address. The first `switch_to_actor`
|
|
||||||
// pops r15..rbx and `ret`s — landing in `entry` with rsp % 16 == 8.
|
|
||||||
//
|
|
||||||
// Layout (high → low), relative to aligned_top = top & ~15:
|
|
||||||
// aligned_top - 8 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
|
|
||||||
// aligned_top - 16 : rbx = 0
|
|
||||||
// aligned_top - 24 : rbp = 0
|
|
||||||
// aligned_top - 32 : r12 = 0
|
|
||||||
// aligned_top - 40 : r13 = 0
|
|
||||||
// aligned_top - 48 : r14 = 0
|
|
||||||
// aligned_top - 56 : r15 = 0 ← initial rsp
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
|
|
||||||
unsafe {
|
|
||||||
let mut sp = (top as usize & !15) - 8;
|
|
||||||
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // rbx
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // rbp
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // r12
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // r13
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // r14
|
|
||||||
sp -= 8; (sp as *mut usize).write(0); // r15
|
|
||||||
sp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Context switch shims
|
|
||||||
//
|
|
||||||
// Each shim:
|
|
||||||
// 1. Pushes the six callee-saved integer registers.
|
|
||||||
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
|
|
||||||
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
|
|
||||||
// 4. Moves that into rsp.
|
|
||||||
// 5. Pops the six registers and rets.
|
|
||||||
//
|
|
||||||
// XMM registers are NOT saved here. We rely on every yield happening through
|
|
||||||
// a Rust call site, which means the compiler has spilled any live XMM state
|
|
||||||
// to the stack before we get here. (This is the same argument the compiler
|
|
||||||
// uses internally — callee-saved regs are what survive a `call`, and the
|
|
||||||
// SysV AMD64 ABI says XMM0–15 are all caller-saved.) If we ever yield from
|
|
||||||
// a place that isn't a Rust call boundary, this assumption breaks.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[unsafe(naked)]
|
|
||||||
unsafe extern "C" fn switch_to_actor_asm() {
|
|
||||||
core::arch::naked_asm!(
|
|
||||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
|
||||||
"mov rdi, rsp",
|
|
||||||
"call {set_sched_sp}",
|
|
||||||
"call {get_actor_sp}",
|
|
||||||
"mov rsp, rax",
|
|
||||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
|
||||||
"ret",
|
|
||||||
set_sched_sp = sym set_scheduler_sp,
|
|
||||||
get_actor_sp = sym get_actor_sp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
|
|
||||||
pub unsafe fn switch_to_actor() {
|
|
||||||
unsafe { switch_to_actor_asm() };
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(naked)]
|
|
||||||
pub unsafe extern "C" fn switch_to_scheduler() {
|
|
||||||
core::arch::naked_asm!(
|
|
||||||
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
|
|
||||||
"mov rdi, rsp",
|
|
||||||
"call {set_actor_sp}",
|
|
||||||
"call {get_sched_sp}",
|
|
||||||
"mov rsp, rax",
|
|
||||||
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
|
|
||||||
"ret",
|
|
||||||
set_actor_sp = sym set_actor_sp,
|
|
||||||
get_sched_sp = sym get_scheduler_sp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! gen_server — synchronous call / asynchronous cast over a single actor.
|
||||||
|
//!
|
||||||
|
//! A thin request-reply layer on top of [`channel`](crate::channel), modelled
|
||||||
|
//! on Erlang's `gen_server`. A *server* is an actor owning a state value that
|
||||||
|
//! implements [`GenServer`]; clients hold a clonable [`ServerRef`] and issue
|
||||||
|
//! [`call`](ServerRef::call) (synchronous, returns a reply) or
|
||||||
|
//! [`cast`](ServerRef::cast) (fire-and-forget).
|
||||||
|
//!
|
||||||
|
//! ## Why a single inbox
|
||||||
|
//!
|
||||||
|
//! smarm has no `select` and no unified per-process mailbox (see the runtime
|
||||||
|
//! gotchas in `task.md`), so a server cannot wait on a "call channel" and a
|
||||||
|
//! "cast channel" simultaneously. Instead every message — call or cast —
|
||||||
|
//! travels the *same* inbox channel as an [`Envelope`], and the server loop
|
||||||
|
//! dispatches by variant. A `call` carries a freshly made one-shot reply
|
||||||
|
//! channel; the server sends the reply straight back down it.
|
||||||
|
//!
|
||||||
|
//! ## Server death
|
||||||
|
//!
|
||||||
|
//! Detection falls out of channel closure, so no monitor is required:
|
||||||
|
//! - if the server is already gone, its inbox is closed and the `send` in
|
||||||
|
//! `call`/`cast` fails → [`CallError::ServerDown`] / [`CastError::ServerDown`];
|
||||||
|
//! - if the server dies *after* a call is enqueued but before it replies
|
||||||
|
//! (a handler panic, or a cooperative `request_stop`), the reply sender is
|
||||||
|
//! dropped as the server's stack unwinds, closing the reply channel; the
|
||||||
|
//! parked caller wakes and its `recv` returns `Err` → `ServerDown`.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle / callbacks
|
||||||
|
//!
|
||||||
|
//! [`GenServer::init`] runs once inside the server actor before the first
|
||||||
|
//! message; [`GenServer::terminate`] runs on the way out. terminate is wired
|
||||||
|
//! through a drop guard, so it fires on *every* exit path — graceful inbox
|
||||||
|
//! close, a handler panic, or a cooperative `request_stop` — not only the clean
|
||||||
|
//! one. Keep it cheap and non-blocking: it may run mid-unwind, and a panic
|
||||||
|
//! inside it during an unwind aborts the process (a double panic).
|
||||||
|
//!
|
||||||
|
//! ## Not here (yet)
|
||||||
|
//!
|
||||||
|
//! No `handle_info` and no call timeout. Both need machinery smarm currently
|
||||||
|
//! defers: a call timeout needs a per-`recv` deadline (cf. the deferred
|
||||||
|
//! `Signal::Timeout`), and `handle_info` needs the cross-channel mailbox merge
|
||||||
|
//! that selective receive deliberately left unmade. They are slated to land
|
||||||
|
//! together with timeouts.
|
||||||
|
|
||||||
|
use crate::channel::{channel, Receiver, Sender};
|
||||||
|
use crate::pid::Pid;
|
||||||
|
use crate::scheduler::{spawn, spawn_under};
|
||||||
|
|
||||||
|
/// Behaviour for a gen_server: a state value plus call/cast handlers.
|
||||||
|
///
|
||||||
|
/// `handle_call` and `handle_cast` are required; [`init`](Self::init) and
|
||||||
|
/// [`terminate`](Self::terminate) are optional lifecycle hooks with no-op
|
||||||
|
/// defaults.
|
||||||
|
pub trait GenServer: Send + 'static {
|
||||||
|
/// Synchronous request type (carried by [`ServerRef::call`]).
|
||||||
|
type Call: Send + 'static;
|
||||||
|
/// Reply type returned for a `Call`.
|
||||||
|
type Reply: Send + 'static;
|
||||||
|
/// Asynchronous request type (carried by [`ServerRef::cast`]).
|
||||||
|
type Cast: Send + 'static;
|
||||||
|
|
||||||
|
/// Runs once inside the server actor before any message is handled.
|
||||||
|
fn init(&mut self) {}
|
||||||
|
|
||||||
|
/// Handle a synchronous call and produce the reply sent back to the caller.
|
||||||
|
fn handle_call(&mut self, request: Self::Call) -> Self::Reply;
|
||||||
|
|
||||||
|
/// Handle a fire-and-forget cast.
|
||||||
|
fn handle_cast(&mut self, request: Self::Cast);
|
||||||
|
|
||||||
|
/// Runs as the server actor exits, on any exit path (see module docs).
|
||||||
|
fn terminate(&mut self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What travels the server's single inbox channel: a synchronous call (with a
|
||||||
|
/// reply sender) or an asynchronous cast.
|
||||||
|
enum Envelope<G: GenServer> {
|
||||||
|
Call(G::Call, Sender<G::Reply>),
|
||||||
|
Cast(G::Cast),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clonable handle to a running server. Cloning yields another sender to the
|
||||||
|
/// same inbox; the server lives until the last `ServerRef` is dropped, at which
|
||||||
|
/// point its inbox closes and the loop exits normally.
|
||||||
|
pub struct ServerRef<G: GenServer> {
|
||||||
|
tx: Sender<Envelope<G>>,
|
||||||
|
pid: Pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: GenServer> Clone for ServerRef<G> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
ServerRef { tx: self.tx.clone(), pid: self.pid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned by [`ServerRef::call`] when the server is no longer reachable.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum CallError {
|
||||||
|
/// The server was already gone, or died before replying.
|
||||||
|
ServerDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned by [`ServerRef::cast`] when the server is no longer reachable.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum CastError {
|
||||||
|
/// The server inbox was closed (the server is gone).
|
||||||
|
ServerDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: GenServer> ServerRef<G> {
|
||||||
|
/// The server actor's pid — usable with `monitor`, `request_stop`, `link`.
|
||||||
|
pub fn pid(&self) -> Pid {
|
||||||
|
self.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous request-reply. Blocks (parking the calling actor) until the
|
||||||
|
/// server replies, or returns [`CallError::ServerDown`] if the server is or
|
||||||
|
/// becomes unreachable before a reply arrives.
|
||||||
|
pub fn call(&self, request: G::Call) -> Result<G::Reply, CallError> {
|
||||||
|
let (reply_tx, reply_rx) = channel::<G::Reply>();
|
||||||
|
self.tx
|
||||||
|
.send(Envelope::Call(request, reply_tx))
|
||||||
|
.map_err(|_| CallError::ServerDown)?;
|
||||||
|
reply_rx.recv().map_err(|_| CallError::ServerDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget request. Returns once the message is enqueued; does not
|
||||||
|
/// wait for the server to handle it. [`CastError::ServerDown`] if the inbox
|
||||||
|
/// is already closed.
|
||||||
|
pub fn cast(&self, request: G::Cast) -> Result<(), CastError> {
|
||||||
|
self.tx
|
||||||
|
.send(Envelope::Cast(request))
|
||||||
|
.map_err(|_| CastError::ServerDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn `state` as a server under the current actor (via [`spawn`]). Returns a
|
||||||
|
/// [`ServerRef`]; the server's lifetime is governed by its refs, not by
|
||||||
|
/// joining, so the backing join handle is dropped.
|
||||||
|
pub fn start<G: GenServer>(state: G) -> ServerRef<G> {
|
||||||
|
let (tx, rx) = channel::<Envelope<G>>();
|
||||||
|
let handle = spawn(move || server_loop::<G>(rx, state));
|
||||||
|
ServerRef { tx, pid: handle.pid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`start`], but spawns the server under an explicit supervisor pid (via
|
||||||
|
/// [`spawn_under`]) so it slots into the supervision tree.
|
||||||
|
pub fn start_under<G: GenServer>(supervisor: Pid, state: G) -> ServerRef<G> {
|
||||||
|
let (tx, rx) = channel::<Envelope<G>>();
|
||||||
|
let handle = spawn_under(supervisor, move || server_loop::<G>(rx, state));
|
||||||
|
ServerRef { tx, pid: handle.pid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_loop<G: GenServer>(rx: Receiver<Envelope<G>>, state: G) {
|
||||||
|
// terminate() must run on every exit path (clean close, panic, stop), so it
|
||||||
|
// lives in this guard's Drop rather than after the loop.
|
||||||
|
struct Terminate<G: GenServer>(G);
|
||||||
|
impl<G: GenServer> Drop for Terminate<G> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut guard = Terminate(state);
|
||||||
|
guard.0.init();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match rx.recv() {
|
||||||
|
Ok(Envelope::Call(request, reply_tx)) => {
|
||||||
|
let reply = guard.0.handle_call(request);
|
||||||
|
// The caller may have gone away (e.g. cancelled while parked);
|
||||||
|
// a failed reply send is not the server's problem.
|
||||||
|
let _ = reply_tx.send(reply);
|
||||||
|
}
|
||||||
|
Ok(Envelope::Cast(request)) => guard.0.handle_cast(request),
|
||||||
|
// All ServerRefs dropped → inbox closed → graceful shutdown.
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
// Observation point so a server whose inbox is never empty stays
|
||||||
|
// preemptible and cancellable.
|
||||||
|
crate::check!();
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-3
@@ -11,6 +11,7 @@
|
|||||||
//!
|
//!
|
||||||
//! See `LOOM.md` for the design intent and the deferred-for-later list.
|
//! See `LOOM.md` for the design intent and the deferred-for-later list.
|
||||||
|
|
||||||
|
pub mod arch;
|
||||||
pub mod stack;
|
pub mod stack;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod preempt;
|
pub mod preempt;
|
||||||
@@ -22,6 +23,9 @@ pub mod supervisor;
|
|||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod mutex;
|
pub mod mutex;
|
||||||
|
pub mod monitor;
|
||||||
|
pub mod link;
|
||||||
|
pub mod gen_server;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
|
||||||
@@ -37,14 +41,17 @@ static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub use channel::{channel, Receiver, RecvError, Sender};
|
pub use channel::{channel, Receiver, RecvError, Sender};
|
||||||
|
pub use gen_server::{CallError, CastError, GenServer, ServerRef};
|
||||||
|
pub use link::{link, trap_exit, unlink, ExitSignal};
|
||||||
|
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
|
||||||
pub use mutex::{LockTimeout, Mutex, MutexGuard};
|
pub use mutex::{LockTimeout, Mutex, MutexGuard};
|
||||||
pub use pid::Pid;
|
pub use pid::Pid;
|
||||||
pub use runtime::{init, Config, Runtime};
|
pub use runtime::{init, Config, Runtime};
|
||||||
pub use scheduler::{
|
pub use scheduler::{
|
||||||
block_on_io, run, self_pid, sleep, spawn, spawn_under, wait_readable, wait_writable,
|
block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
|
||||||
yield_now, JoinError, JoinHandle,
|
wait_writable, yield_now, JoinError, JoinHandle,
|
||||||
};
|
};
|
||||||
pub use supervisor::Signal;
|
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal, Strategy};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// check!()
|
// check!()
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
//! Process links + `trap_exit`.
|
||||||
|
//!
|
||||||
|
//! A *link* is bidirectional and persistent, in contrast to a [`monitor`]
|
||||||
|
//! (unidirectional, one-shot): once two actors are linked, the *abnormal*
|
||||||
|
//! death of either propagates to the other. "Abnormal" means a panic or a
|
||||||
|
//! cooperative [`request_stop`] — a normal return never propagates.
|
||||||
|
//!
|
||||||
|
//! What propagation *does* depends on whether the surviving peer traps exits:
|
||||||
|
//!
|
||||||
|
//! - **not trapping** (the default): the peer is cooperatively
|
||||||
|
//! [`request_stop`]'d, so a crash fate-shares across the whole link set —
|
||||||
|
//! Erlang's "let it crash". Because the peer's own links are walked when
|
||||||
|
//! *it* finalizes, the stop cascades transitively.
|
||||||
|
//! - **trapping** (called [`trap_exit`]): the peer instead receives an
|
||||||
|
//! [`ExitSignal`] *message* on its trap inbox and keeps running, turning
|
||||||
|
//! peer failures into ordinary inbox events (Erlang's
|
||||||
|
//! `process_flag(trap_exit, true)`).
|
||||||
|
//!
|
||||||
|
//! Linking an already-dead pid is not a silent no-op: it behaves exactly like
|
||||||
|
//! an immediate abnormal death of the target with reason [`DownReason::NoProc`]
|
||||||
|
//! — a trapping caller gets a message, a non-trapping caller is stopped.
|
||||||
|
//!
|
||||||
|
//! ## Payload
|
||||||
|
//!
|
||||||
|
//! [`ExitSignal`] reuses [`DownReason`] and, like a monitor, does **not**
|
||||||
|
//! carry the panic payload: a panic's payload has a single owner and is
|
||||||
|
//! delivered to whoever `join()`s the actor. A trap inbox only learns *that*
|
||||||
|
//! (and *how*) a peer died.
|
||||||
|
//!
|
||||||
|
//! ## Inbox
|
||||||
|
//!
|
||||||
|
//! The trap inbox is a dedicated channel, distinct from the monitor [`Down`]
|
||||||
|
//! channel: monitors are documented as one-shot/unidirectional, whereas a trap
|
||||||
|
//! inbox receives one [`ExitSignal`] per linked peer death over the actor's
|
||||||
|
//! lifetime. [`trap_exit`] enables trapping and hands back the inbox in one
|
||||||
|
//! call; calling it again installs a fresh inbox (the previous one closes).
|
||||||
|
//!
|
||||||
|
//! ## Races
|
||||||
|
//!
|
||||||
|
//! [`link`] registration and `finalize_actor` (in `runtime`) both serialize on
|
||||||
|
//! the shared mutex, so a target that is live when the link is registered is
|
||||||
|
//! guaranteed to propagate its eventual death; there is no window in which the
|
||||||
|
//! death slips between the liveness check and the registration. As elsewhere,
|
||||||
|
//! we never `send` or `request_stop` while holding the shared lock — those
|
||||||
|
//! re-enter the runtime — so the act is always performed after the lock is
|
||||||
|
//! released.
|
||||||
|
//!
|
||||||
|
//! [`monitor`]: crate::monitor::monitor
|
||||||
|
//! [`Down`]: crate::monitor::Down
|
||||||
|
//! [`request_stop`]: crate::scheduler::request_stop
|
||||||
|
|
||||||
|
use crate::channel::{channel, Receiver, Sender};
|
||||||
|
use crate::monitor::DownReason;
|
||||||
|
use crate::pid::Pid;
|
||||||
|
use crate::runtime::State;
|
||||||
|
use crate::scheduler::{request_stop, self_pid, with_runtime};
|
||||||
|
|
||||||
|
/// A linked peer's death, delivered to a trapping actor's inbox.
|
||||||
|
///
|
||||||
|
/// `Copy` because it carries no payload — see the module docs for why the
|
||||||
|
/// panic payload is *not* included.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ExitSignal {
|
||||||
|
/// The linked pid that died (or the dead pid that was linked).
|
||||||
|
pub from: Pid,
|
||||||
|
/// How it went down. Only ever `Panic`, `Stopped`, or `NoProc` — a normal
|
||||||
|
/// `Exit` does not propagate, so it never appears here.
|
||||||
|
pub reason: DownReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trap exits on the current actor and return its exit inbox.
|
||||||
|
///
|
||||||
|
/// After this call, the abnormal death of a linked peer arrives here as an
|
||||||
|
/// [`ExitSignal`] message instead of cooperatively stopping this actor.
|
||||||
|
/// Calling it again installs a fresh inbox; the previously returned receiver
|
||||||
|
/// then closes.
|
||||||
|
pub fn trap_exit() -> Receiver<ExitSignal> {
|
||||||
|
let (tx, rx) = channel::<ExitSignal>();
|
||||||
|
let me = self_pid();
|
||||||
|
with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
if let Some(actor) = s.slot_mut(me).and_then(|slot| slot.actor.as_mut()) {
|
||||||
|
actor.trap = Some(tx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bidirectionally link the current actor to `target`.
|
||||||
|
///
|
||||||
|
/// If `target` is live, the link is recorded on both actors and either's
|
||||||
|
/// abnormal death will propagate to the other. If `target` is already gone,
|
||||||
|
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
|
||||||
|
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
|
||||||
|
/// re-linking an existing peer, is a no-op.
|
||||||
|
pub fn link(target: Pid) {
|
||||||
|
let me = self_pid();
|
||||||
|
if target == me {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the lock: if the target is live, record the link both ways and
|
||||||
|
// return `None`. If it is gone, return the caller's trap sender (if any)
|
||||||
|
// so we can deliver the NoProc signal after releasing the lock.
|
||||||
|
let dead_action: Option<Option<Sender<ExitSignal>>> = with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
let target_live = matches!(
|
||||||
|
s.slot(target),
|
||||||
|
Some(slot) if slot.actor.is_some() && !matches!(slot.state, State::Done)
|
||||||
|
);
|
||||||
|
if target_live {
|
||||||
|
if let Some(slot) = s.slot_mut(me) {
|
||||||
|
if !slot.links.contains(&target) {
|
||||||
|
slot.links.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(slot) = s.slot_mut(target) {
|
||||||
|
if !slot.links.contains(&me) {
|
||||||
|
slot.links.push(me);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Grab our own trap sender so the NoProc delivery (below)
|
||||||
|
// doesn't need a second lock acquisition.
|
||||||
|
Some(
|
||||||
|
s.slot(me)
|
||||||
|
.and_then(|slot| slot.actor.as_ref())
|
||||||
|
.and_then(|a| a.trap.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match dead_action {
|
||||||
|
None => {} // linked successfully
|
||||||
|
Some(Some(tx)) => {
|
||||||
|
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
|
||||||
|
}
|
||||||
|
Some(None) => request_stop(me),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the link between the current actor and `target`, in both directions.
|
||||||
|
///
|
||||||
|
/// After this, neither actor's death propagates to the other. A no-op if the
|
||||||
|
/// two were not linked.
|
||||||
|
pub fn unlink(target: Pid) {
|
||||||
|
let me = self_pid();
|
||||||
|
if target == me {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
if let Some(slot) = s.slot_mut(me) {
|
||||||
|
slot.links.retain(|p| *p != target);
|
||||||
|
}
|
||||||
|
if let Some(slot) = s.slot_mut(target) {
|
||||||
|
slot.links.retain(|p| *p != me);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
//! Process monitors.
|
||||||
|
//!
|
||||||
|
//! `monitor(target)` asks the runtime to deliver a single [`Down`] when
|
||||||
|
//! `target` terminates, and hands back a [`Monitor`] — the [`Receiver`] to read
|
||||||
|
//! it from, plus the identity (`id`, `target`) needed to take the registration
|
||||||
|
//! back down with [`demonitor`]. A monitor is:
|
||||||
|
//!
|
||||||
|
//! - **unidirectional** — the watcher learns of the target's death, but the
|
||||||
|
//! target learns nothing of the watcher, and the watcher is unaffected by
|
||||||
|
//! the death beyond the notification (contrast a *link*, which propagates
|
||||||
|
//! failure);
|
||||||
|
//! - **one-shot** — exactly one `Down` is ever sent for a given monitor.
|
||||||
|
//! The returned channel closes afterwards, so a second `recv()` yields
|
||||||
|
//! `Err(RecvError)`.
|
||||||
|
//!
|
||||||
|
//! This generalizes the older single-`supervisor_channel` mechanism: a
|
||||||
|
//! supervisor is just a hard-wired monitor that the parent installs at spawn
|
||||||
|
//! time. Here any actor may monitor any pid, any number of times.
|
||||||
|
//!
|
||||||
|
//! ## Reasons
|
||||||
|
//!
|
||||||
|
//! [`DownReason`] is deliberately payload-free. A panicking actor's payload
|
||||||
|
//! has a single owner and is delivered to whoever `join()`s the actor (as
|
||||||
|
//! `JoinError`); a monitor only learns *that* it panicked, not the value.
|
||||||
|
//! Monitoring a pid that is already gone (reclaimed, or never alive) yields
|
||||||
|
//! [`DownReason::NoProc`] immediately, mirroring Erlang's `noproc`.
|
||||||
|
//!
|
||||||
|
//! ## Demonitoring
|
||||||
|
//!
|
||||||
|
//! Each `monitor()` registration is tagged with a process-unique [`MonitorId`].
|
||||||
|
//! [`demonitor`] removes the registration named by a [`Monitor`] from its
|
||||||
|
//! target's slot, returning `Some(id)` if a live registration was found or
|
||||||
|
//! `None` if it had already fired (or the target is gone). Dropping the
|
||||||
|
//! [`Monitor`] afterwards discards any `Down` that the target had *already*
|
||||||
|
//! queued — the equivalent of Erlang's `demonitor(Ref, [flush])`.
|
||||||
|
//!
|
||||||
|
//! ## Races
|
||||||
|
//!
|
||||||
|
//! Registration (below) and `finalize_actor` (in `runtime`) both run under the
|
||||||
|
//! shared-state mutex, so a target that is still alive when its monitor is
|
||||||
|
//! registered is guaranteed to deliver a real `Down`; there is no window in
|
||||||
|
//! which the death slips between the liveness check and the registration.
|
||||||
|
//! `demonitor` is protected by the generation half of the pid: if the target
|
||||||
|
//! has died and its slot index been recycled, `slot_mut(target)` fails the
|
||||||
|
//! generation check and `demonitor` is a clean no-op — it can never strip a
|
||||||
|
//! *different* actor's monitor that happens to share the slot index.
|
||||||
|
|
||||||
|
use crate::channel::{channel, Receiver, Sender};
|
||||||
|
use crate::pid::Pid;
|
||||||
|
use crate::runtime::State;
|
||||||
|
use crate::scheduler::with_runtime;
|
||||||
|
|
||||||
|
/// Why a monitored actor went down.
|
||||||
|
///
|
||||||
|
/// `Copy` because it carries no payload — see the module docs for why the
|
||||||
|
/// panic payload is *not* included here.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DownReason {
|
||||||
|
/// The target returned normally.
|
||||||
|
Exit,
|
||||||
|
/// The target panicked. The payload is delivered to the actor's joiner,
|
||||||
|
/// not to monitors.
|
||||||
|
Panic,
|
||||||
|
/// The target was cooperatively cancelled via `request_stop`.
|
||||||
|
Stopped,
|
||||||
|
/// The target was already gone (finished and reclaimed, or never alive)
|
||||||
|
/// at the moment `monitor()` was called.
|
||||||
|
NoProc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A monitored actor's termination notice.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Down {
|
||||||
|
/// The pid that was being monitored.
|
||||||
|
pub pid: Pid,
|
||||||
|
/// How it went down.
|
||||||
|
pub reason: DownReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A process-unique identifier for one `monitor()` registration.
|
||||||
|
///
|
||||||
|
/// Opaque and `Copy`. Allocated from a monotonic counter in shared state, so
|
||||||
|
/// it is never reused for the lifetime of the runtime — distinct `monitor()`
|
||||||
|
/// calls on the same target get distinct ids, which is what lets [`demonitor`]
|
||||||
|
/// tear down exactly one of several monitors on a target.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MonitorId(pub(crate) u64);
|
||||||
|
|
||||||
|
/// A live monitor: the receiving end of the one-shot [`Down`] channel, plus the
|
||||||
|
/// identity needed to [`demonitor`] it.
|
||||||
|
///
|
||||||
|
/// Read the notification from [`Monitor::rx`]. Not `Clone` (the receiver is a
|
||||||
|
/// single consumer). Dropping it closes the receiving end; if a `Down` was
|
||||||
|
/// already queued it is discarded with the channel.
|
||||||
|
pub struct Monitor {
|
||||||
|
/// This registration's process-unique id.
|
||||||
|
pub id: MonitorId,
|
||||||
|
/// The pid being monitored.
|
||||||
|
pub target: Pid,
|
||||||
|
/// The one-shot channel the `Down` arrives on.
|
||||||
|
pub rx: Receiver<Down>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monitor `target`. Returns a [`Monitor`] whose `rx` receives exactly one
|
||||||
|
/// [`Down`].
|
||||||
|
///
|
||||||
|
/// If `target` is still live, the `Down` arrives when it terminates. If
|
||||||
|
/// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued
|
||||||
|
/// immediately so the caller's `rx.recv()` returns without parking.
|
||||||
|
pub fn monitor(target: Pid) -> Monitor {
|
||||||
|
let (tx, rx) = channel::<Down>();
|
||||||
|
|
||||||
|
// Register under the shared lock. We allocate the id and (if the target is
|
||||||
|
// live) clone the sender into its monitor list, keeping the original `tx`
|
||||||
|
// for the NoProc fallback. `tx.clone()` only touches the channel's own
|
||||||
|
// mutex, never the shared runtime mutex, so it is safe under the lock — but
|
||||||
|
// we must not *send* here, as `Sender::send` can call back in to unpark a
|
||||||
|
// parked receiver and the shared mutex is not reentrant.
|
||||||
|
let (id, registered) = with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
let id = s.alloc_monitor_id();
|
||||||
|
let registered = match s.slot_mut(target) {
|
||||||
|
Some(slot) if !matches!(slot.state, State::Done) => {
|
||||||
|
slot.monitors.push((id, tx.clone()));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
(id, registered)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if !registered {
|
||||||
|
let _ = tx.send(Down { pid: target, reason: DownReason::NoProc });
|
||||||
|
}
|
||||||
|
|
||||||
|
Monitor { id, target, rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
|
||||||
|
/// on the target's slot and removed, or `None` if there was nothing to remove
|
||||||
|
/// — the target already fired its `Down` (the registration is drained on
|
||||||
|
/// finalize), was never alive (`NoProc`), or has been reclaimed.
|
||||||
|
///
|
||||||
|
/// This stops any *future* `Down`. To also discard a `Down` the target may have
|
||||||
|
/// *already* queued (the finalize-races-demonitor case), drop `m` afterwards;
|
||||||
|
/// dropping the [`Monitor`] closes its receiver and the queued notice goes with
|
||||||
|
/// it — the analogue of Erlang's `demonitor(Ref, [flush])`.
|
||||||
|
pub fn demonitor(m: &Monitor) -> Option<MonitorId> {
|
||||||
|
// Remove the registration under the lock, but move the `Sender` *out* and
|
||||||
|
// let it drop only after the lock is released: dropping the last sender
|
||||||
|
// runs `Sender::drop`, which may unpark a parked receiver → `with_shared`,
|
||||||
|
// and the shared mutex is not reentrant.
|
||||||
|
let removed: Option<(MonitorId, Sender<Down>)> = with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
let slot = s.slot_mut(m.target)?;
|
||||||
|
let pos = slot.monitors.iter().position(|(mid, _)| *mid == m.id)?;
|
||||||
|
Some(slot.monitors.remove(pos))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// `removed`'s sender drops here, outside the lock.
|
||||||
|
removed.map(|(id, _sender)| id)
|
||||||
|
}
|
||||||
+67
-6
@@ -27,9 +27,20 @@
|
|||||||
|
|
||||||
use std::alloc::{GlobalAlloc, Layout, System};
|
use std::alloc::{GlobalAlloc, Layout, System};
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
|
pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
|
||||||
|
|
||||||
|
// The timeslice is measured in `rdtsc()` ticks, and the tick *unit* differs by
|
||||||
|
// ISA (see `crate::arch::read_cycle_counter`). Both constants target ~100µs.
|
||||||
|
// x86-64 : TSC ≈ CPU base clock; 300_000 ticks ≈ 100µs on a 3 GHz core.
|
||||||
|
// aarch64: CNTVCT runs at CNTFRQ_EL0, commonly ~24 MHz; 2_400 ticks ≈ 100µs.
|
||||||
|
// A real implementation would read the frequency at startup and compute this;
|
||||||
|
// for now the constant is calibrated per-arch. Tune on-device if needed.
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
|
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 2_400; // ≈ 100µs at a 24 MHz CNTVCT
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// While `false`, the allocator hook is a no-op.
|
/// While `false`, the allocator hook is a no-op.
|
||||||
@@ -49,6 +60,54 @@ thread_local! {
|
|||||||
/// Per-thread copy of the configured timeslice, written once at
|
/// Per-thread copy of the configured timeslice, written once at
|
||||||
/// scheduler-thread startup.
|
/// scheduler-thread startup.
|
||||||
static CONFIGURED_TIMESLICE_CYCLES: Cell<u64> = const { Cell::new(DEFAULT_TIMESLICE_CYCLES) };
|
static CONFIGURED_TIMESLICE_CYCLES: Cell<u64> = const { Cell::new(DEFAULT_TIMESLICE_CYCLES) };
|
||||||
|
|
||||||
|
/// Raw pointer to the on-CPU actor's cancellation flag, set by the
|
||||||
|
/// scheduler on resume and reset to null when control returns to it.
|
||||||
|
/// Null while no actor is running, so observation points are inert on the
|
||||||
|
/// scheduler's own stack. A raw pointer (not a cloned `Arc`) keeps the
|
||||||
|
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
|
||||||
|
/// the safety argument.
|
||||||
|
static CURRENT_STOP: Cell<*const AtomicBool> = const { Cell::new(std::ptr::null()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cooperative-cancellation observation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bind the on-CPU actor's stop flag. Called by the scheduler immediately
|
||||||
|
/// before `switch_to_actor`. `flag` must point into the resuming actor's
|
||||||
|
/// `Arc<AtomicBool>` heap box.
|
||||||
|
pub(crate) fn set_current_stop(flag: *const AtomicBool) {
|
||||||
|
CURRENT_STOP.with(|c| c.set(flag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unbind the stop flag. Called by the scheduler on the return path from an
|
||||||
|
/// actor, so the pointer never leaks into the scheduler loop or the next actor.
|
||||||
|
pub(crate) fn clear_current_stop() {
|
||||||
|
CURRENT_STOP.with(|c| c.set(std::ptr::null()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observation point for cooperative cancellation. If the on-CPU actor has
|
||||||
|
/// been flagged for stop, raise the sentinel panic so the trampoline's
|
||||||
|
/// `catch_unwind` tears the stack down (running Drop) and reports
|
||||||
|
/// `Outcome::Stopped`, distinct from a user `Panic`. A no-op (one TLS load,
|
||||||
|
/// one relaxed atomic load) on the common path.
|
||||||
|
///
|
||||||
|
/// Called from `maybe_preempt` (amortised, the `check!()`/alloc path) and from
|
||||||
|
/// the wakeup side of every blocking park (`park_current`/`yield_now`), which
|
||||||
|
/// is past the prep-to-park window — so it can never lose a wakeup.
|
||||||
|
#[inline]
|
||||||
|
pub fn check_cancelled() {
|
||||||
|
let p = CURRENT_STOP.with(|c| c.get());
|
||||||
|
// SAFETY: `p` is either null (no actor on-CPU — the scheduler clears it on
|
||||||
|
// every return) or a pointer into the on-CPU actor's `Arc<AtomicBool>`
|
||||||
|
// heap box. That box outlives the run: an actor's slot is not finalized or
|
||||||
|
// reclaimed while the actor is on-CPU (finalize runs only after it yields
|
||||||
|
// back), and the Arc keeps the box alive at a fixed address even if the
|
||||||
|
// slot table Vec reallocates underneath it.
|
||||||
|
if !p.is_null() && unsafe { (*p).load(Ordering::Relaxed) } {
|
||||||
|
std::panic::panic_any(crate::actor::StopSentinel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called once per scheduler thread at startup (before any actor runs).
|
/// Called once per scheduler thread at startup (before any actor runs).
|
||||||
@@ -67,14 +126,12 @@ pub fn reset_timeslice() {
|
|||||||
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
TIMESLICE_START.with(|c| c.set(rdtsc()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-core cycle counter for the timeslice clock. Delegates to the active
|
||||||
|
/// arch backend (`rdtsc` on x86-64, `CNTVCT_EL0` on aarch64). The tick unit
|
||||||
|
/// is ISA-defined, so `DEFAULT_TIMESLICE_CYCLES` is calibrated per-arch below.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn rdtsc() -> u64 {
|
pub fn rdtsc() -> u64 {
|
||||||
unsafe {
|
crate::arch::read_cycle_counter()
|
||||||
// SAFETY: x86-64 only. `lfence` serialises the instruction stream so
|
|
||||||
// we don't measure time before prior instructions retire.
|
|
||||||
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
|
|
||||||
core::arch::x86_64::_rdtsc()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PreemptingAllocator;
|
pub struct PreemptingAllocator;
|
||||||
@@ -122,6 +179,10 @@ pub fn maybe_preempt() {
|
|||||||
let n = c.get();
|
let n = c.get();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get()));
|
c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get()));
|
||||||
|
// Cooperative cancellation shares the amortised cadence with the
|
||||||
|
// timeslice check. Observe a pending stop first: if we are being
|
||||||
|
// cancelled there is no point yielding, we unwind instead.
|
||||||
|
check_cancelled();
|
||||||
if PREEMPTION_ENABLED.with(|e| e.get()) {
|
if PREEMPTION_ENABLED.with(|e| e.get()) {
|
||||||
let start = TIMESLICE_START.with(|s| s.get());
|
let start = TIMESLICE_START.with(|s| s.get());
|
||||||
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
|
||||||
|
|||||||
+140
-32
@@ -37,13 +37,14 @@ use crate::actor::{
|
|||||||
use crate::channel::Sender;
|
use crate::channel::Sender;
|
||||||
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
|
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
|
||||||
use crate::io::IoThread;
|
use crate::io::IoThread;
|
||||||
|
use crate::monitor::{Down, DownReason, MonitorId};
|
||||||
use crate::pid::Pid;
|
use crate::pid::Pid;
|
||||||
use crate::preempt::PREEMPTION_ENABLED;
|
use crate::preempt::PREEMPTION_ENABLED;
|
||||||
use crate::supervisor::Signal;
|
use crate::supervisor::Signal;
|
||||||
use crate::timer::Timers;
|
use crate::timer::Timers;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
@@ -223,6 +224,16 @@ pub(crate) struct Slot {
|
|||||||
pub(crate) waiters: Vec<Pid>,
|
pub(crate) waiters: Vec<Pid>,
|
||||||
pub(crate) outcome: Option<Outcome>,
|
pub(crate) outcome: Option<Outcome>,
|
||||||
pub(crate) supervisor_channel: Option<Sender<Signal>>,
|
pub(crate) supervisor_channel: Option<Sender<Signal>>,
|
||||||
|
/// Watchers registered via `monitor()`, each tagged with its
|
||||||
|
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
|
||||||
|
/// `Down` when this actor terminates (drained in `finalize_actor`).
|
||||||
|
/// Distinct from `supervisor_channel`, which is the parent's single funnel.
|
||||||
|
pub(crate) monitors: Vec<(MonitorId, Sender<Down>)>,
|
||||||
|
/// Bidirectional links (roadmap #3). Each entry is a peer whose abnormal
|
||||||
|
/// death propagates to this actor (and vice versa — the relationship is
|
||||||
|
/// recorded on both slots). Walked + cleared in `finalize_actor`. Reset in
|
||||||
|
/// all three slot-lifecycle sites, like `monitors`.
|
||||||
|
pub(crate) links: Vec<Pid>,
|
||||||
pub(crate) outstanding_handles: u32,
|
pub(crate) outstanding_handles: u32,
|
||||||
pub(crate) pending_io_result: Option<crate::io::IoResult>,
|
pub(crate) pending_io_result: Option<crate::io::IoResult>,
|
||||||
/// Set by `unpark()` when the actor is still running (not yet Parked).
|
/// Set by `unpark()` when the actor is still running (not yet Parked).
|
||||||
@@ -240,6 +251,8 @@ impl Slot {
|
|||||||
waiters: Vec::new(),
|
waiters: Vec::new(),
|
||||||
outcome: None,
|
outcome: None,
|
||||||
supervisor_channel: None,
|
supervisor_channel: None,
|
||||||
|
monitors: Vec::new(),
|
||||||
|
links: Vec::new(),
|
||||||
outstanding_handles: 0,
|
outstanding_handles: 0,
|
||||||
pending_io_result: None,
|
pending_io_result: None,
|
||||||
pending_unpark: false,
|
pending_unpark: false,
|
||||||
@@ -260,6 +273,10 @@ pub(crate) struct SharedState {
|
|||||||
/// `pending_closures[idx]` is `Some` iff the actor at slot `idx` has not
|
/// `pending_closures[idx]` is `Some` iff the actor at slot `idx` has not
|
||||||
/// yet been resumed for the first time. Grows on demand; never shrinks.
|
/// yet been resumed for the first time. Grows on demand; never shrinks.
|
||||||
pub(crate) pending_closures: Vec<Option<Closure>>,
|
pub(crate) pending_closures: Vec<Option<Closure>>,
|
||||||
|
/// Monotonic source of `MonitorId`s, bumped under the shared lock by
|
||||||
|
/// `alloc_monitor_id`. Never reused, so `demonitor` can name one of several
|
||||||
|
/// monitors on the same target unambiguously.
|
||||||
|
pub(crate) next_monitor_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedState {
|
impl SharedState {
|
||||||
@@ -272,9 +289,17 @@ impl SharedState {
|
|||||||
timers: Timers::new(),
|
timers: Timers::new(),
|
||||||
io: None,
|
io: None,
|
||||||
pending_closures: Vec::new(), // indexed by slot index
|
pending_closures: Vec::new(), // indexed by slot index
|
||||||
|
next_monitor_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allocate the next process-unique `MonitorId`. Caller holds the shared
|
||||||
|
/// lock (this is only reached from `monitor()` under `with_shared`).
|
||||||
|
pub(crate) fn alloc_monitor_id(&mut self) -> MonitorId {
|
||||||
|
self.next_monitor_id += 1;
|
||||||
|
MonitorId(self.next_monitor_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn allocate_slot(&mut self) -> (u32, u32) {
|
pub(crate) fn allocate_slot(&mut self) -> (u32, u32) {
|
||||||
if let Some(idx) = self.free_list.pop() {
|
if let Some(idx) = self.free_list.pop() {
|
||||||
let gen = self.slots[idx as usize].generation;
|
let gen = self.slots[idx as usize].generation;
|
||||||
@@ -516,6 +541,8 @@ pub(crate) fn reclaim_slot(s: &mut SharedState, pid: Pid) {
|
|||||||
slot.outcome = None;
|
slot.outcome = None;
|
||||||
slot.waiters.clear();
|
slot.waiters.clear();
|
||||||
slot.supervisor_channel = None;
|
slot.supervisor_channel = None;
|
||||||
|
slot.monitors.clear();
|
||||||
|
slot.links.clear();
|
||||||
slot.state = State::Done;
|
slot.state = State::Done;
|
||||||
slot.outstanding_handles = 0;
|
slot.outstanding_handles = 0;
|
||||||
slot.pending_unpark = false;
|
slot.pending_unpark = false;
|
||||||
@@ -528,15 +555,20 @@ pub(crate) fn reclaim_slot(s: &mut SharedState, pid: Pid) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
||||||
let (joiner_outcome, sup_signal) = match outcome {
|
let (joiner_outcome, sup_signal, down_reason) = match outcome {
|
||||||
Outcome::Exit => (Outcome::Exit, Signal::Exit(pid)),
|
Outcome::Exit => (Outcome::Exit, Signal::Exit(pid), DownReason::Exit),
|
||||||
Outcome::Panic(payload) => (
|
Outcome::Panic(payload) => (
|
||||||
Outcome::Panic(payload),
|
Outcome::Panic(payload),
|
||||||
Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>),
|
Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>),
|
||||||
|
DownReason::Panic,
|
||||||
),
|
),
|
||||||
|
// Cooperative cancellation: kept distinct from a normal Exit so a
|
||||||
|
// supervisor's await logic (roadmap #2) can tell "I stopped it" apart
|
||||||
|
// from "it finished on its own".
|
||||||
|
Outcome::Stopped => (Outcome::Stopped, Signal::Stopped(pid), DownReason::Stopped),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (waiters, supervisor_pid, recycled_stack) = inner.with_shared(|s| {
|
let (waiters, supervisor_pid, monitors, recycled_stack, links) = inner.with_shared(|s| {
|
||||||
let slot = s.slot_mut(pid).expect("finalize_actor: slot vanished");
|
let slot = s.slot_mut(pid).expect("finalize_actor: slot vanished");
|
||||||
let sup = slot.actor.as_ref().map(|a| a.supervisor);
|
let sup = slot.actor.as_ref().map(|a| a.supervisor);
|
||||||
// Extract the stack before clearing the actor so we can recycle it
|
// Extract the stack before clearing the actor so we can recycle it
|
||||||
@@ -544,7 +576,19 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
|||||||
let stack = slot.actor.take().map(|a| a.stack);
|
let stack = slot.actor.take().map(|a| a.stack);
|
||||||
slot.outcome = Some(joiner_outcome);
|
slot.outcome = Some(joiner_outcome);
|
||||||
slot.state = State::Done;
|
slot.state = State::Done;
|
||||||
(std::mem::take(&mut slot.waiters), sup, stack)
|
let monitors = std::mem::take(&mut slot.monitors);
|
||||||
|
let waiters = std::mem::take(&mut slot.waiters);
|
||||||
|
let links = std::mem::take(&mut slot.links);
|
||||||
|
// Drop this (dying) pid from each linked peer's list now, under the
|
||||||
|
// same lock. Done regardless of how we died, so a peer that later
|
||||||
|
// finalizes won't try to propagate back to this about-to-be-reclaimed
|
||||||
|
// slot — which also makes the abnormal-death cascade acyclic.
|
||||||
|
for &peer in &links {
|
||||||
|
if let Some(ps) = s.slot_mut(peer) {
|
||||||
|
ps.links.retain(|p| *p != pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(waiters, sup, monitors, stack, links)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the stack to the pool outside the shared lock. The pool grows
|
// Return the stack to the pool outside the shared lock. The pool grows
|
||||||
@@ -568,6 +612,38 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify monitors. Sent outside the shared lock for the same reason as the
|
||||||
|
// supervisor signal: `send` may unpark a parked receiver, which re-takes
|
||||||
|
// the shared mutex.
|
||||||
|
for (_, m) in monitors {
|
||||||
|
let _ = m.send(Down { pid, reason: down_reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate to linked peers. A normal exit never propagates; an abnormal
|
||||||
|
// death (panic or cooperative stop) reaches each linked peer — a trapping
|
||||||
|
// peer gets an `ExitSignal` message and survives, a non-trapping peer is
|
||||||
|
// cooperatively stopped (which cascades along *its* links in turn). The
|
||||||
|
// reverse links were cleared above, so this never ping-pongs back. Decide
|
||||||
|
// trap-vs-stop under the lock, then act after releasing it: both
|
||||||
|
// `Sender::send` and `request_stop` re-enter the shared mutex.
|
||||||
|
if matches!(down_reason, DownReason::Panic | DownReason::Stopped) {
|
||||||
|
for peer in links {
|
||||||
|
let trap = inner.with_shared(|s| match s.slot(peer) {
|
||||||
|
Some(slot) if !matches!(slot.state, State::Done) => {
|
||||||
|
Some(slot.actor.as_ref().and_then(|a| a.trap.clone()))
|
||||||
|
}
|
||||||
|
_ => None, // peer already gone; nothing to do
|
||||||
|
});
|
||||||
|
match trap {
|
||||||
|
Some(Some(tx)) => {
|
||||||
|
let _ = tx.send(crate::link::ExitSignal { from: pid, reason: down_reason });
|
||||||
|
}
|
||||||
|
Some(None) => crate::scheduler::request_stop(peer),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unpark joiners.
|
// Unpark joiners.
|
||||||
for joiner in waiters {
|
for joiner in waiters {
|
||||||
crate::scheduler::unpark(joiner);
|
crate::scheduler::unpark(joiner);
|
||||||
@@ -594,10 +670,31 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
// losers skip immediately and proceed to step 2.
|
// losers skip immediately and proceed to step 2.
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
if let Ok(_drain_guard) = inner.drain_lock.try_lock() {
|
if let Ok(_drain_guard) = inner.drain_lock.try_lock() {
|
||||||
let now = std::time::Instant::now();
|
// Drain due timers and IO completions in a SINGLE shared-lock
|
||||||
|
// acquisition. Previously this took two separate `with_shared`
|
||||||
// Drain due timers.
|
// calls (one for timers, one for IO) plus an unconditional
|
||||||
let due = inner.with_shared(|s| s.timers.pop_due(now));
|
// `Instant::now()` every loop iteration. On the pure-yield /
|
||||||
|
// pure-compute hot path there are never any timers, so the clock
|
||||||
|
// read and the extra lock were pure per-yield overhead. We now
|
||||||
|
// read the clock only when the timer heap is non-empty and fold
|
||||||
|
// both drains into one lock hold.
|
||||||
|
//
|
||||||
|
// IO completions are still drained unconditionally while the IO
|
||||||
|
// subsystem is present: the IO/pool threads push completions onto
|
||||||
|
// their own mutex (not `shared`), so the scheduler can't cheaply
|
||||||
|
// know in advance whether any arrived — it must look. That look is
|
||||||
|
// a single empty-VecDeque check on the empty path.
|
||||||
|
let (due, completions) = inner.with_shared(|s| {
|
||||||
|
let due = if s.timers.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
s.timers.pop_due(std::time::Instant::now())
|
||||||
|
};
|
||||||
|
let completions = s.io.as_mut()
|
||||||
|
.map(|io| io.drain_completions())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(due, completions)
|
||||||
|
});
|
||||||
for entry in due {
|
for entry in due {
|
||||||
match entry.reason {
|
match entry.reason {
|
||||||
crate::timer::Reason::Sleep => {
|
crate::timer::Reason::Sleep => {
|
||||||
@@ -631,10 +728,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain IO completions.
|
|
||||||
let completions = inner.with_shared(|s| {
|
|
||||||
s.io.as_mut().map(|io| io.drain_completions()).unwrap_or_default()
|
|
||||||
});
|
|
||||||
for completion in completions {
|
for completion in completions {
|
||||||
match completion {
|
match completion {
|
||||||
crate::io::Completion::Blocking { pid, result } => {
|
crate::io::Completion::Blocking { pid, result } => {
|
||||||
@@ -697,7 +790,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
// None with all_clear=false — idle, wait
|
// None with all_clear=false — idle, wait
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
enum PopResult {
|
enum PopResult {
|
||||||
Work(Pid, usize, Option<Closure>),
|
Work(Pid, usize, *const AtomicBool, Option<Closure>),
|
||||||
Idle {
|
Idle {
|
||||||
next_deadline: Option<std::time::Instant>,
|
next_deadline: Option<std::time::Instant>,
|
||||||
io_outstanding: u32,
|
io_outstanding: u32,
|
||||||
@@ -705,23 +798,30 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
},
|
},
|
||||||
AllDone,
|
AllDone,
|
||||||
}
|
}
|
||||||
// SAFETY: the raw pointer is the actor's stack pointer, valid for the
|
// SAFETY: the raw pointers are (a) the actor's stack pointer and (b) a
|
||||||
// lifetime of the actor's Slot. It is only dereferenced by
|
// pointer into the actor's `Arc<AtomicBool>` stop flag, both valid for
|
||||||
// switch_to_actor() on this same thread immediately after this block.
|
// the lifetime of the actor's Slot. They are only used by this same
|
||||||
unsafe impl Send for PopResult {} // closure is Send; usize sp is plain data
|
// thread immediately after this block (switch_to_actor / set_current_stop).
|
||||||
|
unsafe impl Send for PopResult {} // closure is Send; usize sp + stop ptr are plain data
|
||||||
|
|
||||||
let pop = inner.with_shared(|s| {
|
let pop = inner.with_shared(|s| {
|
||||||
let len = s.run_queue.len() as u64;
|
let len = s.run_queue.len() as u64;
|
||||||
stats.run_queue_len.store(len, Ordering::Relaxed);
|
stats.run_queue_len.store(len, Ordering::Relaxed);
|
||||||
|
|
||||||
if let Some(pid) = s.run_queue.pop_front() {
|
if let Some(pid) = s.run_queue.pop_front() {
|
||||||
// Happy path: got a PID, grab SP and optional first closure.
|
// Happy path: got a PID, grab SP, the stop-flag pointer, and
|
||||||
let sp = s.slot(pid)
|
// optional first closure. We hand out a raw pointer into the
|
||||||
.and_then(|slot| slot.actor.as_ref().map(|a| a.sp));
|
// actor's `Arc<AtomicBool>` rather than cloning the Arc: the
|
||||||
match sp {
|
// box stays alive and at a fixed address for as long as the
|
||||||
Some(sp) => {
|
// actor is on-CPU (it can't be finalized until it yields back),
|
||||||
|
// so the scheduler's resume path pays no atomic ref-count cost.
|
||||||
|
let info = s.slot(pid)
|
||||||
|
.and_then(|slot| slot.actor.as_ref()
|
||||||
|
.map(|a| (a.sp, std::sync::Arc::as_ptr(&a.stop))));
|
||||||
|
match info {
|
||||||
|
Some((sp, stop)) => {
|
||||||
let closure = s.pop_pending_closure(pid);
|
let closure = s.pop_pending_closure(pid);
|
||||||
PopResult::Work(pid, sp, closure)
|
PopResult::Work(pid, sp, stop, closure)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Stale PID (actor already finalized). Treat as idle
|
// Stale PID (actor already finalized). Treat as idle
|
||||||
@@ -735,11 +835,17 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Queue was empty. Re-examine to decide exit vs wait.
|
// Queue was empty. Re-examine to decide exit vs wait.
|
||||||
// All four conditions must hold simultaneously before we exit:
|
// We exit only when nothing can ever produce more work:
|
||||||
// 1. run queue is still empty
|
// 1. run queue is still empty
|
||||||
// 2. no live actors (nothing parked, nothing mid-finalize)
|
// 2. no live actors (nothing parked, nothing mid-finalize)
|
||||||
// 3. no pending timers
|
// 3. no outstanding IO
|
||||||
// 4. no outstanding IO
|
// Pending timers are deliberately NOT a condition: a timer can
|
||||||
|
// only ever do something useful by waking a live actor, so once
|
||||||
|
// `live == 0` every remaining timer entry is orphaned (e.g. a
|
||||||
|
// sleeping actor that was cooperatively cancelled out of its
|
||||||
|
// sleep, leaving its deadline behind). Such entries must not
|
||||||
|
// keep the runtime alive until they fire — that could be an
|
||||||
|
// arbitrarily long hang. They are dropped here on the way out.
|
||||||
let next = s.timers.peek_deadline();
|
let next = s.timers.peek_deadline();
|
||||||
let (out, fd) = match s.io.as_ref() {
|
let (out, fd) = match s.io.as_ref() {
|
||||||
Some(io) => (
|
Some(io) => (
|
||||||
@@ -749,9 +855,9 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
None => (0, None),
|
None => (0, None),
|
||||||
};
|
};
|
||||||
let live = s.slots.iter().filter(|slot| slot.actor.is_some()).count();
|
let live = s.slots.iter().filter(|slot| slot.actor.is_some()).count();
|
||||||
let all_clear = s.run_queue.is_empty() && live == 0
|
let all_clear = s.run_queue.is_empty() && live == 0 && out == 0;
|
||||||
&& next.is_none() && out == 0;
|
|
||||||
if all_clear {
|
if all_clear {
|
||||||
|
s.timers.clear();
|
||||||
PopResult::AllDone
|
PopResult::AllDone
|
||||||
} else {
|
} else {
|
||||||
PopResult::Idle { next_deadline: next, io_outstanding: out, wake_fd: fd }
|
PopResult::Idle { next_deadline: next, io_outstanding: out, wake_fd: fd }
|
||||||
@@ -759,10 +865,10 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let (pid, sp, first_closure) = match pop {
|
let (pid, sp, stop_flag, first_closure) = match pop {
|
||||||
PopResult::Work(pid, sp, closure) => {
|
PopResult::Work(pid, sp, stop, closure) => {
|
||||||
crate::te!(crate::trace::Event::Dequeue(pid));
|
crate::te!(crate::trace::Event::Dequeue(pid));
|
||||||
(pid, sp, closure)
|
(pid, sp, stop, closure)
|
||||||
}
|
}
|
||||||
PopResult::AllDone => return,
|
PopResult::AllDone => return,
|
||||||
PopResult::Idle { next_deadline, io_outstanding, wake_fd } => {
|
PopResult::Idle { next_deadline, io_outstanding, wake_fd } => {
|
||||||
@@ -808,6 +914,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
|
|
||||||
set_actor_sp(sp);
|
set_actor_sp(sp);
|
||||||
set_current_pid(pid);
|
set_current_pid(pid);
|
||||||
|
crate::preempt::set_current_stop(stop_flag);
|
||||||
reset_actor_done();
|
reset_actor_done();
|
||||||
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
|
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
|
||||||
crate::preempt::reset_timeslice();
|
crate::preempt::reset_timeslice();
|
||||||
@@ -819,6 +926,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
|
|||||||
PREEMPTION_ENABLED.with(|c| c.set(false));
|
PREEMPTION_ENABLED.with(|c| c.set(false));
|
||||||
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
|
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
|
||||||
clear_current_pid();
|
clear_current_pid();
|
||||||
|
crate::preempt::clear_current_stop();
|
||||||
|
|
||||||
let intent = YIELD_INTENT.with(|c| c.get());
|
let intent = YIELD_INTENT.with(|c| c.get());
|
||||||
let new_sp = get_actor_sp();
|
let new_sp = get_actor_sp();
|
||||||
|
|||||||
+52
-1
@@ -79,6 +79,10 @@ impl JoinHandle {
|
|||||||
return match o {
|
return match o {
|
||||||
Outcome::Exit => Ok(()),
|
Outcome::Exit => Ok(()),
|
||||||
Outcome::Panic(p) => Err(JoinError { payload: p }),
|
Outcome::Panic(p) => Err(JoinError { payload: p }),
|
||||||
|
// A cooperative stop carries no panic payload to
|
||||||
|
// propagate; the *reason* is observable via monitors
|
||||||
|
// (DownReason::Stopped). join() therefore reports Ok.
|
||||||
|
Outcome::Stopped => Ok(()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -147,12 +151,21 @@ pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHa
|
|||||||
let (idx, gen) = s.allocate_slot();
|
let (idx, gen) = s.allocate_slot();
|
||||||
let pid = Pid::new(idx, gen);
|
let pid = Pid::new(idx, gen);
|
||||||
let slot = &mut s.slots[idx as usize];
|
let slot = &mut s.slots[idx as usize];
|
||||||
slot.actor = Some(crate::actor::Actor { pid, stack, sp, supervisor });
|
slot.actor = Some(crate::actor::Actor {
|
||||||
|
pid,
|
||||||
|
stack,
|
||||||
|
sp,
|
||||||
|
supervisor,
|
||||||
|
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
trap: None,
|
||||||
|
});
|
||||||
slot.state = crate::runtime::State::Runnable;
|
slot.state = crate::runtime::State::Runnable;
|
||||||
slot.outstanding_handles = 1;
|
slot.outstanding_handles = 1;
|
||||||
slot.outcome = None;
|
slot.outcome = None;
|
||||||
slot.waiters.clear();
|
slot.waiters.clear();
|
||||||
slot.supervisor_channel = None;
|
slot.supervisor_channel = None;
|
||||||
|
slot.monitors.clear();
|
||||||
|
slot.links.clear();
|
||||||
slot.pending_unpark = false;
|
slot.pending_unpark = false;
|
||||||
slot.pending_io_result = None;
|
slot.pending_io_result = None;
|
||||||
s.run_queue.push_back(pid);
|
s.run_queue.push_back(pid);
|
||||||
@@ -184,11 +197,18 @@ pub fn self_pid() -> Pid {
|
|||||||
pub fn yield_now() {
|
pub fn yield_now() {
|
||||||
runtime::set_yield_intent(YieldIntent::Yield);
|
runtime::set_yield_intent(YieldIntent::Yield);
|
||||||
unsafe { crate::context::switch_to_scheduler() };
|
unsafe { crate::context::switch_to_scheduler() };
|
||||||
|
// Observation point: we may have been resumed only to be cancelled.
|
||||||
|
crate::preempt::check_cancelled();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn park_current() {
|
pub fn park_current() {
|
||||||
runtime::set_yield_intent(YieldIntent::Park);
|
runtime::set_yield_intent(YieldIntent::Park);
|
||||||
unsafe { crate::context::switch_to_scheduler() };
|
unsafe { crate::context::switch_to_scheduler() };
|
||||||
|
// Observation point on the wakeup side of every blocking primitive
|
||||||
|
// (recv/sleep/mutex/io/join). Past the prep-to-park window, so this never
|
||||||
|
// races a wakeup: a stop unparks us, we resume here, and unwind out of
|
||||||
|
// whatever blocking call parked us — running Drop along the way.
|
||||||
|
crate::preempt::check_cancelled();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unpark(pid: Pid) {
|
pub fn unpark(pid: Pid) {
|
||||||
@@ -219,6 +239,37 @@ pub fn unpark(pid: Pid) {
|
|||||||
let _ = result;
|
let _ = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request cooperative cancellation of `pid`.
|
||||||
|
///
|
||||||
|
/// Sets the actor's stop flag and, if it is parked, wakes it so it observes
|
||||||
|
/// the flag promptly. The actor realises the stop as a controlled unwind at
|
||||||
|
/// its next observation point (`check!()`/allocation, or the wakeup side of a
|
||||||
|
/// blocking park), terminating with `Outcome::Stopped`.
|
||||||
|
///
|
||||||
|
/// This is best-effort and cooperative: an actor that never reaches an
|
||||||
|
/// observation point — a tight loop with no `check!()`, no allocation, and no
|
||||||
|
/// blocking op — cannot be stopped, exactly as it cannot be preempted. A no-op
|
||||||
|
/// if `pid` is already gone.
|
||||||
|
pub fn request_stop(pid: Pid) {
|
||||||
|
let _ = try_with_runtime(|inner| {
|
||||||
|
inner.with_shared(|s| {
|
||||||
|
if let Some(slot) = s.slot_mut(pid) {
|
||||||
|
if let Some(actor) = slot.actor.as_ref() {
|
||||||
|
actor.stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
// Wake a parked target so it reaches its post-park observation
|
||||||
|
// point now. A Runnable target will observe on its next yield;
|
||||||
|
// a Done target has nothing to stop.
|
||||||
|
if matches!(slot.state, crate::runtime::State::Parked) {
|
||||||
|
slot.state = crate::runtime::State::Runnable;
|
||||||
|
s.run_queue.push_back(pid);
|
||||||
|
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// NoPreempt
|
// NoPreempt
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ pub enum Signal {
|
|||||||
Exit(Pid),
|
Exit(Pid),
|
||||||
/// The child panicked. Payload is whatever `panic!` was called with.
|
/// The child panicked. Payload is whatever `panic!` was called with.
|
||||||
Panic(Pid, Box<dyn Any + Send>),
|
Panic(Pid, Box<dyn Any + Send>),
|
||||||
|
/// The child was cooperatively cancelled via `request_stop`. Carries no
|
||||||
|
/// payload — there is nothing to propagate. Kept distinct from `Exit` so a
|
||||||
|
/// supervisor can distinguish a stop it requested from a self-termination.
|
||||||
|
Stopped(Pid),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Signal {
|
impl std::fmt::Debug for Signal {
|
||||||
@@ -23,6 +27,7 @@ impl std::fmt::Debug for Signal {
|
|||||||
match self {
|
match self {
|
||||||
Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid),
|
Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid),
|
||||||
Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid),
|
Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid),
|
||||||
|
Signal::Stopped(pid) => write!(f, "Signal::Stopped({:?})", pid),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +37,283 @@ impl Signal {
|
|||||||
match self {
|
match self {
|
||||||
Signal::Exit(p) => *p,
|
Signal::Exit(p) => *p,
|
||||||
Signal::Panic(p, _) => *p,
|
Signal::Panic(p, _) => *p,
|
||||||
|
Signal::Stopped(p) => *p,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One-for-one supervisor
|
||||||
|
//
|
||||||
|
// A supervisor is itself an actor. It spawns each child under its own pid so
|
||||||
|
// that every child death funnels into one mailbox (the `supervisor_channel`),
|
||||||
|
// then loops: receive a Signal, decide per the child's `Restart` policy
|
||||||
|
// whether to restart, and enforce a restart-intensity cap so a child that
|
||||||
|
// crashes in a tight loop eventually gives up instead of spinning forever.
|
||||||
|
//
|
||||||
|
// What this does NOT do: *forcibly* terminate a running child. smarm actors
|
||||||
|
// share a heap and rely on Drop/RAII, so tearing down a peer's stack from
|
||||||
|
// outside is unsound. Stopping a sibling — whether for `one_for_all` /
|
||||||
|
// `rest_for_one`, for a propagated link death, or for the ordered shutdown
|
||||||
|
// below — is therefore *cooperative*: `request_stop` flags the child and it
|
||||||
|
// unwinds at its next observation point. A child with no observation points
|
||||||
|
// (a tight loop with no `check!()`, allocation, or blocking op) cannot be
|
||||||
|
// stopped, exactly as it cannot be preempted. When the intensity cap trips,
|
||||||
|
// this supervisor stops restarting and tears the remaining children down in
|
||||||
|
// reverse start order before returning.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use crate::channel::channel;
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// When a terminated child should be restarted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Restart {
|
||||||
|
/// Always restart, on normal exit or panic.
|
||||||
|
Permanent,
|
||||||
|
/// Restart only on panic; a normal exit is treated as "done".
|
||||||
|
Transient,
|
||||||
|
/// Never restart.
|
||||||
|
Temporary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child managed by a supervisor: a restart policy plus a *factory* that
|
||||||
|
/// produces a fresh instance of the child's work on each (re)start.
|
||||||
|
///
|
||||||
|
/// The factory is `Fn` (not `FnOnce`) precisely so it can be called again to
|
||||||
|
/// restart; it is shared via `Arc` so the spec stays cheap to clone.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChildSpec {
|
||||||
|
start: Arc<dyn Fn() + Send + Sync + 'static>,
|
||||||
|
restart: Restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildSpec {
|
||||||
|
pub fn new(restart: Restart, start: impl Fn() + Send + Sync + 'static) -> Self {
|
||||||
|
Self { start: Arc::new(start), restart }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a supervisor reacts when one child terminates and a restart is due.
|
||||||
|
///
|
||||||
|
/// The *triggering* child's [`Restart`] policy still decides whether a restart
|
||||||
|
/// happens at all; the strategy only decides *which other children* are cycled
|
||||||
|
/// along with it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Strategy {
|
||||||
|
/// Restart only the child that terminated. Siblings are untouched.
|
||||||
|
OneForOne,
|
||||||
|
/// Restart every child: the survivors are cooperatively stopped (in
|
||||||
|
/// reverse start order) and the whole set is restarted in start order.
|
||||||
|
OneForAll,
|
||||||
|
/// Restart the terminated child and every child started *after* it; those
|
||||||
|
/// started before it are untouched.
|
||||||
|
RestForOne,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A supervisor over a fixed set of children.
|
||||||
|
///
|
||||||
|
/// Despite the name (kept for backwards compatibility), the restart strategy
|
||||||
|
/// is selectable via [`OneForOne::strategy`]; the default is
|
||||||
|
/// [`Strategy::OneForOne`]. Build with `new()`, add children with `child()`,
|
||||||
|
/// tune the cap with `intensity()`, then drive it with `run()` from inside an
|
||||||
|
/// actor (typically `spawn(|| OneForOne::new()....run())`).
|
||||||
|
pub struct OneForOne {
|
||||||
|
children: Vec<ChildSpec>,
|
||||||
|
strategy: Strategy,
|
||||||
|
intensity: u32,
|
||||||
|
period: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OneForOne {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Erlang's default supervisor intensity: 1 restart per 5 seconds. We
|
||||||
|
// start a little more permissive but in the same spirit.
|
||||||
|
Self {
|
||||||
|
children: Vec::new(),
|
||||||
|
strategy: Strategy::OneForOne,
|
||||||
|
intensity: 3,
|
||||||
|
period: Duration::from_secs(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OneForOne {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the restart strategy (default [`Strategy::OneForOne`]).
|
||||||
|
pub fn strategy(mut self, strategy: Strategy) -> Self {
|
||||||
|
self.strategy = strategy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow at most `max` restarts within any `period`-long window before the
|
||||||
|
/// supervisor gives up and `run()` returns.
|
||||||
|
pub fn intensity(mut self, max: u32, period: Duration) -> Self {
|
||||||
|
self.intensity = max;
|
||||||
|
self.period = period;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(mut self, spec: ChildSpec) -> Self {
|
||||||
|
self.children.push(spec);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the supervision loop on the current actor. Returns when every child
|
||||||
|
/// has reached a terminal, non-restartable state, or when the restart
|
||||||
|
/// intensity cap is tripped.
|
||||||
|
pub fn run(self) {
|
||||||
|
let me = crate::scheduler::self_pid();
|
||||||
|
let (tx, rx) = channel::<Signal>();
|
||||||
|
crate::scheduler::register_supervisor_channel(me, tx);
|
||||||
|
|
||||||
|
// pid -> index into `self.children`, for the children currently alive.
|
||||||
|
let mut by_pid: HashMap<Pid, usize> = HashMap::new();
|
||||||
|
let mut active: usize = 0;
|
||||||
|
// Sliding window of recent restart instants, for the intensity cap.
|
||||||
|
let mut restarts: Vec<Instant> = Vec::new();
|
||||||
|
|
||||||
|
let start_child = |idx: usize, by_pid: &mut HashMap<Pid, usize>| {
|
||||||
|
let start = self.children[idx].start.clone();
|
||||||
|
let h = crate::scheduler::spawn_under(me, move || (start)());
|
||||||
|
by_pid.insert(h.pid(), idx);
|
||||||
|
// We supervise via the signal funnel, not by joining; drop the
|
||||||
|
// handle so the child's slot is reclaimed promptly on death (the
|
||||||
|
// termination Signal is delivered before reclamation regardless).
|
||||||
|
drop(h);
|
||||||
|
};
|
||||||
|
|
||||||
|
for idx in 0..self.children.len() {
|
||||||
|
start_child(idx, &mut by_pid);
|
||||||
|
active += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A signal that arrives while we are awaiting stop-confirmations (for a
|
||||||
|
// child we are *not* currently stopping) is stashed here and processed
|
||||||
|
// by the main loop before it blocks on `recv` again.
|
||||||
|
let mut pending: VecDeque<Signal> = VecDeque::new();
|
||||||
|
let next_signal = |pending: &mut VecDeque<Signal>| -> Option<Signal> {
|
||||||
|
if let Some(s) = pending.pop_front() {
|
||||||
|
Some(s)
|
||||||
|
} else {
|
||||||
|
rx.recv().ok()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while active > 0 {
|
||||||
|
let sig = match next_signal(&mut pending) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => break, // mailbox closed: nothing left to supervise
|
||||||
|
};
|
||||||
|
let idx = match by_pid.remove(&sig.pid()) {
|
||||||
|
Some(i) => i,
|
||||||
|
None => continue, // stray/duplicate signal
|
||||||
|
};
|
||||||
|
|
||||||
|
// The triggering child's own policy decides whether *anything*
|
||||||
|
// restarts. A cooperative stop counts as abnormal alongside a panic.
|
||||||
|
let abnormal = matches!(sig, Signal::Panic(..) | Signal::Stopped(..));
|
||||||
|
let should_restart = match self.children[idx].restart {
|
||||||
|
Restart::Permanent => true,
|
||||||
|
Restart::Transient => abnormal,
|
||||||
|
Restart::Temporary => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_restart {
|
||||||
|
active -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intensity cap: prune the window, then give up if we are already
|
||||||
|
// at the limit. One triggering failure counts as one restart event,
|
||||||
|
// even when the strategy cycles several children. On giving up we
|
||||||
|
// fall out of the loop and the ordered shutdown below stops any
|
||||||
|
// survivors.
|
||||||
|
let now = Instant::now();
|
||||||
|
restarts.retain(|t| now.duration_since(*t) <= self.period);
|
||||||
|
if restarts.len() as u32 >= self.intensity {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
restarts.push(now);
|
||||||
|
|
||||||
|
// Which *live* siblings get cycled along with the failed child.
|
||||||
|
// (The failed child is already gone — removed from `by_pid` above.)
|
||||||
|
let mut to_stop: Vec<(Pid, usize)> = match self.strategy {
|
||||||
|
Strategy::OneForOne => Vec::new(),
|
||||||
|
Strategy::OneForAll => by_pid.iter().map(|(p, i)| (*p, *i)).collect(),
|
||||||
|
Strategy::RestForOne => by_pid
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, i)| **i > idx)
|
||||||
|
.map(|(p, i)| (*p, *i))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
// Stop survivors in reverse start order (highest child index first).
|
||||||
|
to_stop.sort_unstable_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
// The set we will restart: the failed child plus every sibling we
|
||||||
|
// are about to stop, restarted in start (ascending index) order.
|
||||||
|
let mut restart_set: Vec<usize> = Vec::with_capacity(to_stop.len() + 1);
|
||||||
|
restart_set.push(idx);
|
||||||
|
|
||||||
|
// Request stops, then await each survivor's termination signal
|
||||||
|
// before restarting. `request_stop` on an already-dead pid is a
|
||||||
|
// no-op; in that case its (already-sent) Exit signal serves as the
|
||||||
|
// confirmation. Any signal for a pid we are *not* awaiting is
|
||||||
|
// stashed for the main loop.
|
||||||
|
let mut awaiting: Vec<Pid> = Vec::with_capacity(to_stop.len());
|
||||||
|
for (pid, cidx) in &to_stop {
|
||||||
|
by_pid.remove(pid);
|
||||||
|
restart_set.push(*cidx);
|
||||||
|
crate::scheduler::request_stop(*pid);
|
||||||
|
awaiting.push(*pid);
|
||||||
|
}
|
||||||
|
while !awaiting.is_empty() {
|
||||||
|
let s = match next_signal(&mut pending) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => break, // mailbox closed mid-await; stop waiting
|
||||||
|
};
|
||||||
|
if let Some(pos) = awaiting.iter().position(|p| *p == s.pid()) {
|
||||||
|
awaiting.swap_remove(pos);
|
||||||
|
} else {
|
||||||
|
pending.push_back(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the whole set in start order. Net effect on `active`:
|
||||||
|
// one child died (idx), `to_stop.len()` were stopped, and
|
||||||
|
// `restart_set.len() == 1 + to_stop.len()` are started — so
|
||||||
|
// `active` is unchanged and needs no adjustment here.
|
||||||
|
restart_set.sort_unstable();
|
||||||
|
for cidx in restart_set {
|
||||||
|
start_child(cidx, &mut by_pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered shutdown: stop any survivors in reverse start order and await
|
||||||
|
// their termination. On the normal `active == 0` exit `by_pid` is empty
|
||||||
|
// and this is a no-op; on a cap-trip or mailbox-closed break it tears
|
||||||
|
// the remaining children down deterministically instead of leaking them.
|
||||||
|
let mut survivors: Vec<(Pid, usize)> = by_pid.iter().map(|(p, i)| (*p, *i)).collect();
|
||||||
|
survivors.sort_unstable_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
let mut awaiting: Vec<Pid> = Vec::with_capacity(survivors.len());
|
||||||
|
for (pid, _) in &survivors {
|
||||||
|
crate::scheduler::request_stop(*pid);
|
||||||
|
awaiting.push(*pid);
|
||||||
|
}
|
||||||
|
while !awaiting.is_empty() {
|
||||||
|
let s = match next_signal(&mut pending) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
if let Some(pos) = awaiting.iter().position(|p| *p == s.pid()) {
|
||||||
|
awaiting.swap_remove(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ impl Timers {
|
|||||||
self.heap.is_empty()
|
self.heap.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop all pending entries. Called by the scheduler when it has decided
|
||||||
|
/// no actor is live: any remaining timer is orphaned and exists only to be
|
||||||
|
/// discarded so it can't keep the runtime alive.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.heap.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Soonest pending deadline, or `None` if the heap is empty.
|
/// Soonest pending deadline, or `None` if the heap is empty.
|
||||||
pub fn peek_deadline(&self) -> Option<Instant> {
|
pub fn peek_deadline(&self) -> Option<Instant> {
|
||||||
self.heap.peek().map(|r| r.0.deadline)
|
self.heap.peek().map(|r| r.0.deadline)
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# smarm — task.md (next steps)
|
||||||
|
|
||||||
|
Handoff for a future session reusing this sandbox. Read top to bottom once
|
||||||
|
before starting; the gotchas section is hard-won and will save you a faceplant.
|
||||||
|
|
||||||
|
## Resume the environment
|
||||||
|
|
||||||
|
- Repo: `smarm`. Two branches (the old single `arm-port` stack was split):
|
||||||
|
- `master` — the mainline, and HEAD. Carries roadmap #1–#5: cooperative
|
||||||
|
cancellation, supervisor strategies (one_for_one/all, rest_for_one) + the
|
||||||
|
orphaned-timer shutdown fix, links/trap_exit, selective receive, gen_server,
|
||||||
|
and demonitor/`MonitorId`. Tagged `v0.4.0`. x86-64 Linux only.
|
||||||
|
- `arm-port` — `master` plus a single commit: `feat(arch): aarch64 context
|
||||||
|
switch + cycle counter`. Extracts the x86-64 context-switch / stack-init /
|
||||||
|
cycle-counter out of `context.rs` into a `target_arch`-gated `src/arch/`
|
||||||
|
(x86_64 + aarch64 backends) and adds an AAPCS64 backend. ⚠️ UNTESTED: never
|
||||||
|
built or run on real ARM hardware. The x86-64 path is unchanged
|
||||||
|
(`arch/x86_64.rs` is the old `context.rs` body verbatim), so the x86 suite
|
||||||
|
passing says nothing about the aarch64 backend. Build + test on-device
|
||||||
|
before trusting it.
|
||||||
|
- Toolchain is installed but NOT on PATH in a fresh shell. First line of every
|
||||||
|
session: `. "$HOME/.cargo/env"` (rustc/cargo 1.96).
|
||||||
|
- Build `cargo build` · all tests `cargo test` · one suite `cargo test --test monitor`.
|
||||||
|
- Bench probe `cargo bench --bench general` (custom print-only harness; compiles
|
||||||
|
tokio in release the first time — slow — but `target/` persists across git
|
||||||
|
checkouts so it's paid once).
|
||||||
|
- Perf regression check: `git checkout <pre-change-sha>` →
|
||||||
|
`cargo bench --bench general | tee before.txt` → `git checkout arm-port` →
|
||||||
|
run again → diff the `smarm 1-thread` medians for `chained_spawn` and
|
||||||
|
`yield_many` (those exercise spawn/finalize/scheduler). Numbers are noisy on
|
||||||
|
this shared CPU; treat as "regression beyond noise?" not a precise delta.
|
||||||
|
|
||||||
|
## Roadmap (dependency order)
|
||||||
|
|
||||||
|
### 1. Cooperative cancellation — the keystone ✅ DONE (`a8ddb4a`)
|
||||||
|
Everything below (one_for_all/rest_for_one, links) needs a *safe* way to stop a
|
||||||
|
running peer. Forcible teardown of another green thread's stack is unsound here
|
||||||
|
(shared heap + Drop). So: cooperative stop the actor observes and unwinds itself.
|
||||||
|
|
||||||
|
Shipped as designed (sentinel unwind, not Result-threading). Notes for what
|
||||||
|
came next / future readers:
|
||||||
|
- Stop flag lives on `Actor` behind `Arc<AtomicBool>` (fresh per spawn), NOT a
|
||||||
|
`Slot` field — sidesteps the three-place reset, at the cost of one small
|
||||||
|
alloc per spawn. The scheduler hands the resume path a raw `*const AtomicBool`
|
||||||
|
(no per-resume refcount traffic); `yield_many` bench stayed at baseline,
|
||||||
|
`chained_spawn` ~+6% from that alloc (left as-is; move to a `Slot` field if it
|
||||||
|
ever matters).
|
||||||
|
- Observation points: amortised `maybe_preempt`/`check!()` path + the wakeup
|
||||||
|
side of `park_current`/`yield_now`. Sentinel = `StopSentinel` (zero-size),
|
||||||
|
recognised in the trampoline → `Outcome::Stopped`. `join()` on a stopped actor
|
||||||
|
returns `Ok(())` (no payload to propagate; reason is on the monitor channel).
|
||||||
|
- Documented gaps confirmed by tests: no-observation-point loop can't be stopped
|
||||||
|
(same as preemption); a user `catch_unwind` can swallow the sentinel but the
|
||||||
|
flag stays set so the next yield re-raises.
|
||||||
|
|
||||||
|
Original plan, for reference:
|
||||||
|
- Add a per-actor stop flag (Slot field + atomic, or check via shared state).
|
||||||
|
- `request_stop(pid)`: set the flag, unpark if parked.
|
||||||
|
- Realize the stop as a **controlled unwind**: when the scheduler resumes a
|
||||||
|
stop-requested actor, inject a sentinel panic (dedicated payload type) so the
|
||||||
|
existing `trampoline` `catch_unwind` tears the stack down and runs Drop. The
|
||||||
|
trampoline recognizes the sentinel and reports a new `Outcome::Stopped`
|
||||||
|
(distinct from a user `Panic`). This avoids changing every blocking-op
|
||||||
|
signature.
|
||||||
|
- Alternative considered: thread `Result<_, Cancelled>` through recv/sleep/
|
||||||
|
lock/io. Rejected — large API churn. Go with the sentinel unwind.
|
||||||
|
- Caveat to document: user code with its own `catch_unwind` can swallow the
|
||||||
|
sentinel (cf. Erlang `catch`); re-check the flag at the next yield and/or
|
||||||
|
re-raise. And a tight no-alloc loop without `check!()` can't be stopped —
|
||||||
|
same inherent limitation as preemption.
|
||||||
|
- Observation points: `maybe_preempt()`/`check!()` (cheap flag check) and the
|
||||||
|
blocking parks (recv/sleep/mutex/io) on the stop-driven unpark.
|
||||||
|
- Tests: looping actor on `check!()` gets stopped → `Outcome::Stopped`, Drop
|
||||||
|
guards ran; parked-on-recv actor gets stopped; no-check loop documents the gap.
|
||||||
|
|
||||||
|
### 2. one_for_all / rest_for_one + ordered shutdown ✅ DONE (`351dc9c`)
|
||||||
|
Shipped. What landed vs the plan:
|
||||||
|
- `Strategy::{OneForOne,OneForAll,RestForOne}` selected via `.strategy()`,
|
||||||
|
default `OneForOne`. The struct keeps the `OneForOne` name (compat; existing
|
||||||
|
tests untouched) — a rename to `Supervisor` is a deferred refactor.
|
||||||
|
- The *triggering* child's `Restart` policy decides whether anything restarts;
|
||||||
|
the strategy decides which live siblings are cycled (all / index-> after the
|
||||||
|
failed one). Survivors are `request_stop`'d in reverse start order, awaited on
|
||||||
|
the existing `supervisor_channel` funnel (no new channel, no `select`),
|
||||||
|
restarted in start order. One failure = one intensity tick regardless of group
|
||||||
|
size. Out-of-band signals during an await are stashed and replayed.
|
||||||
|
- `Signal::Stopped(pid)` + `DownReason::Stopped` added (kept distinct from Exit,
|
||||||
|
as planned). A `Stopped` signal counts as abnormal for the restart decision.
|
||||||
|
- Ordered shutdown: on cap-trip / mailbox-close-with-survivors, stop remaining
|
||||||
|
children in reverse start order and await them (no-op on the normal exit).
|
||||||
|
- ⚠️ Surfaced + fixed a latent keystone bug (`e80334b`): a cancelled
|
||||||
|
sleeping/timeout actor orphans its timer entry, and the scheduler's shutdown
|
||||||
|
check counted pending timers → `run()` hung until the dead actor's deadline
|
||||||
|
fired (a `sleep(30s)` sleeper hung shutdown 30s). Fix: timers no longer gate
|
||||||
|
shutdown (`live == 0` already implies nothing a timer could wake); heap is
|
||||||
|
cleared on exit. Independent of the supervisor work.
|
||||||
|
- Tests: all-restart (sibling cycled despite clean exit), suffix-restart
|
||||||
|
(prefix child left alone), reverse-order teardown.
|
||||||
|
|
||||||
|
Original plan, for reference:
|
||||||
|
- one_for_all: on any child failure, `request_stop` all siblings, await their
|
||||||
|
termination signals, restart all per spec.
|
||||||
|
- rest_for_one: stop+restart the failed child and those started after it.
|
||||||
|
- Supervisor shutdown: stop children in reverse start order.
|
||||||
|
- Decide signal surface: add `Signal::Stopped(pid)` + `DownReason::Stopped`
|
||||||
|
rather than folding into Exit (clearer for the supervisor's await logic).
|
||||||
|
- Tests: all-restart, suffix-restart, reverse-order shutdown.
|
||||||
|
|
||||||
|
### 3. Links + trap_exit ✅ DONE (`6581484`)
|
||||||
|
- `Slot.links: Vec<Pid>` (bidirectional); `link`/`unlink`; `trap_exit()` flag
|
||||||
|
lives on `Actor` (fresh per spawn → a restarted child starts un-trapped, and
|
||||||
|
no fourth slot-reset site).
|
||||||
|
- On finalize, reverse links are cleared under the lock (always — keeps the
|
||||||
|
cascade acyclic), then for each linked peer: abnormal death (`Panic`/
|
||||||
|
`Stopped`) → `request_stop(peer)` unless peer traps, in which case deliver an
|
||||||
|
`ExitSignal` *message* instead. Normal exit does NOT propagate. Linking an
|
||||||
|
already-dead pid delivers an immediate `NoProc` signal (message if trapping,
|
||||||
|
else `request_stop(self)` — not a silent no-op).
|
||||||
|
- Resolved: the trap inbox is a **dedicated** channel (`trap_exit() ->
|
||||||
|
Receiver<ExitSignal>`), distinct from the monitor `Down` channel; `ExitSignal`
|
||||||
|
reuses `DownReason` and carries no panic payload (joiner-only, as with
|
||||||
|
monitors). `spawn_link` deferred to #5.
|
||||||
|
- Tests (`tests/link.rs`): linked pair one panics → other stopped (+Drop ran);
|
||||||
|
trap_exit → other gets a message and survives; normal exit doesn't propagate;
|
||||||
|
dead-pid link stops a non-trapper / messages a trapper; `unlink` prevents
|
||||||
|
propagation.
|
||||||
|
|
||||||
|
### 4. Selective receive (independent track) ✅ DONE (`03f3875`)
|
||||||
|
Shipped. What landed vs the plan:
|
||||||
|
- `Receiver::recv_match(pred) -> Result<T, RecvError>` scans the queued
|
||||||
|
`VecDeque` front-to-back, removes+returns the first match, leaves the rest in
|
||||||
|
arrival order; parks and re-scans when nothing matches. `try_recv_match` (the
|
||||||
|
non-blocking variant, mirroring `try_recv`) rolled in same commit.
|
||||||
|
- Wakeup turned out cheaper than feared: `Sender::send` *already* took
|
||||||
|
`parked_receiver` on every push, so "wake on ANY send" needed no send-side
|
||||||
|
change. The one real edit was relaxing `Sender::drop` to unpark the parked
|
||||||
|
receiver on the last-sender drop regardless of queue emptiness — a selective
|
||||||
|
receiver can park on a non-empty no-match queue and must wake to observe
|
||||||
|
closure. No-op for plain `recv` (only ever parks on an empty queue); stress
|
||||||
|
suite stays green.
|
||||||
|
- `pred` is `Fn(&T) -> bool` (not `FnMut`) on purpose: it's re-run from scratch
|
||||||
|
on every scan, so a stateful predicate would re-count surprisingly. It runs
|
||||||
|
under the channel lock — keep it cheap/pure, don't re-enter the channel.
|
||||||
|
- Close semantics: `recv_match` returns `Err(RecvError)` only when closed AND no
|
||||||
|
queued message matches; a match is still returned on a closed channel.
|
||||||
|
Non-matches are left for a later `recv`.
|
||||||
|
- Tests (`tests/selective_recv.rs`): out-of-order match pulled first; non-matches
|
||||||
|
remain in order; park-on-non-empty then wake on a match; closed-with-only-
|
||||||
|
non-matches → Err; closed-but-match-present → match; `try_recv_match` states.
|
||||||
|
|
||||||
|
Original plan, for reference:
|
||||||
|
- Add `Receiver::recv_match(pred) -> T`: scan the queued `VecDeque`, remove+return
|
||||||
|
first match, leave the rest in order; park and re-scan on new arrivals.
|
||||||
|
- This changes channel wakeup: a parked selective receiver must wake on ANY send
|
||||||
|
(not just empty→nonempty) and re-scan. Touches `channel.rs` carefully — the
|
||||||
|
stress tests guard lost-wakeup invariants; keep them green.
|
||||||
|
- Tests: messages arrive out of interest-order; match pulled first; non-matches
|
||||||
|
remain for a later `recv`.
|
||||||
|
|
||||||
|
### 5. Grab-bag (each its own small commit)
|
||||||
|
- `spawn_link`: spawn-and-link atomically (deferred from #3); thin wrapper over
|
||||||
|
`spawn_under` + `link`, but do it under one lock so there's no window where
|
||||||
|
the child dies before the link is recorded.
|
||||||
|
- `demonitor`: needs a per-monitor id to remove a specific sender. Decide the
|
||||||
|
monitor API NOW before more code depends on it — likely return a
|
||||||
|
`Monitor { id, rx }` instead of a bare `Receiver<Down>`.
|
||||||
|
✅ DONE (this commit). What landed vs the plan:
|
||||||
|
- `monitor()` now returns `Monitor { id, target, rx }` (added `target` over
|
||||||
|
the sketched `{id, rx}` so `demonitor` jumps straight to the slot instead of
|
||||||
|
scanning every slot for the id). `MonitorId(u64)` is opaque, from a
|
||||||
|
monotonic `next_monitor_id` counter on `SharedState`, bumped under the
|
||||||
|
shared lock in `monitor()` — no atomics, deterministic, never reused.
|
||||||
|
- `Slot.monitors: Vec<(MonitorId, Sender<Down>)>`. The three slot-reset sites
|
||||||
|
were untouched — they `.clear()`/`Vec::new()`, which is element-type-
|
||||||
|
agnostic, so no new reset obligation. `finalize_actor` just destructures
|
||||||
|
`(_, m)` and sends as before.
|
||||||
|
- `demonitor(&Monitor) -> Option<MonitorId>`: `Some(id)` when a live
|
||||||
|
registration was found+removed, `None` when it had already fired (drained on
|
||||||
|
finalize), was `NoProc`, or the slot was reclaimed. Chose `Option<MonitorId>`
|
||||||
|
over a bare bool — names which registration went. Generation half of the pid
|
||||||
|
makes a stale demonitor a clean no-op: a recycled slot index fails
|
||||||
|
`slot_mut`'s generation check, so it can never strip a different actor's
|
||||||
|
monitor.
|
||||||
|
- ⚠️ Reentrancy: the removed `Sender` is `remove`d out of the Vec under the
|
||||||
|
lock but **dropped after the lock is released** — `Sender::drop` can unpark a
|
||||||
|
parked receiver → `with_shared`, and the shared mutex is non-reentrant. Same
|
||||||
|
discipline as `finalize_actor`.
|
||||||
|
- "Flush" (discard a `Down` the target already queued) falls out of dropping
|
||||||
|
the `Monitor`: `demonitor(&m); drop(m)`. That's the cleanup the still-to-come
|
||||||
|
gen_server **call timeout** wants — monitor the server, wait reply-or-Down-
|
||||||
|
or-deadline, then demonitor+drop so a timed-out call leaks no registration
|
||||||
|
and no stale `Down`.
|
||||||
|
- Perf: touches `Slot` + `finalize_actor`, but `chained_spawn`/`yield_many`
|
||||||
|
register no monitors, so the Vec stays empty (take-empty is identical cost,
|
||||||
|
finalize loop runs zero times). before/after `general` probe medians within
|
||||||
|
noise. Tests (`tests/monitor.rs`): demonitor-stops-delivery, one-of-many
|
||||||
|
(siblings untouched), after-fire-is-None.
|
||||||
|
- Named registry: `register(name,pid)`/`whereis`/`send_by_name`; a
|
||||||
|
`HashMap<String,Pid>` in `SharedState`.
|
||||||
|
- gen_server-style call/cast: request-reply correlation as a thin layer over
|
||||||
|
channels (`call` sends `{req, reply_tx}`, awaits `reply_rx`); no runtime change.
|
||||||
|
✅ DONE (`a4fcf6c`). What landed vs the plan:
|
||||||
|
- `GenServer` trait on the state value: assoc `Call`/`Reply`/`Cast` types,
|
||||||
|
required `handle_call`/`handle_cast`, optional `init`/`terminate` hooks.
|
||||||
|
`ServerRef<G>` is a clonable inbox sender + `pid()`; `start` / `start_under`.
|
||||||
|
- One inbox, not two: a single `Envelope { Call(req, reply_tx) | Cast }`
|
||||||
|
channel, dispatched by variant. Forced by no-`select`/no-unified-mailbox —
|
||||||
|
a server can't wait on a call channel and a cast channel at once.
|
||||||
|
- Server-down falls out of channel closure (no monitor needed): `send` fails
|
||||||
|
if the inbox is gone; the reply sender drops on the server's unwind so a
|
||||||
|
parked caller wakes to `Err`. Both → `Call/CastError::ServerDown`.
|
||||||
|
- `terminate` runs via a drop guard → fires on *every* exit path (clean inbox
|
||||||
|
close, handler panic, `request_stop`), not just the clean one. Caveat: it
|
||||||
|
may run mid-unwind, so keep it non-blocking (a panic inside it during an
|
||||||
|
unwind double-panics → abort).
|
||||||
|
- No `handle_info`, no call timeout — both deferred to land with timeouts
|
||||||
|
(`handle_info` needs the still-unmade cross-channel mailbox merge; a call
|
||||||
|
timeout needs a per-`recv` deadline / `Signal::Timeout`).
|
||||||
|
- Pure additive layer (no Slot/scheduler/spawn/finalize change) → no perf
|
||||||
|
check run. Tests (`tests/gen_server.rs`): cast→call roundtrip, init/terminate
|
||||||
|
ordering, both server-down paths.
|
||||||
|
- Docs: README now points at the experimental, untested aarch64 port on the
|
||||||
|
`arm-port` branch. The module table still calls `context` x86-64-only — true
|
||||||
|
for `master`, since the `src/arch/` split rides on `arm-port`. Fold the arch/
|
||||||
|
split and ARM64-supported wording into the README module table + build section
|
||||||
|
once `arm-port` is validated on hardware and merged.
|
||||||
|
|
||||||
|
## Gotchas / invariants (respect these)
|
||||||
|
|
||||||
|
- **Shared mutex is non-reentrant.** `Sender::send` can call `unpark` →
|
||||||
|
`with_shared`. NEVER send on a channel while holding the shared lock. Pattern:
|
||||||
|
`mem::take` the senders/data under the lock, send after releasing. See
|
||||||
|
`finalize_actor` (supervisor signal + monitor Downs both sent post-lock).
|
||||||
|
- **`finalize_actor` order:** take stack/waiters/monitors under lock + set
|
||||||
|
Done/outcome → recycle stack (post-lock) → deliver supervisor Signal + monitor
|
||||||
|
Downs (post-lock) → unpark joiners → reclaim slot iff `outstanding_handles==0`.
|
||||||
|
Death notifications always precede reclamation, so a pid carried in a
|
||||||
|
Signal/Down is still matchable even as its slot is about to be reused.
|
||||||
|
- **Slot lifecycle is reset in THREE places** — `Slot::vacant()`,
|
||||||
|
`reclaim_slot()` (runtime.rs), and the slot-init block in `spawn_under`
|
||||||
|
(scheduler.rs). Any new Slot field must be reset in all three (monitors was).
|
||||||
|
- **Pid = (index, generation);** stale handles caught by generation mismatch in
|
||||||
|
`slot()/slot_mut()`. The monitor `NoProc` path relies on this.
|
||||||
|
- **No `select`, no unified per-process mailbox.** Why the supervisor uses the
|
||||||
|
single `supervisor_channel` funnel rather than N monitor channels. trap_exit
|
||||||
|
resolved this by giving each trapping actor a dedicated `Receiver<ExitSignal>`
|
||||||
|
inbox (see #3); selective receive (#4) stayed *per-channel* (`recv_match`
|
||||||
|
scans one channel's queue) rather than introducing a cross-channel mailbox —
|
||||||
|
if selective receive ever needs to span the monitor/trap inboxes too, that
|
||||||
|
cross-channel merge is the still-unmade decision.
|
||||||
|
- **Cooperative-only**: preemption and (future) cancellation both depend on the
|
||||||
|
actor reaching `check!()`/yield/alloc/blocking points.
|
||||||
|
- `run()` is single-thread (`Config::exact(1)`); tests rely on deterministic
|
||||||
|
single-thread ordering (parent runs until it parks). Multi-thread via
|
||||||
|
`runtime::init(Config…)`.
|
||||||
|
|
||||||
|
## Workflow expectations (from the human)
|
||||||
|
|
||||||
|
- TDD: write the failing test first, then implement.
|
||||||
|
- Commit incrementally with conventional-commit messages; keep each commit a
|
||||||
|
reviewable unit (they diff in their IDE and are the filter to the codebase).
|
||||||
|
- Run the full suite before each commit; check perf when a change touches
|
||||||
|
Slot/scheduler/spawn/finalize hot paths.
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
//! Cooperative cancellation tests (roadmap item #1, the keystone).
|
||||||
|
//!
|
||||||
|
//! `request_stop(pid)` flags an actor for cancellation. The actor realizes the
|
||||||
|
//! stop as a *controlled unwind*: at the next observation point (a `check!()`
|
||||||
|
//! / allocation via `maybe_preempt`, or the wakeup side of any blocking park)
|
||||||
|
//! a dedicated sentinel panic is raised, the trampoline's `catch_unwind` tears
|
||||||
|
//! the stack down — running Drop guards — and reports `Outcome::Stopped`,
|
||||||
|
//! distinct from a user `Panic`. Monitors see `DownReason::Stopped`.
|
||||||
|
//!
|
||||||
|
//! These cases are deterministic under the single-thread runtime: the parent
|
||||||
|
//! runs until it parks, so the relative order of `request_stop`, the child
|
||||||
|
//! reaching its observation point, and the monitor `Down` is fixed.
|
||||||
|
|
||||||
|
use smarm::{channel, monitor, request_stop, run, spawn, yield_now, DownReason};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Sets its flag when dropped — used to prove the cancellation unwind runs
|
||||||
|
/// Drop guards rather than leaking the stack.
|
||||||
|
struct DropFlag(Arc<AtomicBool>);
|
||||||
|
impl Drop for DropFlag {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looping_actor_on_check_is_stopped() {
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let saw_stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let (d, s) = (dropped.clone(), saw_stopped.clone());
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let _g = DropFlag(d);
|
||||||
|
// Tight loop whose only observation point is check!().
|
||||||
|
loop {
|
||||||
|
smarm::check!();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
// Flag the stop before the child is ever resumed; it will observe the
|
||||||
|
// flag once its check!() loop reaches the amortised preempt check.
|
||||||
|
request_stop(pid);
|
||||||
|
let dn = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(dn.pid, pid);
|
||||||
|
if matches!(dn.reason, DownReason::Stopped) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
|
||||||
|
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parked_on_recv_actor_is_stopped() {
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let saw_stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let (d, s) = (dropped.clone(), saw_stopped.clone());
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let _g = DropFlag(d);
|
||||||
|
let (tx, rx) = channel::<u8>();
|
||||||
|
// Keep a sender alive so the channel stays open and recv() parks
|
||||||
|
// indefinitely rather than returning Err.
|
||||||
|
let _keep = tx;
|
||||||
|
let _ = rx.recv(); // parks here until the stop unwinds us out
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
// Let the child run and park in recv() before we request the stop.
|
||||||
|
yield_now();
|
||||||
|
request_stop(pid);
|
||||||
|
let dn = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(dn.pid, pid);
|
||||||
|
if matches!(dn.reason, DownReason::Stopped) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
|
||||||
|
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run on cancellation of a parked actor");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_check_no_alloc_loop_is_not_stopped() {
|
||||||
|
// Documents the inherent gap: an actor that never reaches an observation
|
||||||
|
// point (no check!(), no allocation, no blocking op) cannot be stopped —
|
||||||
|
// the same limitation as preemption. Here the child runs a bounded,
|
||||||
|
// allocation-free arithmetic loop, so the stop flagged before it runs is
|
||||||
|
// silently never honored and the actor exits normally.
|
||||||
|
let saw_exit = Arc::new(AtomicBool::new(false));
|
||||||
|
let s = saw_exit.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(|| {
|
||||||
|
let mut x: u64 = 0;
|
||||||
|
for i in 0..2_000_000u64 {
|
||||||
|
x = x.wrapping_add(i ^ (x >> 1));
|
||||||
|
}
|
||||||
|
std::hint::black_box(x);
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
request_stop(pid); // no observation point => ignored
|
||||||
|
let dn = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
if matches!(dn.reason, DownReason::Exit) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
saw_exit.load(Ordering::SeqCst),
|
||||||
|
"a loop with no observation points must exit normally, never Stopped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_on_stopped_actor_returns_ok() {
|
||||||
|
// A cooperative stop carries no panic payload to propagate, so join()
|
||||||
|
// reports Ok(()); the *fact* of the stop is observable via monitors
|
||||||
|
// (DownReason::Stopped), which is the channel that carries termination
|
||||||
|
// reason.
|
||||||
|
run(|| {
|
||||||
|
let h = spawn(|| loop {
|
||||||
|
smarm::check!();
|
||||||
|
});
|
||||||
|
let pid = h.pid();
|
||||||
|
request_stop(pid);
|
||||||
|
assert!(h.join().is_ok(), "join on a stopped actor returns Ok(())");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ use std::sync::OnceLock;
|
|||||||
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
|
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
|
||||||
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
|
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
extern "C-unwind" fn actor_reg_check() {
|
extern "C-unwind" fn actor_reg_check() {
|
||||||
unsafe {
|
unsafe {
|
||||||
let s0: u64 = 0xAAAA_BBBB_0000_0001;
|
let s0: u64 = 0xAAAA_BBBB_0000_0001;
|
||||||
@@ -83,6 +84,39 @@ extern "C-unwind" fn actor_reg_check() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aarch64 sibling: same intent, AAPCS64 callee-saved integer registers.
|
||||||
|
// LLVM reserves x19 (and uses x29/x30 as fp/lr), so those can't be named as
|
||||||
|
// asm operands. We use x23–x26 instead — callee-saved and bindable — moving
|
||||||
|
// known values into them, yielding, then reading them back, exactly mirroring
|
||||||
|
// the x86 test's move-into-r12..r15 pattern. The shim saves x23–x26 in its
|
||||||
|
// `stp x23,x24` / `stp x25,x26` pairs, so a clean round-trip proves they
|
||||||
|
// survive the switch.
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
extern "C-unwind" fn actor_reg_check() {
|
||||||
|
unsafe {
|
||||||
|
let s0: u64 = 0xAAAA_BBBB_0000_0001;
|
||||||
|
let s1: u64 = 0xCCCC_DDDD_0000_0002;
|
||||||
|
let s2: u64 = 0xEEEE_FFFF_0000_0003;
|
||||||
|
let s3: u64 = 0x1111_2222_0000_0004;
|
||||||
|
|
||||||
|
core::arch::asm!(
|
||||||
|
"mov x23, {s0}", "mov x24, {s1}", "mov x25, {s2}", "mov x26, {s3}",
|
||||||
|
s0 = in(reg) s0, s1 = in(reg) s1, s2 = in(reg) s2, s3 = in(reg) s3,
|
||||||
|
out("x23") _, out("x24") _, out("x25") _, out("x26") _,
|
||||||
|
);
|
||||||
|
REG_BEFORE.set([s0, s1, s2, s3]).ok();
|
||||||
|
switch_to_scheduler();
|
||||||
|
|
||||||
|
let a0: u64; let a1: u64; let a2: u64; let a3: u64;
|
||||||
|
core::arch::asm!(
|
||||||
|
"mov {a0}, x23", "mov {a1}, x24", "mov {a2}, x25", "mov {a3}, x26",
|
||||||
|
a0 = out(reg) a0, a1 = out(reg) a1, a2 = out(reg) a2, a3 = out(reg) a3,
|
||||||
|
);
|
||||||
|
REG_AFTER.set([a0, a1, a2, a3]).ok();
|
||||||
|
switch_to_scheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn callee_saved_registers_survive_yield() {
|
fn callee_saved_registers_survive_yield() {
|
||||||
let stack = Stack::new(64 * 1024).unwrap();
|
let stack = Stack::new(64 * 1024).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! gen_server tests: call round-trip, cast, lifecycle callbacks, and the two
|
||||||
|
//! server-down detection paths (reply-channel close vs. inbox-send failure).
|
||||||
|
|
||||||
|
use smarm::gen_server::{start, CallError, GenServer};
|
||||||
|
use smarm::run;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A trivial counter server: casts mutate, calls read (or blow up).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Counter {
|
||||||
|
n: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Req {
|
||||||
|
Get,
|
||||||
|
Boom,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Op {
|
||||||
|
Add(i64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenServer for Counter {
|
||||||
|
type Call = Req;
|
||||||
|
type Reply = i64;
|
||||||
|
type Cast = Op;
|
||||||
|
|
||||||
|
fn handle_call(&mut self, req: Req) -> i64 {
|
||||||
|
match req {
|
||||||
|
Req::Get => self.n,
|
||||||
|
Req::Boom => panic!("boom"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_cast(&mut self, op: Op) {
|
||||||
|
match op {
|
||||||
|
Op::Add(x) => self.n += x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Casts are applied in order and a later call observes the accumulated state.
|
||||||
|
#[test]
|
||||||
|
fn cast_then_call_roundtrip() {
|
||||||
|
let got = Arc::new(Mutex::new(0i64));
|
||||||
|
let got2 = got.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Counter { n: 0 });
|
||||||
|
server.cast(Op::Add(5)).unwrap();
|
||||||
|
server.cast(Op::Add(3)).unwrap();
|
||||||
|
let n = server.call(Req::Get).unwrap();
|
||||||
|
*got2.lock().unwrap() = n;
|
||||||
|
});
|
||||||
|
assert_eq!(*got.lock().unwrap(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle: init runs before the first message, terminate on graceful exit.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Lifecycle {
|
||||||
|
log: Arc<Mutex<Vec<&'static str>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenServer for Lifecycle {
|
||||||
|
type Call = ();
|
||||||
|
type Reply = ();
|
||||||
|
type Cast = ();
|
||||||
|
|
||||||
|
fn init(&mut self) {
|
||||||
|
self.log.lock().unwrap().push("init");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_call(&mut self, _req: ()) {
|
||||||
|
self.log.lock().unwrap().push("call");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_cast(&mut self, _req: ()) {}
|
||||||
|
|
||||||
|
fn terminate(&mut self) {
|
||||||
|
self.log.lock().unwrap().push("terminate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// init -> handle_call -> (drop last ref closes inbox) -> terminate.
|
||||||
|
#[test]
|
||||||
|
fn init_and_terminate_run() {
|
||||||
|
let log = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let log2 = log.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Lifecycle { log: log2 });
|
||||||
|
server.call(()).unwrap();
|
||||||
|
// Dropping the only ref closes the inbox; the server breaks out of its
|
||||||
|
// recv loop and runs terminate. run() will not return until it has.
|
||||||
|
drop(server);
|
||||||
|
});
|
||||||
|
assert_eq!(*log.lock().unwrap(), vec!["init", "call", "terminate"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server-down detection.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The server dies *while* a call is in flight (handler panics): the reply
|
||||||
|
// sender drops on unwind, closing the reply channel, so the parked caller's
|
||||||
|
// recv returns Err -> ServerDown.
|
||||||
|
#[test]
|
||||||
|
fn call_to_panicking_handler_is_server_down() {
|
||||||
|
let got = Arc::new(Mutex::new(None));
|
||||||
|
let got2 = got.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Counter { n: 0 });
|
||||||
|
let r = server.call(Req::Boom);
|
||||||
|
*got2.lock().unwrap() = Some(r);
|
||||||
|
});
|
||||||
|
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A call issued *after* the server is already gone: the inbox is closed, so the
|
||||||
|
// send itself fails -> ServerDown (the other detection path).
|
||||||
|
#[test]
|
||||||
|
fn call_after_server_gone_is_server_down() {
|
||||||
|
let got = Arc::new(Mutex::new(None));
|
||||||
|
let got2 = got.clone();
|
||||||
|
run(move || {
|
||||||
|
let server = start(Counter { n: 0 });
|
||||||
|
let server2 = server.clone();
|
||||||
|
// This kills the server (and is itself ServerDown).
|
||||||
|
assert_eq!(server.call(Req::Boom), Err(CallError::ServerDown));
|
||||||
|
// Inbox now closed; a fresh call can't even be enqueued.
|
||||||
|
let r = server2.call(Req::Get);
|
||||||
|
*got2.lock().unwrap() = Some(r);
|
||||||
|
});
|
||||||
|
assert_eq!(*got.lock().unwrap(), Some(Err(CallError::ServerDown)));
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
//! Link + trap_exit tests (roadmap item #3).
|
||||||
|
//!
|
||||||
|
//! A *link* is bidirectional and persistent (contrast a monitor, which is
|
||||||
|
//! unidirectional and one-shot). When a linked actor dies *abnormally* — a
|
||||||
|
//! panic or a cooperative `request_stop` — the death propagates to the peer:
|
||||||
|
//!
|
||||||
|
//! - a peer that has NOT trapped exits is cooperatively `request_stop`'d, so
|
||||||
|
//! a crash fate-shares across the link set (Erlang's "let it crash");
|
||||||
|
//! - a peer that HAS called `trap_exit()` instead receives an [`ExitSignal`]
|
||||||
|
//! *message* on its trap inbox and survives.
|
||||||
|
//!
|
||||||
|
//! A *normal* exit never propagates — not even to a trapping peer. Linking an
|
||||||
|
//! already-dead pid delivers an immediate exit signal (`NoProc`): a message to
|
||||||
|
//! a trapping caller, a `request_stop` to a non-trapping one.
|
||||||
|
//!
|
||||||
|
//! These cases are deterministic under the single-thread runtime: an actor
|
||||||
|
//! runs until it parks/yields, so the relative order of link setup, the
|
||||||
|
//! triggering death, and the propagated signal is fixed.
|
||||||
|
|
||||||
|
use smarm::{
|
||||||
|
channel, link, monitor, run, self_pid, spawn, trap_exit, unlink, yield_now, DownReason,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Sets its flag when dropped — proves a propagated stop unwound the peer's
|
||||||
|
/// stack (running Drop) rather than leaking it.
|
||||||
|
struct DropFlag(Arc<AtomicBool>);
|
||||||
|
impl Drop for DropFlag {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abnormal death of one linked actor cooperatively stops its non-trapping
|
||||||
|
/// peer, and the peer's Drop guards run during the propagated unwind.
|
||||||
|
#[test]
|
||||||
|
fn linked_pair_one_panics_other_is_stopped() {
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let saw_stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let (d, s) = (dropped.clone(), saw_stopped.clone());
|
||||||
|
run(move || {
|
||||||
|
// B parks on recv (kept open by a retained sender) so it is alive and
|
||||||
|
// suspended when the propagated stop arrives.
|
||||||
|
let hb = spawn(move || {
|
||||||
|
let _g = DropFlag(d);
|
||||||
|
let (tx, rx) = channel::<u8>();
|
||||||
|
let _keep = tx;
|
||||||
|
let _ = rx.recv(); // parks until the link-propagated stop unwinds us
|
||||||
|
});
|
||||||
|
let b = hb.pid();
|
||||||
|
let down_b = monitor(b);
|
||||||
|
|
||||||
|
// A links B, then panics. The panic propagates along the link to B.
|
||||||
|
let ha = spawn(move || {
|
||||||
|
link(b);
|
||||||
|
panic!("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(dn.pid, b, "Down reported the wrong pid");
|
||||||
|
if matches!(dn.reason, DownReason::Stopped) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
drop(ha);
|
||||||
|
let _ = hb.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
saw_stopped.load(Ordering::SeqCst),
|
||||||
|
"peer should be Stopped by the propagated abnormal exit"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dropped.load(Ordering::SeqCst),
|
||||||
|
"peer's Drop guard must run during the propagated cancellation unwind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trapping peer receives an abnormal death as an `ExitSignal` message and
|
||||||
|
/// keeps running instead of being torn down.
|
||||||
|
#[test]
|
||||||
|
fn trap_exit_turns_abnormal_death_into_a_message() {
|
||||||
|
let ok = Arc::new(AtomicBool::new(false));
|
||||||
|
let o = ok.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let inbox = trap_exit();
|
||||||
|
// Spawn the peer from here so we control ordering: it is enqueued
|
||||||
|
// behind us and runs once we park on `inbox.recv()`.
|
||||||
|
let ha = spawn(|| panic!("peer down"));
|
||||||
|
let a = ha.pid();
|
||||||
|
link(a);
|
||||||
|
// Parks here; A then runs, panics, and the trap message wakes us.
|
||||||
|
let sig = inbox.recv().expect("trap inbox closed without a signal");
|
||||||
|
if sig.from == a && matches!(sig.reason, DownReason::Panic) {
|
||||||
|
o.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
drop(ha);
|
||||||
|
});
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
ok.load(Ordering::SeqCst),
|
||||||
|
"trapping peer should receive ExitSignal{{from, Panic}} and survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A normal exit never propagates — a trapping linked peer sees no message and
|
||||||
|
/// stays alive.
|
||||||
|
#[test]
|
||||||
|
fn normal_exit_does_not_propagate() {
|
||||||
|
let empty = Arc::new(AtomicBool::new(false));
|
||||||
|
let e = empty.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let inbox = trap_exit();
|
||||||
|
let ha = spawn(|| { /* exits normally, immediately */ });
|
||||||
|
let a = ha.pid();
|
||||||
|
link(a);
|
||||||
|
yield_now(); // let A run to completion and finalize
|
||||||
|
// A exited normally: nothing should have landed on the inbox.
|
||||||
|
if let Ok(None) = inbox.try_recv() {
|
||||||
|
e.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
drop(ha);
|
||||||
|
});
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
empty.load(Ordering::SeqCst),
|
||||||
|
"normal exit must not deliver any ExitSignal to a trapping peer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linking an already-dead pid from a non-trapping actor stops the caller
|
||||||
|
/// (no silent no-op).
|
||||||
|
#[test]
|
||||||
|
fn link_to_dead_pid_stops_a_nontrapping_caller() {
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let saw_stopped = Arc::new(AtomicBool::new(false));
|
||||||
|
let (d, s) = (dropped.clone(), saw_stopped.clone());
|
||||||
|
run(move || {
|
||||||
|
let ha = spawn(|| {});
|
||||||
|
let a = ha.pid();
|
||||||
|
ha.join().unwrap(); // A finishes and is reclaimed → `a` is now stale
|
||||||
|
|
||||||
|
let hb = spawn(move || {
|
||||||
|
let _g = DropFlag(d);
|
||||||
|
link(a); // dead target, not trapping → request_stop(self)
|
||||||
|
loop {
|
||||||
|
smarm::check!(); // observation point to realize the stop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let b = hb.pid();
|
||||||
|
let down_b = monitor(b);
|
||||||
|
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
if matches!(dn.reason, DownReason::Stopped) {
|
||||||
|
s.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = hb.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
saw_stopped.load(Ordering::SeqCst),
|
||||||
|
"linking a dead pid (non-trapping) should stop the caller"
|
||||||
|
);
|
||||||
|
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linking an already-dead pid from a trapping actor delivers an immediate
|
||||||
|
/// `NoProc` exit message instead of killing it.
|
||||||
|
#[test]
|
||||||
|
fn link_to_dead_pid_messages_a_trapping_caller() {
|
||||||
|
let ok = Arc::new(AtomicBool::new(false));
|
||||||
|
let o = ok.clone();
|
||||||
|
run(move || {
|
||||||
|
let ha = spawn(|| {});
|
||||||
|
let a = ha.pid();
|
||||||
|
ha.join().unwrap(); // `a` is now stale
|
||||||
|
|
||||||
|
let hb = spawn(move || {
|
||||||
|
let inbox = trap_exit();
|
||||||
|
link(a); // dead target, trapping → immediate NoProc message
|
||||||
|
let sig = inbox.recv().expect("trap inbox closed without a signal");
|
||||||
|
if sig.from == a && matches!(sig.reason, DownReason::NoProc) {
|
||||||
|
o.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = hb.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
ok.load(Ordering::SeqCst),
|
||||||
|
"linking a dead pid (trapping) should deliver a NoProc ExitSignal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `unlink` removes the relationship in both directions: an abnormal death no
|
||||||
|
/// longer reaches the formerly-linked peer.
|
||||||
|
#[test]
|
||||||
|
fn unlink_prevents_propagation() {
|
||||||
|
let survived = Arc::new(AtomicBool::new(false));
|
||||||
|
let sv = survived.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(move || {
|
||||||
|
let b = self_pid();
|
||||||
|
let inbox = trap_exit();
|
||||||
|
let ha = spawn(move || {
|
||||||
|
link(b);
|
||||||
|
unlink(b);
|
||||||
|
panic!("boom"); // abnormal, but the link is gone
|
||||||
|
});
|
||||||
|
yield_now(); // let A link, unlink, and panic
|
||||||
|
// Unlinked before death → no ExitSignal should have arrived.
|
||||||
|
if let Ok(None) = inbox.try_recv() {
|
||||||
|
sv.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
drop(ha);
|
||||||
|
});
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
survived.load(Ordering::SeqCst),
|
||||||
|
"unlinked peer must not receive a propagated exit"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! Monitor tests: `monitor(pid)` delivers exactly one `Down` describing how
|
||||||
|
//! the target terminated, and `demonitor(&m)` tears a registration back down.
|
||||||
|
//!
|
||||||
|
//! A monitor is unidirectional and one-shot: it never propagates failure to
|
||||||
|
//! the watcher (that's a link), it just drops a `Down` into the returned
|
||||||
|
//! channel. These tests pin the three reasons — `Exit`, `Panic`, `NoProc` —
|
||||||
|
//! the fan-out case where several monitors watch the same target, and that
|
||||||
|
//! `demonitor` removes exactly the one registration it names.
|
||||||
|
//!
|
||||||
|
//! Scheduling note: under the default single-thread runtime the parent runs
|
||||||
|
//! until it parks, so every `monitor()` call below registers while the target
|
||||||
|
//! is still `Runnable` (it hasn't been resumed yet). Registration and
|
||||||
|
//! `finalize_actor` both serialize on the shared mutex, so a target that is
|
||||||
|
//! alive at registration time always produces a real `Down`, never a missed
|
||||||
|
//! one.
|
||||||
|
|
||||||
|
use smarm::{demonitor, monitor, run, spawn, DownReason};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_sees_normal_exit() {
|
||||||
|
let ok = Arc::new(AtomicBool::new(false));
|
||||||
|
let o = ok.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
let d = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(d.pid, pid, "Down reported the wrong pid");
|
||||||
|
if matches!(d.reason, DownReason::Exit) {
|
||||||
|
o.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(ok.load(Ordering::SeqCst), "expected DownReason::Exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_sees_panic() {
|
||||||
|
let ok = Arc::new(AtomicBool::new(false));
|
||||||
|
let o = ok.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(|| panic!("boom"));
|
||||||
|
let pid = h.pid();
|
||||||
|
let down = monitor(pid);
|
||||||
|
let d = down.rx.recv().expect("monitor channel closed before Down");
|
||||||
|
assert_eq!(d.pid, pid);
|
||||||
|
if matches!(d.reason, DownReason::Panic) {
|
||||||
|
o.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
// Drain the panic outcome so the joiner path is exercised too; the
|
||||||
|
// payload goes here, not to the monitor.
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
assert!(ok.load(Ordering::SeqCst), "expected DownReason::Panic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_already_dead_target_is_noproc() {
|
||||||
|
let ok = Arc::new(AtomicBool::new(false));
|
||||||
|
let o = ok.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
// Consume the only handle on a finished child: the slot is reclaimed
|
||||||
|
// and its generation bumped, so `pid` is now stale.
|
||||||
|
h.join().unwrap();
|
||||||
|
let down = monitor(pid);
|
||||||
|
let d = down.rx.recv().expect("NoProc Down should be delivered immediately");
|
||||||
|
assert_eq!(d.pid, pid);
|
||||||
|
if matches!(d.reason, DownReason::NoProc) {
|
||||||
|
o.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(ok.load(Ordering::SeqCst), "expected DownReason::NoProc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_monitors_all_notified() {
|
||||||
|
let count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c = count.clone();
|
||||||
|
run(move || {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let monitors = [monitor(pid), monitor(pid), monitor(pid)];
|
||||||
|
let _ = h.join();
|
||||||
|
for m in monitors {
|
||||||
|
if matches!(m.rx.recv().unwrap().reason, DownReason::Exit) {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(count.load(Ordering::SeqCst), 3, "every monitor should see the Down");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demonitor_stops_delivery() {
|
||||||
|
// Demonitoring a live registration removes the slot's only sender for that
|
||||||
|
// channel; the channel closes, so a later recv() observes Err rather than
|
||||||
|
// a Down. demonitor reports the id it tore down.
|
||||||
|
run(|| {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let m = monitor(pid);
|
||||||
|
assert_eq!(demonitor(&m), Some(m.id), "live registration should be removed");
|
||||||
|
assert!(m.rx.recv().is_err(), "no Down should arrive after demonitor");
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demonitor_one_of_many() {
|
||||||
|
// Three monitors on one target; demonitor the middle. The other two still
|
||||||
|
// fire; the demonitored channel is closed with no Down. Distinct ids mean
|
||||||
|
// tearing one down never disturbs its siblings.
|
||||||
|
run(|| {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let ms = [monitor(pid), monitor(pid), monitor(pid)];
|
||||||
|
assert_eq!(demonitor(&ms[1]), Some(ms[1].id));
|
||||||
|
let _ = h.join();
|
||||||
|
assert!(matches!(ms[0].rx.recv().unwrap().reason, DownReason::Exit));
|
||||||
|
assert!(matches!(ms[2].rx.recv().unwrap().reason, DownReason::Exit));
|
||||||
|
assert!(ms[1].rx.recv().is_err(), "demonitored channel should be closed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demonitor_after_fire_is_none() {
|
||||||
|
// Once the Down has fired, the registration is already drained from the
|
||||||
|
// slot, so demonitor finds nothing to remove and reports None.
|
||||||
|
run(|| {
|
||||||
|
let h = spawn(|| {});
|
||||||
|
let pid = h.pid();
|
||||||
|
let m = monitor(pid);
|
||||||
|
let d = m.rx.recv().expect("Down before close");
|
||||||
|
assert!(matches!(d.reason, DownReason::Exit));
|
||||||
|
assert_eq!(demonitor(&m), None, "already-fired monitor has nothing to remove");
|
||||||
|
let _ = h.join();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -64,3 +64,72 @@ fn check_is_a_noop_when_timeslice_not_expired() {
|
|||||||
});
|
});
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 1_000);
|
assert_eq!(count.load(Ordering::Relaxed), 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// XMM-not-saved assumption (context.rs): the context switch saves no SSE
|
||||||
|
// state. The justification is that every yield crosses a Rust `call` boundary
|
||||||
|
// (SysV AMD64: XMM0-15 caller-saved), so the compiler spills live XMM before
|
||||||
|
// the switch and reloads after. The non-obvious path is PREEMPTION: `check!()`
|
||||||
|
// inlines `maybe_preempt`, so the yield can fire mid-floating-point-loop, not
|
||||||
|
// at a syntactic call site. This is adversarial because the yield is buried
|
||||||
|
// inside the actor's own hot loop — but `switch_to_scheduler` is still an
|
||||||
|
// `extern "C"` call, so the spill still happens. Verified by disasm: the FP
|
||||||
|
// accumulators are spilled to (%rsp) immediately before the preempt path and
|
||||||
|
// reloaded after. This test guards against a future change to the yield path
|
||||||
|
// that bypasses that call boundary (which WOULD require saving XMM).
|
||||||
|
#[test]
|
||||||
|
fn live_xmm_survives_preemptive_switch() {
|
||||||
|
// A floating-point loop with several live f64 accumulators the compiler
|
||||||
|
// wants resident in XMM across iterations. We force a timeslice expiry on
|
||||||
|
// a chosen iteration so a preemptive switch_to_scheduler fires while XMM
|
||||||
|
// is live, then compare against the same workload run with no preemption.
|
||||||
|
#[inline(never)]
|
||||||
|
fn fp_workload(preempt_at: u64) -> f64 {
|
||||||
|
let mut a = 1.0000001_f64;
|
||||||
|
let mut b = 0.9999999_f64;
|
||||||
|
let mut acc = 0.0_f64;
|
||||||
|
for i in 0..50_000u64 {
|
||||||
|
a = a * 1.0000003 + 0.0000001;
|
||||||
|
b = b * 0.9999997 + 0.0000002;
|
||||||
|
acc += a - b + (i as f64) * 1e-9;
|
||||||
|
if i == preempt_at {
|
||||||
|
smarm::preempt::expire_timeslice_for_test();
|
||||||
|
}
|
||||||
|
smarm::check!();
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference: a preempt_at past the loop end => check!() never yields.
|
||||||
|
let reference = fp_workload(u64::MAX);
|
||||||
|
|
||||||
|
let got = Arc::new(AtomicU64::new(0));
|
||||||
|
let g = got.clone();
|
||||||
|
run(move || {
|
||||||
|
// A second actor so the scheduler has somewhere to switch on yield.
|
||||||
|
let _other = spawn(|| {
|
||||||
|
for _ in 0..8 {
|
||||||
|
smarm::preempt::expire_timeslice_for_test();
|
||||||
|
smarm::check!();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let h = spawn(move || {
|
||||||
|
// Yield from several distinct points so the switch lands on
|
||||||
|
// different live-XMM states.
|
||||||
|
let mut total = 0.0_f64;
|
||||||
|
for k in 0..8 {
|
||||||
|
total += fp_workload(k * 6_000 + 100);
|
||||||
|
}
|
||||||
|
g.store(total.to_bits(), Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let reference_total: f64 = (0..8).map(|_| reference).sum();
|
||||||
|
let got = f64::from_bits(got.load(Ordering::SeqCst));
|
||||||
|
assert_eq!(
|
||||||
|
got.to_bits(),
|
||||||
|
reference_total.to_bits(),
|
||||||
|
"XMM state diverged across preemptive switch: got {got:.12}, want {reference_total:.12}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Selective-receive tests: `Receiver::recv_match` and `try_recv_match`.
|
||||||
|
//!
|
||||||
|
//! `recv_match(pred)` scans the queued messages, removes and returns the first
|
||||||
|
//! one for which `pred` holds, and leaves the rest in arrival order. With no
|
||||||
|
//! match it parks and re-scans on every send (a selective receiver may park on
|
||||||
|
//! a *non-empty* queue), returning `Err` only once the channel is closed and no
|
||||||
|
//! queued message matches. `try_recv_match` is the non-blocking variant.
|
||||||
|
|
||||||
|
use smarm::{channel, run, spawn};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recv_match — same-actor scan semantics (no parking)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// First matching message is pulled out of arrival order; non-matches stay
|
||||||
|
// queued in their original order for later plain recv().
|
||||||
|
#[test]
|
||||||
|
fn match_pulled_first_non_matches_remain_in_order() {
|
||||||
|
let got = Arc::new(Mutex::new(Vec::<i64>::new()));
|
||||||
|
let got2 = got.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
for v in [1, 2, 3] {
|
||||||
|
tx.send(v).unwrap();
|
||||||
|
}
|
||||||
|
// 2 is the only even; it's pulled despite arriving second.
|
||||||
|
let m = rx.recv_match(|&v| v % 2 == 0).unwrap();
|
||||||
|
// The non-matches remain, in order: 1 then 3.
|
||||||
|
let a = rx.recv().unwrap();
|
||||||
|
let b = rx.recv().unwrap();
|
||||||
|
let mut g = got2.lock().unwrap();
|
||||||
|
g.push(m);
|
||||||
|
g.push(a);
|
||||||
|
g.push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(*got.lock().unwrap(), vec![2, 1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read-only captured value (request-id style) is a plain `Fn` predicate.
|
||||||
|
#[test]
|
||||||
|
fn matches_on_captured_value() {
|
||||||
|
let got = Arc::new(Mutex::new(0i64));
|
||||||
|
let got2 = got.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
for v in [10, 20, 30] {
|
||||||
|
tx.send(v).unwrap();
|
||||||
|
}
|
||||||
|
let target = 20;
|
||||||
|
let m = rx.recv_match(move |&v| v == target).unwrap();
|
||||||
|
*got2.lock().unwrap() = m;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(*got.lock().unwrap(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recv_match — parking on a non-empty, no-match queue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The receiver parks while the queue holds only non-matching messages, must
|
||||||
|
// wake on a send that does NOT empty->fill the queue, re-scan, re-park on a
|
||||||
|
// still-non-matching send, and finally return when a match arrives.
|
||||||
|
#[test]
|
||||||
|
fn parks_on_non_matching_queue_then_wakes_on_match() {
|
||||||
|
let got = Arc::new(Mutex::new(0i64));
|
||||||
|
let got2 = got.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
tx.send(1).unwrap(); // odd: queued, will not match
|
||||||
|
let h = spawn(move || {
|
||||||
|
// Scans [1], no even -> parks on a non-empty queue.
|
||||||
|
let v = rx.recv_match(|&v| v % 2 == 0).unwrap();
|
||||||
|
*got2.lock().unwrap() = v;
|
||||||
|
});
|
||||||
|
smarm::yield_now(); // let the child park
|
||||||
|
tx.send(3).unwrap(); // odd: child wakes, re-scans [1,3], re-parks
|
||||||
|
tx.send(4).unwrap(); // even: child wakes, scans [1,3,4], returns 4
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(*got.lock().unwrap(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recv_match — close semantics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Channel closes (all senders dropped) while only non-matching messages are
|
||||||
|
// queued: recv_match must return Err rather than block forever.
|
||||||
|
#[test]
|
||||||
|
fn closed_with_only_non_matches_returns_err() {
|
||||||
|
let saw_err = Arc::new(Mutex::new(false));
|
||||||
|
let saw_err2 = saw_err.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
let h = spawn(move || {
|
||||||
|
// Wants an even; only odds will ever exist.
|
||||||
|
let r = rx.recv_match(|&v| v % 2 == 0);
|
||||||
|
*saw_err2.lock().unwrap() = r.is_err();
|
||||||
|
});
|
||||||
|
smarm::yield_now(); // child parks on empty queue
|
||||||
|
tx.send(1).unwrap(); // odd: wakes, re-scans, re-parks (no match)
|
||||||
|
drop(tx); // last sender gone, still no match -> Err
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(*saw_err.lock().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A match present on a closed channel is still returned (closure doesn't
|
||||||
|
// pre-empt an available match).
|
||||||
|
#[test]
|
||||||
|
fn closed_but_match_present_returns_match() {
|
||||||
|
let got = Arc::new(Mutex::new(0i64));
|
||||||
|
let got2 = got.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
tx.send(1).unwrap();
|
||||||
|
tx.send(2).unwrap();
|
||||||
|
drop(tx); // closed, but a match (2) is queued
|
||||||
|
let m = rx.recv_match(|&v| v % 2 == 0).unwrap();
|
||||||
|
*got2.lock().unwrap() = m;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(*got.lock().unwrap(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// try_recv_match — non-blocking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_recv_match_states() {
|
||||||
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let out2 = out.clone();
|
||||||
|
|
||||||
|
run(move || {
|
||||||
|
let (tx, rx) = channel::<i64>();
|
||||||
|
let log = |s: &str| out2.lock().unwrap().push(s.to_string());
|
||||||
|
|
||||||
|
// Empty + open -> Ok(None).
|
||||||
|
log(match rx.try_recv_match(|&v| v % 2 == 0) {
|
||||||
|
Ok(None) => "open_empty_none",
|
||||||
|
_ => "WRONG_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-matching present + open -> Ok(None), message left in place.
|
||||||
|
tx.send(1).unwrap();
|
||||||
|
log(match rx.try_recv_match(|&v| v % 2 == 0) {
|
||||||
|
Ok(None) => "open_nomatch_none",
|
||||||
|
_ => "WRONG_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match present -> Ok(Some(v)).
|
||||||
|
tx.send(2).unwrap();
|
||||||
|
log(match rx.try_recv_match(|&v| v % 2 == 0) {
|
||||||
|
Ok(Some(2)) => "got_match",
|
||||||
|
_ => "WRONG_3",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the leftover non-match with a plain recv to confirm it stayed.
|
||||||
|
log(match rx.recv() {
|
||||||
|
Ok(1) => "leftover_one",
|
||||||
|
_ => "WRONG_4",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closed + no match -> Err.
|
||||||
|
drop(tx);
|
||||||
|
log(match rx.try_recv_match(|&v| v % 2 == 0) {
|
||||||
|
Err(_) => "closed_err",
|
||||||
|
_ => "WRONG_5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*out.lock().unwrap(),
|
||||||
|
vec![
|
||||||
|
"open_empty_none",
|
||||||
|
"open_nomatch_none",
|
||||||
|
"got_match",
|
||||||
|
"leftover_one",
|
||||||
|
"closed_err",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//! One-for-one supervisor tests.
|
||||||
|
//!
|
||||||
|
//! A `OneForOne` runs as an ordinary actor: it spawns its children under
|
||||||
|
//! itself, collects their termination `Signal`s on a single mailbox, and
|
||||||
|
//! restarts them per policy until either every child is in a terminal,
|
||||||
|
//! non-restartable state (then `run()` returns) or the restart intensity cap
|
||||||
|
//! is tripped (then `run()` gives up and returns).
|
||||||
|
//!
|
||||||
|
//! Policies:
|
||||||
|
//! - `Permanent` — restart on any termination (normal or panic).
|
||||||
|
//! - `Transient` — restart only on panic; a normal exit is "done".
|
||||||
|
//! - `Temporary` — never restart.
|
||||||
|
//!
|
||||||
|
//! All cases below are deterministic under the single-thread runtime: the
|
||||||
|
//! supervisor parks in `recv()` while a child runs to completion, so signals
|
||||||
|
//! arrive one at a time in a fixed order.
|
||||||
|
|
||||||
|
use smarm::supervisor::{ChildSpec, OneForOne, Restart, Strategy};
|
||||||
|
use smarm::{run, sleep, spawn};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn counting_child(
|
||||||
|
counter: &Arc<AtomicUsize>,
|
||||||
|
behavior: impl Fn(usize) + Send + Sync + 'static,
|
||||||
|
) -> impl Fn() + Send + Sync + 'static {
|
||||||
|
let c = counter.clone();
|
||||||
|
move || {
|
||||||
|
let n = c.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
behavior(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_child_is_restarted_on_panic_then_settles() {
|
||||||
|
let runs = Arc::new(AtomicUsize::new(0));
|
||||||
|
let r = runs.clone();
|
||||||
|
run(move || {
|
||||||
|
let child = counting_child(&r, |n| {
|
||||||
|
if n < 3 {
|
||||||
|
panic!("crash {n}");
|
||||||
|
}
|
||||||
|
// 3rd run returns normally -> Transient does not restart.
|
||||||
|
});
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.intensity(10, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(runs.load(Ordering::SeqCst), 3, "two restarts then a clean exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_child_normal_exit_is_not_restarted() {
|
||||||
|
let runs = Arc::new(AtomicUsize::new(0));
|
||||||
|
let r = runs.clone();
|
||||||
|
run(move || {
|
||||||
|
let child = counting_child(&r, |_| { /* return normally */ });
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(runs.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temporary_child_is_not_restarted_on_panic() {
|
||||||
|
let runs = Arc::new(AtomicUsize::new(0));
|
||||||
|
let r = runs.clone();
|
||||||
|
run(move || {
|
||||||
|
let child = counting_child(&r, |_| panic!("once"));
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.child(ChildSpec::new(Restart::Temporary, child))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(runs.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intensity_cap_stops_a_permanent_crash_loop() {
|
||||||
|
let runs = Arc::new(AtomicUsize::new(0));
|
||||||
|
let r = runs.clone();
|
||||||
|
run(move || {
|
||||||
|
let child = counting_child(&r, |n| panic!("always {n}"));
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.intensity(3, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, child))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
// 1 initial run + 3 restarts, then the 4th failure trips the cap.
|
||||||
|
assert_eq!(runs.load(Ordering::SeqCst), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_for_one_restarts_only_the_failed_child() {
|
||||||
|
let a = Arc::new(AtomicUsize::new(0));
|
||||||
|
let b = Arc::new(AtomicUsize::new(0));
|
||||||
|
let (ra, rb) = (a.clone(), b.clone());
|
||||||
|
run(move || {
|
||||||
|
let child_a = counting_child(&ra, |n| {
|
||||||
|
if n < 2 {
|
||||||
|
panic!("a crash {n}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let child_b = counting_child(&rb, |_| { /* runs once, normal */ });
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.intensity(10, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_a))
|
||||||
|
.child(ChildSpec::new(Restart::Temporary, child_b))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(a.load(Ordering::SeqCst), 2, "A restarts once then settles");
|
||||||
|
assert_eq!(b.load(Ordering::SeqCst), 1, "B is untouched by A's restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// one_for_all / rest_for_one (roadmap #2)
|
||||||
|
//
|
||||||
|
// These exercise the group-restart strategies. The discriminator across all
|
||||||
|
// three strategies is how a *sibling* of the failed child is treated:
|
||||||
|
// - one_for_one : sibling untouched.
|
||||||
|
// - rest_for_one: only siblings started *after* the failed child are cycled.
|
||||||
|
// - one_for_all : every sibling is cycled, regardless of its own policy.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_for_all_restarts_a_normally_exited_sibling() {
|
||||||
|
// A panics once then settles; B always exits normally. Under one_for_all,
|
||||||
|
// A's failure cycles the whole group, so B is *restarted even though it had
|
||||||
|
// exited normally* and its own Transient policy would never self-restart.
|
||||||
|
// That second B run is what distinguishes one_for_all from one_for_one
|
||||||
|
// (where B would run exactly once).
|
||||||
|
let a = Arc::new(AtomicUsize::new(0));
|
||||||
|
let b = Arc::new(AtomicUsize::new(0));
|
||||||
|
let (ra, rb) = (a.clone(), b.clone());
|
||||||
|
run(move || {
|
||||||
|
let child_a = counting_child(&ra, |n| {
|
||||||
|
if n < 2 {
|
||||||
|
panic!("a crash {n}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let child_b = counting_child(&rb, |_| { /* always normal */ });
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.strategy(Strategy::OneForAll)
|
||||||
|
.intensity(10, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_a))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_b))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(a.load(Ordering::SeqCst), 2, "A: crash then clean run");
|
||||||
|
assert_eq!(b.load(Ordering::SeqCst), 2, "B cycled with the group despite a clean exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rest_for_one_cycles_only_the_suffix() {
|
||||||
|
// Three children A(0) B(1) C(2). B panics once. Under rest_for_one only the
|
||||||
|
// children started after B (i.e. C) are cycled with it; A — started before
|
||||||
|
// B — is left running untouched.
|
||||||
|
//
|
||||||
|
// A must still be *alive* when B's failure is processed, otherwise "not
|
||||||
|
// restarted" is indistinguishable from "already gone". So A sleeps on its
|
||||||
|
// first run (parking across B's failure) and returns immediately on any
|
||||||
|
// later run. Result:
|
||||||
|
// one_for_one : A=1 B=2 C=1
|
||||||
|
// rest_for_one: A=1 B=2 C=2 <- this test
|
||||||
|
// one_for_all : A=2 B=2 C=2
|
||||||
|
let a = Arc::new(AtomicUsize::new(0));
|
||||||
|
let b = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c = Arc::new(AtomicUsize::new(0));
|
||||||
|
let (ra, rb, rc) = (a.clone(), b.clone(), c.clone());
|
||||||
|
run(move || {
|
||||||
|
let child_a = counting_child(&ra, |n| {
|
||||||
|
if n == 1 {
|
||||||
|
// Stay alive (parked) across B's failure, then exit on its own.
|
||||||
|
sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let child_b = counting_child(&rb, |n| {
|
||||||
|
if n < 2 {
|
||||||
|
panic!("b crash {n}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let child_c = counting_child(&rc, |_| { /* always normal */ });
|
||||||
|
let sup = spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.strategy(Strategy::RestForOne)
|
||||||
|
.intensity(10, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_a))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_b))
|
||||||
|
.child(ChildSpec::new(Restart::Transient, child_c))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(a.load(Ordering::SeqCst), 1, "A (prefix) is left untouched");
|
||||||
|
assert_eq!(b.load(Ordering::SeqCst), 2, "B (the failure) is restarted");
|
||||||
|
assert_eq!(c.load(Ordering::SeqCst), 2, "C (suffix) is cycled with B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_restart_and_shutdown_stop_in_reverse_start_order() {
|
||||||
|
// A(0) and B(1) park (long sleep) and record their teardown order on Drop;
|
||||||
|
// C(2) panics on every run. Under one_for_all with intensity(1):
|
||||||
|
// - C's first panic cycles the group: live siblings B,A are stopped in
|
||||||
|
// reverse start order -> Drop order [B, A].
|
||||||
|
// - after the restart C panics again, tripping the cap; the supervisor
|
||||||
|
// shuts down, stopping the survivors B,A in reverse order again.
|
||||||
|
// We assert the first teardown wave is [B, A] (reverse of start order A,B).
|
||||||
|
let order = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||||
|
|
||||||
|
struct Rec(&'static str, Arc<Mutex<Vec<&'static str>>>);
|
||||||
|
impl Drop for Rec {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.1.lock().unwrap().push(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let oa = order.clone();
|
||||||
|
run(move || {
|
||||||
|
let o_a = oa.clone();
|
||||||
|
let o_b = oa.clone();
|
||||||
|
let sup = spawn(move || {
|
||||||
|
let o_a2 = o_a.clone();
|
||||||
|
let o_b2 = o_b.clone();
|
||||||
|
OneForOne::new()
|
||||||
|
.strategy(Strategy::OneForAll)
|
||||||
|
.intensity(1, Duration::from_secs(60))
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, move || {
|
||||||
|
let _g = Rec("A", o_a2.clone());
|
||||||
|
sleep(Duration::from_secs(30)); // interrupted by the stop
|
||||||
|
}))
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, move || {
|
||||||
|
let _g = Rec("B", o_b2.clone());
|
||||||
|
sleep(Duration::from_secs(30));
|
||||||
|
}))
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, || panic!("c always")))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
sup.join().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let recorded = order.lock().unwrap().clone();
|
||||||
|
assert!(
|
||||||
|
recorded.len() >= 2,
|
||||||
|
"expected at least one teardown wave, got {recorded:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&recorded[..2],
|
||||||
|
&["B", "A"],
|
||||||
|
"siblings must be torn down in reverse start order (got {recorded:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user