Skip to main content

agentmux_srv/broker/
process.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Process Broker — Phase A of the process-tracking consolidation.
5//!
6//! See `docs/specs/REPORT_PROCESS_ARCHITECTURE_STATE_AND_RETHINK_2026_07_22.md`
7//! for the full rationale. AgentMux answers "is this agent/process alive and
8//! what's it doing" six different, only-partially-overlapping ways today
9//! (`blockcontroller::CONTROLLER_REGISTRY`, `process_tracker`, the `reactive`
10//! handler's own registration list, `pidregistry`, `TurnActivityTracker`'s
11//! turn-active bookkeeping, and `watchdog.rs`'s PTY-idle timers). This
12//! module is the consolidation point for the two callers that most directly
13//! motivated it (the Agent pane's process badge and the Swarm pane's block
14//! discovery/overview), modeled on `broker::scheduler`'s Credential Broker
15//! (same crate, sibling module — same "one broker, register your own
16//! backing store" pattern applied to a second domain) and on
17//! `agentmux-srv::reducer`'s existing discipline of computing state through
18//! one arbiter with explicit transition logic rather than letting every
19//! caller mutate a shared map directly (see `lifecycle_from` below, which
20//! mirrors `reducer/lifecycle.rs::handle_register`'s shape: pure function,
21//! explicit rule per legal state, no bare field copy).
22//!
23//! **Phase A scope:** read-side only. `status()`/`list()` compute a
24//! normalized `ProcessStatus` by querying today's existing sources
25//! (`blockcontroller` for lifecycle — authoritative, has an entry for every
26//! controller type; `process_tracker` for OS-process detail — only
27//! populated for `subprocess`/`persistent` controllers today) and cache the
28//! result, single-flight-guarded per call. This closes the concrete,
29//! reported bug directly: `agent.tracked-blocks`'s `.chain()` of
30//! `process_tracker` + the `reactive` handler's registration list (two
31//! structurally different registries unioned with no reconciliation) is
32//! replaced by `blockcontroller::get_all_controllers()`, which is already
33//! authoritative for "which blocks exist" across every controller type.
34//!
35//! **Deferred to later phases** (see the report's §6 open questions):
36//! migrating each `Controller` impl to register with this broker directly
37//! at spawn (closing the coverage gap at the write side, not just papering
38//! over it at the read side); consolidating the two independent
39//! activity-summary pipelines; moving the Swarm pane's client-side-only
40//! fine-grained activity chip server-side. None of that is touched here.
41
42use std::collections::HashMap;
43use std::sync::{Arc, OnceLock};
44
45use parking_lot::Mutex;
46
47use crate::backend::blockcontroller::{self, BlockControllerRuntimeStatus};
48use crate::backend::process_tracker::{self, TrackedProcess, TrackingConfidence};
49use crate::backend::mps::Broker as WpsBroker;
50
51/// MPS event name for a `ProcessStatus` change. Scoped `block:<id>`, same
52/// convention as `agent:process-added`/`controllerstatus`. Consumers that
53/// want the unified signal subscribe to this instead of the two separate
54/// events the old `.chain()`-based discovery relied on.
55pub const EVENT_STATUS_CHANGED: &str = "processbroker:status-changed";
56
57/// MPS event name for "`list()`/`list_agent_panes()` membership may have
58/// changed" — published whenever `blockcontroller::register_controller` or
59/// `delete_controller` runs. Unscoped (no `block:<id>` — see
60/// `emit_tracked_blocks_changed`'s doc comment for why).
61pub const EVENT_TRACKED_BLOCKS_CHANGED: &str = "processbroker:tracked-blocks-changed";
62
63/// Coarse "is it alive" question, normalized from `BlockControllerRuntimeStatus`'s
64/// `turn_active`/`shellprocstatus`/`shellprocexitcode` fields into one enum —
65/// the Pod-phase-equivalent signal in the Kubernetes phase/status/probe
66/// analogy the report draws in §4.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
68#[ts(export, export_to = "../../../frontend/types/rpc/")]
69#[serde(rename_all = "snake_case")]
70pub enum Lifecycle {
71    Running,
72    Idle,
73    Done,
74    Error,
75    /// No controller found for this block_id at all — distinct from `Idle`,
76    /// which means a controller exists and reports it's not mid-turn.
77    Unknown,
78}
79
80/// The broker's public read shape — one struct every consumer (Agent pane,
81/// Swarm pane, future consumers) reads instead of composing raw registry
82/// data themselves.
83#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
84#[ts(export, export_to = "../../../frontend/types/rpc/")]
85pub struct ProcessStatus {
86    pub block_id: String,
87    pub lifecycle: Lifecycle,
88    /// OS-process-tree detail — empty for controller types `process_tracker`
89    /// doesn't cover yet (today: `shell`, `acp`; report §1/§5.2). Presence of
90    /// entries here is opportunistic enrichment, not a liveness signal on
91    /// its own — use `lifecycle`, not `!processes.is_empty()`, to answer
92    /// "is it alive."
93    pub processes: Vec<TrackedProcess>,
94    pub liveness_confidence: TrackingConfidence,
95    /// Controller type string (`"shell"`, `"cmd"`, `"subprocess"`,
96    /// `"persistent"`, `"acp"`, `"app-server"`) — see `is_agent()` for why this
97    /// matters beyond being informational.
98    pub controller_type: String,
99    pub is_agent_pane: bool,
100    #[ts(type = "number")]
101    pub last_computed_ms: u64,
102    /// The raw `BlockControllerRuntimeStatus` `compute_status` already reads
103    /// to derive `lifecycle`/`is_agent_pane` above — exposed so callers that
104    /// want the finer-grained fields (`shellprocstatus`, `spawn_ts_ms`,
105    /// `turn_active` itself, not just the `lifecycle` it was folded into)
106    /// don't have to read the controller registry a second time and risk a
107    /// second, possibly-inconsistent snapshot (codex P2 on PR #2380 — the
108    /// `muxspect describe` route originally did exactly that).
109    pub controller_status: Option<BlockControllerRuntimeStatus>,
110}
111
112impl ProcessStatus {
113    fn unknown(block_id: &str) -> Self {
114        Self {
115            block_id: block_id.to_string(),
116            lifecycle: Lifecycle::Unknown,
117            processes: Vec::new(),
118            liveness_confidence: TrackingConfidence::None,
119            controller_type: String::new(),
120            is_agent_pane: false,
121            last_computed_ms: now_ms(),
122            controller_status: None,
123        }
124    }
125
126    /// True if this block is an agent pane rather than a plain terminal.
127    ///
128    /// `is_agent_pane` alone is NOT sufficient: `subprocess`/`persistent`/
129    /// `acp` controllers exist *only* for agent CLIs (one-shot-per-turn,
130    /// long-lived stream-json, and JSON-RPC/ACP agents respectively — see
131    /// `blockcontroller/mod.rs`'s controller-type doc), but
132    /// `SubprocessController` unconditionally reports `is_agent_pane:
133    /// false` in its runtime status regardless (reagent/codex P1 on
134    /// #2273 — filtering on the flag alone would wrongly exclude every
135    /// `subprocess`-type agent pane). Only `shell`/`cmd` controllers can
136    /// legitimately be either a plain terminal or an agent running inside
137    /// one, so `is_agent_pane` (set dynamically at PTY spawn time — see
138    /// `shell/lifecycle.rs`) is the correct signal for exactly those two
139    /// types, and only those two.
140    pub fn is_agent(&self) -> bool {
141        match self.controller_type.as_str() {
142            blockcontroller::BLOCK_CONTROLLER_SUBPROCESS
143            | blockcontroller::BLOCK_CONTROLLER_PERSISTENT
144            | blockcontroller::BLOCK_CONTROLLER_ACP => true,
145            _ => self.is_agent_pane,
146        }
147    }
148}
149
150use agentmux_common::time::now_ms_u64 as now_ms;
151
152/// Pure reducer over one block's raw controller status: decides
153/// `Lifecycle` via explicit per-case rules, not a bare field copy. Isolated
154/// from I/O so the transition rules are unit-testable without a real
155/// controller instance — mirrors `reducer/lifecycle.rs::handle_register`'s
156/// shape (compute the legal next state from the current observation,
157/// rather than trusting the caller's intent unconditionally).
158fn lifecycle_from(status: &BlockControllerRuntimeStatus) -> Lifecycle {
159    // turn_active is the only signal that distinguishes "actively
160    // generating a turn" from "process alive, idle between turns" for a
161    // PersistentSubprocessController — shellprocstatus alone can't tell
162    // the difference (see BlockControllerRuntimeStatus's own doc comment).
163    if status.turn_active {
164        return Lifecycle::Running;
165    }
166    match status.shellprocstatus.as_str() {
167        blockcontroller::STATUS_RUNNING => Lifecycle::Idle,
168        blockcontroller::STATUS_INIT => Lifecycle::Idle,
169        blockcontroller::STATUS_DONE if status.shellprocexitcode == 0 => Lifecycle::Done,
170        blockcontroller::STATUS_DONE => Lifecycle::Error,
171        _ => Lifecycle::Unknown,
172    }
173}
174
175/// Compute a block's current `ProcessStatus` from today's underlying
176/// sources. Pure with respect to its two inputs — the only I/O is the two
177/// read calls themselves (both cheap in-memory lock acquisitions, not
178/// blocking I/O — process_tracker's registry and blockcontroller's
179/// CONTROLLER_REGISTRY are both `parking_lot`/`std` sync locks guarding
180/// plain maps).
181fn compute_status(block_id: &str) -> ProcessStatus {
182    let controller = blockcontroller::get_controller(block_id);
183    let controller_status = controller.as_ref().map(|c| c.get_runtime_status());
184    let (processes, liveness_confidence) = process_tracker::registry::global()
185        .map(|r| (r.list_block(block_id), r.confidence_of(block_id)))
186        .unwrap_or((Vec::new(), TrackingConfidence::None));
187
188    match (controller, controller_status) {
189        (Some(controller), Some(status)) => ProcessStatus {
190            block_id: block_id.to_string(),
191            lifecycle: lifecycle_from(&status),
192            processes,
193            liveness_confidence,
194            controller_type: controller.controller_type().to_string(),
195            is_agent_pane: status.is_agent_pane,
196            last_computed_ms: now_ms(),
197            controller_status: Some(status),
198        },
199        _ => ProcessStatus::unknown(block_id),
200    }
201}
202
203pub struct ProcessBroker {
204    /// Broker-owned cache — the single source future callers read from,
205    /// rather than each composing `blockcontroller`/`process_tracker`
206    /// themselves. `parking_lot::Mutex`, not an async mutex: unlike the
207    /// credential broker's `Entry::lock` (which guards an actual network
208    /// refresh call and must not block a tokio worker thread while held),
209    /// this cache's critical section is two cheap in-memory map reads plus
210    /// a struct build — synchronous and fast, the same reasoning
211    /// `process_tracker::registry::AgentProcessRegistry` already uses
212    /// `parking_lot::Mutex` for. Coarse-grained (one mutex, not
213    /// per-block_id): contention is expected to be negligible for the same
214    /// reason; narrow the granularity later if profiling says otherwise.
215    /// If a later phase adds a genuinely blocking liveness check (report
216    /// §3.0.3 point 1 — e.g. a real health-probe round-trip), that phase
217    /// should revisit this as an async lock at the same time, not before.
218    cache: Mutex<HashMap<String, ProcessStatus>>,
219    mps_broker: Option<Arc<WpsBroker>>,
220}
221
222impl ProcessBroker {
223    pub fn new(mps_broker: Option<Arc<WpsBroker>>) -> Self {
224        Self {
225            cache: Mutex::new(HashMap::new()),
226            mps_broker,
227        }
228    }
229
230    /// Recompute and return one block's status. Single-flight-guarded by
231    /// holding the cache lock across the *entire* read-compare-write
232    /// sequence, not just the write — `compute_status` must run inside the
233    /// critical section, not before it. reagent P1 on #2273 caught this:
234    /// with the read outside the lock, two concurrent callers (e.g. an
235    /// Agent pane mount and Swarm's refresh landing near-simultaneously)
236    /// could interleave so the thread that *read* first but *wrote*
237    /// second overwrites a fresher value the other thread already wrote —
238    /// the cache regresses to a stale snapshot even though a newer read
239    /// already happened. Locking around the whole sequence (safe here
240    /// because `compute_status` is synchronous and cheap — see the `cache`
241    /// field's own doc comment) makes "last writer wins" and "last reader
242    /// wins" the same thing, which is what single-flight is supposed to
243    /// guarantee.
244    pub fn status(&self, block_id: &str) -> ProcessStatus {
245        let mut cache = self.cache.lock();
246        let fresh = compute_status(block_id);
247        // Never cache (or emit a change event for) a block with no real
248        // controller — `list()`'s own callers only ever pass real,
249        // discovered block_ids (from `get_all_controllers()`), but this
250        // broker is also reachable directly with caller-supplied input via
251        // `muxspect describe` (crates/srv/src/server/muxspect_handlers.rs)
252        // — the first RPC-adjacent surface to call `status()` with an
253        // arbitrary string rather than one already known to exist. Without
254        // this guard, repeated queries for distinct nonexistent block_ids
255        // would grow this cache unboundedly, since `forget()` is only ever
256        // called from real controller teardown and never sees IDs that were
257        // never real to begin with (reagent + codex, independently, on
258        // PR #2380).
259        if fresh.lifecycle == Lifecycle::Unknown {
260            drop(cache);
261            return fresh;
262        }
263        let changed = cache
264            .get(block_id)
265            .map(|prev| {
266                prev.lifecycle != fresh.lifecycle || prev.processes.len() != fresh.processes.len()
267            })
268            .unwrap_or(true);
269        cache.insert(block_id.to_string(), fresh.clone());
270        drop(cache);
271        if changed {
272            self.emit_changed(&fresh);
273        }
274        fresh
275    }
276
277    /// The missing batch query (report §5.4) — replaces both
278    /// `agent.tracked-blocks`'s `.chain()` of two independent registries
279    /// and the N sequential single-block `GetControllerStatus` calls
280    /// Swarm's `subscribeToBlockStatuses` issues today with one call.
281    /// Discovery source is `blockcontroller::get_all_controllers` —
282    /// authoritative for every controller type, closing the coverage gap
283    /// `process_tracker` alone has for `shell`/`acp` blocks (report §1).
284    ///
285    /// Returns EVERY controller-backed block — plain `shell`/`cmd`
286    /// terminals included, not just agent panes. Use `list_agent_panes()`
287    /// for the agent-scoped subset (which is what `agent.tracked-blocks`
288    /// actually needs — see that method's doc comment).
289    pub fn list(&self) -> Vec<ProcessStatus> {
290        blockcontroller::get_all_controllers()
291            .into_keys()
292            .map(|block_id| self.status(&block_id))
293            .collect()
294    }
295
296    /// Agent-scoped subset of `list()`. `agent.tracked-blocks`'s original
297    /// (pre-broker) implementation was implicitly agent-only — the two
298    /// registries it unioned (`process_tracker`, populated only by
299    /// agent-CLI controller types, and `reactive`'s registration list,
300    /// populated only via `register_agent`) never included a plain
301    /// terminal. `list()` alone loses that scoping because
302    /// `get_all_controllers()` is controller-type-agnostic by design
303    /// (reagent/codex P1 on #2273 — an earlier version of this PR fed
304    /// `list()` straight into `agent.tracked-blocks`, which made every
305    /// open terminal pane show up in Swarm mislabeled as an "Agent").
306    /// See `ProcessStatus::is_agent` for the exact classification rule.
307    pub fn list_agent_panes(&self) -> Vec<ProcessStatus> {
308        self.list().into_iter().filter(|s| s.is_agent()).collect()
309    }
310
311    /// Drop a block from the cache. Call on pane close so `list()` doesn't
312    /// keep serving a stale entry for a block whose controller is gone —
313    /// `compute_status` would report `Lifecycle::Unknown` for it on the
314    /// next `status()` call regardless, but a closed pane shouldn't appear
315    /// in `list()` (sourced from `get_all_controllers()`, which already
316    /// won't include it) while a stale cache entry lingers unreachable.
317    pub fn forget(&self, block_id: &str) {
318        self.cache.lock().remove(block_id);
319    }
320
321    /// Test-only: current cache size, to prove unknown/nonexistent
322    /// block_ids never get inserted (see `status()`'s own guard and the
323    /// regression test below).
324    #[cfg(test)]
325    fn cache_len(&self) -> usize {
326        self.cache.lock().len()
327    }
328
329    fn emit_changed(&self, status: &ProcessStatus) {
330        let Some(ref broker) = self.mps_broker else {
331            return;
332        };
333        broker.publish_event::<crate::backend::mps_events::ProcessStatusChanged>(status, vec![format!("block:{}", status.block_id)], 0);
334    }
335
336    /// Announce that `list()`/`list_agent_panes()` membership may have
337    /// changed — i.e. `blockcontroller::register_controller`/
338    /// `delete_controller` just ran. Unscoped (no `block:<id>`, unlike
339    /// `emit_changed` above): this is "recompute your list," not a status
340    /// update for one already-known block, so there is no single block
341    /// scope to attach it to.
342    ///
343    /// Exists because `agent.tracked-blocks`'s only data source
344    /// (`CONTROLLER_REGISTRY`) never announced its own writes — Swarm's
345    /// `agent.tracked-blocks` list was refreshed only by two *different*,
346    /// independently-timed mechanisms standing in as proxies
347    /// (`process_tracker`'s 2s poll-and-diff loop, and `reactive`'s own
348    /// registration events), neither directly coupled to when a controller
349    /// actually got registered or removed. That gap let restored agent
350    /// panes' controllers register successfully (confirmed via
351    /// `resync_controller`'s own logging) while Swarm's client-side list —
352    /// fetched once at mount, refreshed only by those two proxies — stayed
353    /// stale indefinitely for any block whose proxy signal didn't happen to
354    /// fire. See docs/reports/REPORT_SWARM_MOUNT_DEPENDENT_TRACKING_GAP_2026_09_15.md.
355    pub fn emit_tracked_blocks_changed(&self) {
356        let Some(ref broker) = self.mps_broker else {
357            return;
358        };
359        broker.publish_event::<crate::backend::mps_events::TrackedBlocksChanged>(&(), vec![], 0);
360    }
361}
362
363static GLOBAL: OnceLock<Arc<ProcessBroker>> = OnceLock::new();
364
365pub fn set_global(broker: Arc<ProcessBroker>) {
366    let _ = GLOBAL.set(broker);
367}
368
369pub fn global() -> Option<Arc<ProcessBroker>> {
370    GLOBAL.get().cloned()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn status_with(shellprocstatus: &str, turn_active: bool, exit_code: i32) -> BlockControllerRuntimeStatus {
378        BlockControllerRuntimeStatus {
379            shellprocstatus: shellprocstatus.to_string(),
380            turn_active,
381            shellprocexitcode: exit_code,
382            ..Default::default()
383        }
384    }
385
386    fn process_status_with(controller_type: &str, is_agent_pane: bool) -> ProcessStatus {
387        ProcessStatus {
388            block_id: "test-block".to_string(),
389            lifecycle: Lifecycle::Idle,
390            processes: Vec::new(),
391            liveness_confidence: TrackingConfidence::None,
392            controller_type: controller_type.to_string(),
393            is_agent_pane,
394            last_computed_ms: 0,
395            controller_status: None,
396        }
397    }
398
399    #[test]
400    fn subprocess_controller_is_always_agent_even_when_is_agent_pane_is_false() {
401        // reagent/codex P1 on #2273: SubprocessController unconditionally
402        // reports is_agent_pane: false in its own runtime status (see
403        // subprocess/mod.rs) despite existing only for agent CLIs — is_agent()
404        // must not rely on the flag for this controller type.
405        let status = process_status_with(blockcontroller::BLOCK_CONTROLLER_SUBPROCESS, false);
406        assert!(status.is_agent());
407    }
408
409    #[test]
410    fn persistent_and_acp_controllers_are_always_agent() {
411        for ty in [
412            blockcontroller::BLOCK_CONTROLLER_PERSISTENT,
413            blockcontroller::BLOCK_CONTROLLER_ACP,
414        ] {
415            let status = process_status_with(ty, false);
416            assert!(status.is_agent(), "{ty} should always classify as agent");
417        }
418    }
419
420    #[test]
421    fn shell_and_cmd_controllers_defer_to_the_real_is_agent_pane_flag() {
422        for ty in [
423            blockcontroller::BLOCK_CONTROLLER_SHELL,
424            blockcontroller::BLOCK_CONTROLLER_CMD,
425        ] {
426            assert!(
427                !process_status_with(ty, false).is_agent(),
428                "{ty} with is_agent_pane=false must not classify as agent (this is the exact regression reagent/codex caught — a plain terminal wrongly showing up in Swarm as an agent)"
429            );
430            assert!(
431                process_status_with(ty, true).is_agent(),
432                "{ty} with is_agent_pane=true (agent running inside a terminal) must classify as agent"
433            );
434        }
435    }
436
437    #[test]
438    fn unknown_block_is_never_classified_as_agent() {
439        let status = ProcessStatus::unknown("no-such-block");
440        assert!(!status.is_agent());
441    }
442
443    #[test]
444    fn active_turn_is_running_regardless_of_shellprocstatus() {
445        let status = status_with(blockcontroller::STATUS_RUNNING, true, 0);
446        assert_eq!(lifecycle_from(&status), Lifecycle::Running);
447    }
448
449    #[test]
450    fn running_without_active_turn_is_idle() {
451        let status = status_with(blockcontroller::STATUS_RUNNING, false, 0);
452        assert_eq!(lifecycle_from(&status), Lifecycle::Idle);
453    }
454
455    #[test]
456    fn init_is_idle() {
457        let status = status_with(blockcontroller::STATUS_INIT, false, 0);
458        assert_eq!(lifecycle_from(&status), Lifecycle::Idle);
459    }
460
461    #[test]
462    fn done_with_zero_exit_code_is_done() {
463        let status = status_with(blockcontroller::STATUS_DONE, false, 0);
464        assert_eq!(lifecycle_from(&status), Lifecycle::Done);
465    }
466
467    #[test]
468    fn done_with_nonzero_exit_code_is_error() {
469        let status = status_with(blockcontroller::STATUS_DONE, false, 1);
470        assert_eq!(lifecycle_from(&status), Lifecycle::Error);
471    }
472
473    #[test]
474    fn unrecognized_shellprocstatus_is_unknown() {
475        let status = status_with("some-future-status", false, 0);
476        assert_eq!(lifecycle_from(&status), Lifecycle::Unknown);
477    }
478
479    #[test]
480    fn status_of_a_block_with_no_controller_is_unknown() {
481        let broker = ProcessBroker::new(None);
482        let status = broker.status("no-such-block-id");
483        assert_eq!(status.lifecycle, Lifecycle::Unknown);
484        assert!(status.processes.is_empty());
485    }
486
487    #[test]
488    fn unknown_block_ids_are_never_cached_unbounded_growth_guard() {
489        // reagent + codex, independently, on PR #2380: `muxspect describe`
490        // is the first caller to reach `status()` with arbitrary,
491        // caller-supplied block_ids rather than ones already known to be
492        // real (list()'s own callers only ever pass IDs from
493        // get_all_controllers()). Without this guard, a loop probing
494        // distinct nonexistent block_ids would grow the cache forever,
495        // since forget() only ever fires from real controller teardown.
496        let broker = ProcessBroker::new(None);
497        for i in 0..50 {
498            let _ = broker.status(&format!("garbage-block-{i}"));
499        }
500        assert_eq!(broker.cache_len(), 0, "unknown block_ids must never be cached");
501    }
502
503    #[test]
504    fn ten_concurrent_callers_for_the_same_block_all_get_a_consistent_answer() {
505        // Not a call-count assertion (unlike the credential broker's
506        // analogous test — that one collapses concurrent refreshes onto
507        // one actual call; this one recomputes on every call by design,
508        // report §5.1, since the underlying reads are cheap in-memory
509        // lookups, not an expensive operation worth collapsing). What must
510        // hold is that concurrent callers across real OS threads never
511        // observe a torn/partial write to the shared cache.
512        let broker = Arc::new(ProcessBroker::new(None));
513        let handles: Vec<_> = (0..10)
514            .map(|_| {
515                let broker = broker.clone();
516                std::thread::spawn(move || broker.status("some-block"))
517            })
518            .collect();
519        for h in handles {
520            let status = h.join().unwrap();
521            assert_eq!(status.lifecycle, Lifecycle::Unknown);
522        }
523    }
524
525    #[test]
526    fn forget_does_not_panic_on_an_untracked_block() {
527        let broker = ProcessBroker::new(None);
528        broker.forget("never-seen-this-block");
529    }
530
531    #[test]
532    fn list_block_ids_exactly_match_the_controller_registrys_current_keys() {
533        // CONTROLLER_REGISTRY is a process-wide static shared by every test
534        // in this binary (`cargo test` runs unit tests concurrently in one
535        // process by default), so this can't assert `list()` is empty —
536        // another test running at the same time may have a real controller
537        // registered. What must hold regardless of what else is registered:
538        // list()'s block_ids exactly match get_all_controllers()'s current
539        // keys, with nothing added or dropped in between the two calls.
540        let broker = ProcessBroker::new(None);
541        let mut from_broker: Vec<String> =
542            broker.list().into_iter().map(|s| s.block_id).collect();
543        let mut from_registry: Vec<String> =
544            blockcontroller::get_all_controllers().into_keys().collect();
545        from_broker.sort();
546        from_registry.sort();
547        assert_eq!(from_broker, from_registry);
548    }
549}