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}