P11 slice 70 — IRCv3 MONITOR (online/offline watch list)#
Goal#
Implement the IRCv3 MONITOR extension in leveva: a per-connection watch list of
nicks that the server proactively reports online/offline transitions for, replacing
client-side ISON polling. Spec: docs/rfcs/ircv3/monitor.md. This is the next
substantial IRCv3-track feature (the track note flagged it as "the next substantial
one" after the message-tags follow-ons). leveva-native (the C 2.11 oracle has no
MONITOR) → unit + golden + proptest, no leveva-integration differential.
Locked decisions#
- No new capability. MONITOR is advertised via the
MONITOR=<limit>ISUPPORT (005) token, not a CAP (per the spec).cap.rs/ClientCaps/SUPPORTEDare untouched. - The watch state lives in a shared
Monitorsindex, NOT onRegistered. Keeping the per-connection list insidectx.monitorskeyed by the watcher's UID avoids touching theRegisteredstruct (and its ~25 test-literal sites — see [[leveva-ircv3-track]] churn note). The index holds two views under one lock:by_target: folded-nick → set<watcher Uid>— the reverse index for online/offline fanout.by_watcher: Uid → Vec<display target>— insertion-ordered, forMONITOR L/Sand disconnect cleanup. Dedup is by RFC-1459 fold; lists are ≤ limit so a linear scan is fine.
MONITOR_LIMIT = 100(the ISUPPORT value).- Online status is read from the registry (
registry.get(nick)), which is the truth for both local and remote (S2SUNICK) users. Live notifications fire only on LOCAL events (registration, disconnect, nick change) — remote online/offline transitions arriving over S2S do not yet trigger MONITOR notifications (single-server-local posture, deferred — the track's consistent stance since slice 59).
Design#
New module leveva/src/monitor.rs (pub mod monitor; in lib.rs)#
pub const MONITOR_LIMIT: usize = 100;struct Monitors { inner: Mutex<Inner> }withInner { by_target: HashMap<String, HashSet<Uid>>, by_watcher: HashMap<Uid, Vec<String>> }.add(watcher, display) -> bool— add iff not already present (by fold) and returns true if newly added. Updates both maps.contains(watcher, display) -> bool,count(watcher) -> usize.remove(watcher, display)— drop from both; prune an emptiedby_targetset.clear(watcher)— remove the watcher from every target set (pruning empties) + drop itsby_watcherentry. Called on disconnect.list(watcher) -> Vec<String>— the watcher's display targets, insertion order.watchers_of(folded_target) -> Vec<Uid>— for fanout (sorted for determinism).
- Free fns (the live-notify plane, called from the session/nick hooks):
notify_online(ctx, nick, user, host)— deliver730 <watcher-nick> :nick!user@hostto each watcher offold(nick)(watcher nick resolved live viaregistry.nick_of).notify_offline(ctx, nick)— deliver731 <watcher-nick> :nickto each watcher.
New handler leveva/src/command/monitor.rs (mod monitor; + dispatch arm)#
MONITOR <modifier> [targets], one modifier per command:
- no param →
461 ERR_NEEDMOREPARAMS. + t1,t2,…— add each (validNick, skip invalid). Already-monitored → no-op. Past the limit → collect into a single734 ERR_MONLISTFULL <limit> <rejected> :Monitor list is full.For the newly-added targets: online →730(hostmasknick!user@host), offline →731(bare nick), each comma-chunked within the 512 wire budget.- t1,t2,…— remove each. No output.C— clear all. No output.L—732(comma-chunked list) then733 :End of MONITOR list(just733if empty).S— per target: online730/ offline731, comma-chunked.- unknown modifier → silent no-op.
Targets gathered from params()[1..] + trailing(), split on ,, empties filtered.
Comma-chunking helper budgets :server NNN <nick> : + joined items ≤ MAX_CONTENT_LEN.
Live-notify hooks#
session.rs::finalize_registration— after a successfultry_claim, callmonitor::notify_online(ctx, &nick, &user, &self.host).session.rs::release(thePhase::Registeredarm) —monitor::notify_offline(ctx, &client.nick)thenctx.monitors.clear(&client.uid)(drop this watcher's own list).command/nick.rs::nick_change(success) —notify_offline(ctx, &old_nick)thennotify_online(ctx, &new, user, host)(old nick goes offline, new comes online).
Numerics (numeric.rs)#
Add RplMononline=730, RplMonoffline=731, RplMonlist=732, RplEndofmonlist=733,
ErrMonlistfull=734 (variant + name() + try_from arms). is_error stays range-based
(730–734 are all ≥ 600 → classified replies; harmless, 734 is a functional error reply).
ISUPPORT (isupport.rs)#
Append format!("MONITOR={}", crate::monitor::MONITOR_LIMIT) to tokens() (005 snapshots
regenerate — golden_registration etc.).
Tests (TDD, inverse invariants)#
monitor.rsunits: add/contains/count/remove/clear/list/watchers_of round-trips with inverses — remove un-monitors (watchers_of no longer lists the UID); clear drops the watcher from every target AND empties its list; a re-add after remove works; dedup keeps one entry (fold-aware:Bob/bob); pruning empties (a target with no watchers disappears); watchers_of on an unknown target is empty; clear of an unknown UID is a no-op.command/monitor.rsunits:+ bobwhen bob online →730 …:bob!u@h; when offline →731.- bobremoves (inverse: a laterSno longer reports it).Cclears (inverse:L→ just 733).Llists added targets + 733; empty → only 733.Sreports current online/offline. no-param → 461. invalid nick skipped. dedup:+ bobtwice keeps one (second is a no-op). limit overflow → 734 with the rejected tail (and the under-limit prefix still added).- live notify units (drive through the real seams): a watcher with an observable mailbox
gets
730when the monitored nick registers,731when it disconnects, and731+730across a nick change; inverse: a non-watcher's mailbox stays empty; after-/Cthe ex-watcher gets nothing on the next transition. - Golden
golden_monitor.rs(real binary, two clients): aliceMONITOR + bob→731(bob offline); bob registers → alice reads730 alice :bob!…@127.0.0.1; bob quits → alice reads731 alice :bob.MONITOR L/Ssnapshotted. Deterministic host → no canonicalizer rule. - Proptest
monitor_proptest.rs(the fuzz target = theMonitorsindex + chunker): a model set vs the index agree on membership/count after an arbitrary add/remove/clear sequence;watchers_of⟺ the watcher monitors the target; the comma-chunker never exceeds the wire budget and never splits a token, and re-parses to the same item set; panic-freedom on arbitrary nick bytes.
Divergences#
- Single-server-local live notify (remote S2S transitions deferred); MONITOR is CAP-less (ISUPPORT only, per spec); 730 always carries the hostmask (spec MAY); re-adding an already-monitored target is a silent no-op (no re-report); invalid-nick targets skipped silently; unknown modifier is a no-op.
Gate#
cargo test -p leveva green (new monitor units + command units + golden + proptest +
regenerated 005 snapshots); cargo clippy -p leveva --tests clean; cargo build --workspace
0 warnings.