RFC 016 Chunk 4: observer gen_server (feature-gated)

A thin GenServer consumer of the Chunk-1 read primitive — the live
observer process (D12). ObserverRequest/ObserverReply are the wire
contract (D11); the version rides along on the snapshot/tree payloads,
which already carry SNAPSHOT_FORMAT_VERSION (D1). Behind the new
`observer` Cargo feature, off by default (D10): the primitive stays
always-on, only the transport is gated. Cast is Infallible, so the
server takes no async traffic and handle_cast is statically unreachable.

Gated integration test proves each verb relays exactly what the
corresponding primitive returns (snapshot/tree/actor_info), incl. a
forged-pid None and a live Parked classification.
This commit is contained in:
smarm-agent
2026-06-19 10:18:54 +00:00
parent 48d47c45c9
commit 6df4cd4a0b
4 changed files with 244 additions and 0 deletions
+4
View File
@@ -28,6 +28,8 @@ pub mod pg;
pub mod link;
pub mod gen_server;
pub mod introspect;
#[cfg(feature = "observer")]
pub mod observer;
pub mod runtime;
pub(crate) mod raw_mutex;
pub(crate) mod slot_state;
@@ -59,6 +61,8 @@ pub use introspect::{
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
TreeNode, SNAPSHOT_FORMAT_VERSION,
};
#[cfg(feature = "observer")]
pub use observer::{ObserverReply, ObserverRequest};
pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard};
+114
View File
@@ -0,0 +1,114 @@
//! RFC 016 — runtime observability (Chunk 4: the observer gen_server).
//!
//! A thin [`GenServer`] that consumes the Chunk-1 read primitive
//! ([`snapshot`](crate::snapshot) / [`tree`](crate::tree) /
//! [`actor_info`](crate::actor_info)) over a message interface — the live
//! `observer` process, in the OTP sense. It is a *transport*, not the
//! mechanism: the synchronous internal read stays the primitive, and the
//! observer is just one more consumer of it alongside the test suite. This is
//! also the read half of the future RFC 003 control plane — the same actor
//! gains write verbs there rather than a second consumer being spun up
//! (DECISION D12).
//!
//! ## Why it is feature-gated (DECISION D10)
//!
//! The read primitive (Chunks 13) is always present and unflagged: it is pure
//! reads and the test suite leans on it. The *gen_server* sits behind the
//! `observer` Cargo feature, off by default, matching RFC 003's dev-only
//! feature-flag stance — a release build pays nothing for a live observer it
//! never starts.
//!
//! ## The protocol is the contract (DECISION D11)
//!
//! [`ObserverRequest`] / [`ObserverReply`] *are* the wire contract. They carry
//! no version field of their own because the payloads already do:
//! [`RuntimeSnapshot`](crate::RuntimeSnapshot) and
//! [`RuntimeTree`](crate::RuntimeTree) each carry
//! [`SNAPSHOT_FORMAT_VERSION`](crate::SNAPSHOT_FORMAT_VERSION) (D1). The owned
//! snapshot — a potentially large `Vec<ActorInfo>` — travels over the call
//! channel by value; that is intended, it is exactly what a remote observer
//! (RFC 011) will serialize across a node boundary.
use crate::gen_server::{GenServer, ServerBuilder, ServerRef};
use crate::introspect::{actor_info, snapshot, tree};
use crate::introspect::{ActorInfo, RuntimeSnapshot, RuntimeTree};
use crate::pid::Pid;
/// A read-only request to the observer. Each verb maps one-to-one onto a
/// Chunk-1 read; there are deliberately no mutating verbs here (those are RFC
/// 003, D12).
#[derive(Debug, Clone)]
pub enum ObserverRequest {
/// Whole-runtime [`snapshot`].
Snapshot,
/// Parentage forest, folded from a snapshot ([`tree`]).
Tree,
/// Coherent view of one actor ([`actor_info`]); `None` reply if the pid is
/// stale, forged, or names a vacant slot.
ActorInfo(Pid),
}
/// The observer's reply, tagged to match the [`ObserverRequest`] verb. Each
/// variant wraps the owned Chunk-1 read result unchanged — the observer adds no
/// interpretation, it is pure transport.
#[derive(Debug, Clone)]
pub enum ObserverReply {
Snapshot(RuntimeSnapshot),
Tree(RuntimeTree),
ActorInfo(Option<ActorInfo>),
}
/// The observer server. Stateless by construction (a ZST): every reply is
/// derived freshly from the live runtime on each call, so there is nothing to
/// keep between requests.
pub struct Observer;
impl GenServer for Observer {
type Call = ObserverRequest;
type Reply = ObserverReply;
/// No async verbs: the observer is request/reply only. `Infallible` is
/// uninhabited, so a `cast` can never be constructed and
/// [`handle_cast`](GenServer::handle_cast) is statically unreachable.
type Cast = core::convert::Infallible;
type Info = ();
type Timer = ();
fn handle_call(&mut self, request: ObserverRequest) -> ObserverReply {
match request {
ObserverRequest::Snapshot => ObserverReply::Snapshot(snapshot()),
ObserverRequest::Tree => ObserverReply::Tree(tree()),
ObserverRequest::ActorInfo(pid) => ObserverReply::ActorInfo(actor_info(pid)),
}
}
fn handle_cast(&mut self, request: core::convert::Infallible) {
// Uninhabited: this match has no arms because `Cast` cannot be
// constructed. It documents at the type level that the observer takes
// no fire-and-forget traffic.
match request {}
}
}
/// Spawn the observer under the current actor and hand back its [`ServerRef`].
/// Shorthand for `ServerBuilder::new(Observer).start()`; use the builder
/// directly (e.g. `.under(sup)`) to slot it into a supervision tree.
///
/// ```
/// use smarm::run;
/// use smarm::observer::{self, ObserverRequest, ObserverReply};
///
/// run(|| {
/// let obs = observer::start();
///
/// // Ask for a whole-runtime snapshot over the call channel.
/// let ObserverReply::Snapshot(snap) = obs.call(ObserverRequest::Snapshot).unwrap()
/// else { panic!("snapshot verb must reply with a snapshot") };
///
/// // The observer is itself a scheduled actor, so it appears in the very
/// // snapshot it produced — transport over the same read every consumer sees.
/// assert!(snap.actors.iter().any(|a| a.pid == obs.pid()));
/// });
/// ```
pub fn start() -> ServerRef<Observer> {
ServerBuilder::new(Observer).start()
}