From 8c764e9169ff72f27bd25baf74293a4cd3749e1f Mon Sep 17 00:00:00 2001 From: Mark Kalsbeek Date: Fri, 24 Jul 2026 08:44:56 +0200 Subject: [PATCH] docs(monitor): user-facing rewrite of process monitors Lead with the user's problem (learn when another actor dies without it knowing you're watching), explain one-directional/one-shot semantics and contrast briefly with link without assuming link.rs has been read. Add a compiling doctest. Drop em-dashes. Correctness facts about registration/death races and demonitor-after-fire safety kept, reworded in plain terms and separated from the public item docs. --- src/monitor.rs | 164 ++++++++++++++++++++++++++++++------------------- 1 file changed, 102 insertions(+), 62 deletions(-) diff --git a/src/monitor.rs b/src/monitor.rs index ce4bdc8..4e7ecd1 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,49 +1,85 @@ -//! Process monitors. +//! Find out when another actor dies, without it knowing or caring that you're +//! watching. //! -//! `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: +//! Say one actor manages a pool of workers and needs to know when a worker +//! exits, so it can replace it. The worker does not need to know it is being +//! watched, and nothing about the worker's own behavior should change because +//! someone is watching it. That is what [`monitor`] is for: call +//! `monitor(target)` to get a [`Monitor`], and read exactly one [`Down`] +//! message off `monitor.rx` whenever `target` terminates, however it +//! terminates. //! -//! - **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)`. +//! ``` +//! use smarm::{monitor, run, spawn, DownReason}; //! -//! 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. +//! run(|| { +//! let worker = spawn(|| { +//! // does some work, then returns +//! }); +//! let pid = worker.pid(); //! -//! ## Reasons +//! let m = monitor(pid); +//! let _ = worker.join(); //! -//! [`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`. +//! let down = m.rx.recv().expect("monitor channel closed before Down"); +//! assert_eq!(down.pid, pid); +//! assert_eq!(down.reason, DownReason::Exit); +//! }); +//! ``` //! -//! ## Demonitoring +//! A monitor is one-directional and one-shot: //! -//! 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])`. +//! - **One-directional**: the watcher learns that the target died, but the +//! target is completely unaffected. It never learns it was being watched, +//! and its own behavior and lifetime do not change because of the monitor. +//! This is the opposite of a [`link`](mod@crate::link), which is bidirectional: +//! linking two actors means an abnormal death on either side can bring the +//! other down too. Reach for a monitor when you just want to *know*; reach +//! for a link when a peer's crash should actually stop you. +//! - **One-shot**: you get exactly one [`Down`] per `monitor()` call, then the +//! channel closes. Calling `monitor` again on the same target (or a +//! different one) gives you an independent registration with its own +//! [`Monitor`] and its own one-shot channel; nothing stops you from +//! monitoring the same actor many times over; each call is watched and +//! fires on its own. //! -//! ## Races +//! ## Why a monitor never hands you the panic value //! -//! 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. +//! If the target panicked, [`Down`] tells you *that* it panicked +//! ([`DownReason::Panic`]), but not the panic's payload. The payload has a +//! single owner: it is handed to whichever caller `join()`s the actor's +//! [`JoinHandle`](crate::JoinHandle), as a `JoinError`. A monitor only needs +//! to know that something went wrong, not reproduce the exact value that +//! caused it, so it gets the reason and nothing else. +//! +//! Monitoring a target that is already gone (it finished and was cleaned up, +//! or the pid never pointed at a real actor) is not an error: you get a +//! [`Down`] with [`DownReason::NoProc`] right away, instead of waiting +//! forever for something that already happened. +//! +//! ## Stopping a monitor early +//! +//! [`demonitor`] cancels a monitor before it fires. If the registration was +//! still live, it removes it and returns `Some` of the monitor's id: no +//! `Down` will arrive on that channel from here on. If the target had already +//! died and its `Down` already sent, there is nothing left to cancel and +//! `demonitor` returns `None`; the `Down` you already have (or that is +//! already sitting in the channel) is unaffected. +//! +//! If you want to cancel *and* make sure a `Down` that already arrived is +//! discarded without reading it, just drop the [`Monitor`]: dropping it closes +//! its receiver, and any queued `Down` is dropped along with it. +//! +//! ## Correctness notes for implementers +//! +//! A target that is still alive at the moment `monitor()` registers is +//! guaranteed to eventually produce a real `Down`: registration and the +//! target's own termination bookkeeping run under the same lock, so there is +//! no window in which the target could die without the just-added +//! registration seeing it. `demonitor` is similarly race-free against a target +//! that has since died and had its slot reused by a new, unrelated actor: it +//! is checked against the exact monitored incarnation, so it can never remove +//! a different actor's registration by accident, it simply reports `None`. use crate::channel::{channel, Receiver, Sender}; use crate::pid::Pid; @@ -51,8 +87,8 @@ 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. +/// Carries no payload: see the module docs for why a monitor never receives +/// the panic value itself. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DownReason { /// The target returned normally. @@ -76,21 +112,22 @@ pub struct Down { pub reason: DownReason, } -/// A process-unique identifier for one `monitor()` registration. +/// A 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. +/// Opaque and `Copy`. Never reused for the life of the runtime, so if you +/// monitor the same target more than once, each call's id is distinct. This +/// is what lets [`demonitor`] tear down exactly one of several monitors on +/// the same target without disturbing the others. #[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. +/// Read the notification from [`Monitor::rx`]. Not `Clone`, since only one +/// side is meant to consume it. Dropping a `Monitor` closes the receiving +/// end; if a `Down` had already arrived but was never read, it is discarded +/// along with it. pub struct Monitor { /// This registration's process-unique id. pub id: MonitorId, @@ -110,10 +147,11 @@ pub fn monitor(target: Pid) -> Monitor { let target = target.erase(); let (tx, rx) = channel::(); - // Register under the target's cold lock. `tx.clone()` takes the channel's - // own lock — a Channel-class RawMutex, explicitly permitted *under* a Leaf - // (cold) lock by the lock order (see raw_mutex.rs). We must still not - // *send* under the lock, as `Sender::send` can unpark a parked receiver, + // Implementation note: registration happens under the target's cold + // lock. `tx.clone()` takes the channel's own lock, a Channel-class + // RawMutex, which is explicitly permitted under a Leaf (cold) lock by + // the lock order documented in raw_mutex.rs. We must still not *send* + // under the lock, since `Sender::send` can unpark a parked receiver, // and there's no reason to nest that. let (id, registered) = with_runtime(|inner| { let id = inner.alloc_monitor_id(); @@ -140,19 +178,21 @@ pub fn monitor(target: Pid) -> Monitor { } /// 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. +/// and removed, so no `Down` will arrive on `m.rx` from here on. Returns +/// `None` if there was nothing left to remove: the target had already gone +/// down and its `Down` was already sent (or is already sitting in the +/// channel, unread). /// -/// 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])`. +/// This only stops a *future* `Down`. If you also want to discard a `Down` +/// that already arrived (or is about to, in a race with this call), drop `m` +/// instead of, or in addition to, calling this: dropping the [`Monitor`] +/// closes its receiver and any queued notice is discarded with it. pub fn demonitor(m: &Monitor) -> Option { - // Remove the registration under the target's cold 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 — legal under a cold lock, but pointless to nest. + // Implementation note: the registration is removed under the target's + // cold lock, but the `Sender` is moved *out* and dropped only after the + // lock is released. Dropping the last sender runs `Sender::drop`, which + // may unpark a parked receiver; legal under a cold lock, but pointless + // to nest. let removed: Option<(MonitorId, Sender)> = with_runtime(|inner| { let slot = inner.slot_at(m.target)?; let mut cold = slot.cold.lock();