nexo_core/config_reload.rs
1//! Runtime config reload coordinator.
2//!
3//! Watches the config directory, re-runs boot validation, builds a
4//! fresh `RuntimeSnapshot` per agent, and dispatches
5//! `ReloadCommand::Apply` to each live runtime through the per-agent
6//! mpsc channel the runtime exposed via `reload_sender()`. The
7//! existing per-agent snapshot is left in place whenever validation
8//! fails or a snapshot cannot be built — servicing never drops to a
9//! broken config.
10//!
11//! Scope: hot-swap of **existing** agents only. Adding a brand-new
12//! agent id or removing a running one requires spawn/teardown
13//! plumbing that lives in `src/main.rs` today.
14
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use arc_swap::ArcSwapOption;
20use dashmap::DashMap;
21use nexo_broker::{AnyBroker, BrokerHandle};
22use nexo_config::AppConfig;
23use nexo_llm::LlmRegistry;
24use tokio::sync::{mpsc, Mutex};
25use tokio_util::sync::CancellationToken;
26
27use crate::agent::runtime::ReloadCommand;
28use crate::agent::spawn::{AgentSpawnerFn, SharedRuntimeContext};
29use crate::runtime_snapshot::RuntimeSnapshot;
30use crate::telemetry;
31
32/// Per-agent state the coordinator needs to dispatch a reload. Held
33/// in a `DashMap<String, AgentReloadHandle>` keyed by agent id.
34///
35/// `known_tools` captures the agent's tool surface at boot time —
36/// builtins + plugins + MCP + extensions + skills, after the per-agent
37/// allowlist prune. The coordinator uses it during reload so a typo
38/// in `allowed_tools` (binding-level) fails the swap instead of
39/// silently degrading to "agent has no tools at runtime".
40pub struct AgentReloadHandle {
41 pub reload_tx: mpsc::Sender<ReloadCommand>,
42 pub known_tools: Arc<Vec<String>>,
43}
44
45/// Hook the reload coordinator runs after every successful swap.
46/// Used to invalidate process-wide caches that hold a stale view of
47/// data the reload may have changed (e.g. `PairingGate`'s in-memory
48/// allowlist cache, which would otherwise keep blocking a sender the
49/// operator just `nexo pair seed`-ed). Hooks are best-effort; they
50/// run sequentially under the same gate as the reload itself, so
51/// keep them cheap (one mutex / one dashmap clear).
52pub type PostReloadHook = Box<dyn Fn() + Send + Sync>;
53
54/// Reload coordinator. One instance per process; `start` spawns the
55/// file watcher and the broker `control.reload` subscriber.
56pub struct ConfigReloadCoordinator {
57 config_dir: PathBuf,
58 runtimes: DashMap<String, AgentReloadHandle>,
59 llm_registry: Arc<LlmRegistry>,
60 version: Mutex<u64>,
61 /// Serial gate so two overlapping triggers (watcher + CLI) don't
62 /// race the snapshot build. The second trigger queues behind the
63 /// first.
64 gate: Mutex<()>,
65 /// Broker handle attached at `start()`. `Some` once the daemon is
66 /// up; the file-watcher branch uses it to publish
67 /// `events.runtime.config.reloaded` after every successful swap so
68 /// extensions and dashboards can react without polling.
69 broker: ArcSwapOption<AnyBroker>,
70 /// Cache-flush hooks fired after every successful reload. Locked
71 /// by the same gate as the reload to keep the contract simple
72 /// (no observer can run mid-swap).
73 post_hooks: Mutex<Vec<PostReloadHook>>,
74 /// Phase 81.32 — shared runtime context the coordinator
75 /// hands to `spawn_agent_runtime` when an agent id appears
76 /// in the new config that wasn't there before. `None` keeps
77 /// the legacy "adding a new agent at runtime is not
78 /// supported" rejection so tests that haven't wired the
79 /// context stay on the old behaviour.
80 shared_ctx: ArcSwapOption<SharedRuntimeContext>,
81 /// Phase 81.32 c6 — spawner closure the coordinator invokes
82 /// when an unknown agent id appears in `agents.yaml`. `None`
83 /// keeps the legacy rejection ("adding a new agent at
84 /// runtime is not supported"). Installed at boot via
85 /// [`Self::set_spawner`] once `src/main.rs` has finished
86 /// constructing all per-agent dependencies.
87 spawner: ArcSwapOption<AgentSpawnerFn>,
88 shutdown: CancellationToken,
89}
90
91/// Result returned by [`ConfigReloadCoordinator::reload`].
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct ReloadOutcome {
94 pub version: u64,
95 pub applied: Vec<String>,
96 pub rejected: Vec<ReloadRejection>,
97 pub elapsed_ms: u64,
98}
99
100#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct ReloadRejection {
102 pub agent_id: Option<String>,
103 pub reason: String,
104}
105
106impl ConfigReloadCoordinator {
107 pub fn new(
108 config_dir: PathBuf,
109 llm_registry: Arc<LlmRegistry>,
110 shutdown: CancellationToken,
111 ) -> Self {
112 Self {
113 config_dir,
114 runtimes: DashMap::new(),
115 llm_registry,
116 version: Mutex::new(0),
117 gate: Mutex::new(()),
118 broker: ArcSwapOption::from(None),
119 post_hooks: Mutex::new(Vec::new()),
120 shared_ctx: ArcSwapOption::from(None),
121 spawner: ArcSwapOption::from(None),
122 shutdown,
123 }
124 }
125
126 /// Phase 81.32 — install the [`SharedRuntimeContext`] the
127 /// reload coordinator hands to `spawn_agent_runtime` when an
128 /// agent id appears in the freshly-loaded config that wasn't
129 /// in the previous one. Without this wired, the legacy
130 /// rejection ("adding a new agent at runtime is not
131 /// supported") fires for unknown ids.
132 ///
133 /// Late-bindable so `src/main.rs` can build the coordinator
134 /// before the boot-loop singletons are fully assembled and
135 /// upgrade it once they are.
136 pub fn with_shared_context(self, shared: Arc<SharedRuntimeContext>) -> Self {
137 self.shared_ctx.store(Some(shared));
138 self
139 }
140
141 /// Phase 81.32 — read-only handle to the configured shared
142 /// context. `None` when [`Self::with_shared_context`] has
143 /// not been called yet (legacy / test path).
144 pub fn shared_context(&self) -> Option<Arc<SharedRuntimeContext>> {
145 self.shared_ctx.load_full()
146 }
147
148 /// Phase 81.32 c6 — install the spawner closure invoked when
149 /// an unknown agent id appears in `agents.yaml`. Late-bindable
150 /// (same shape as [`Self::with_shared_context`]) so the
151 /// coordinator can exist before the boot loop captures every
152 /// per-agent dependency.
153 pub fn set_spawner(&self, spawner: Arc<AgentSpawnerFn>) {
154 self.spawner.store(Some(spawner));
155 }
156
157 /// Phase 81.32 c6 — read-only handle to the configured
158 /// spawner. `None` when [`Self::set_spawner`] has not yet
159 /// fired; callers fall back to the legacy "not supported"
160 /// rejection.
161 pub fn spawner(&self) -> Option<Arc<AgentSpawnerFn>> {
162 self.spawner.load_full()
163 }
164
165 /// Phase 81.32 — uninstall the per-agent reload handle when
166 /// an agent is hot-removed from `agents.yaml`. Returns the
167 /// handle so the coordinator can drive its
168 /// `ReloadCommand::Shutdown` send before dropping it.
169 pub fn unregister(&self, agent_id: &str) -> Option<AgentReloadHandle> {
170 self.runtimes.remove(agent_id).map(|(_, handle)| handle)
171 }
172
173 /// Register a closure that fires after every successful reload.
174 /// Callers should add their cache-invalidation entry point here
175 /// at boot. Hooks run inside the reload gate so they observe the
176 /// new config but cannot themselves overlap a future reload.
177 pub async fn register_post_hook(&self, hook: PostReloadHook) {
178 self.post_hooks.lock().await.push(hook);
179 }
180
181 /// Register a live agent runtime's reload channel. Called once per
182 /// agent during boot; subsequent reloads look these up to dispatch.
183 pub fn register(
184 &self,
185 agent_id: impl Into<String>,
186 reload_tx: mpsc::Sender<ReloadCommand>,
187 known_tools: Arc<Vec<String>>,
188 ) {
189 self.runtimes.insert(
190 agent_id.into(),
191 AgentReloadHandle {
192 reload_tx,
193 known_tools,
194 },
195 );
196 }
197
198 /// Re-read config, validate, build snapshots, dispatch. Returns an
199 /// aggregate outcome — successful ids in `applied`, per-agent or
200 /// top-level failures in `rejected`. The coordinator never panics
201 /// on a bad config; it logs + bumps the rejected counter and keeps
202 /// old snapshots serving.
203 pub async fn reload(&self) -> ReloadOutcome {
204 let _gate = self.gate.lock().await;
205 let started = Instant::now();
206 let mut applied: Vec<String> = Vec::new();
207 let mut rejected: Vec<ReloadRejection> = Vec::new();
208
209 // 1. Load + env-resolve.
210 let cfg = match AppConfig::load(&self.config_dir) {
211 Ok(c) => c,
212 Err(e) => {
213 telemetry::inc_config_reload_rejected();
214 tracing::warn!(error = %e, "config reload: load failed, keeping previous snapshot");
215 rejected.push(ReloadRejection {
216 agent_id: None,
217 reason: format!("AppConfig::load: {e}"),
218 });
219 let current = *self.version.lock().await;
220 return ReloadOutcome {
221 version: current,
222 applied,
223 rejected,
224 elapsed_ms: started.elapsed().as_millis() as u64,
225 };
226 }
227 };
228
229 // 2. Structural + provider validation (aggregate errors).
230 // Providers are LLM yaml instance ids
231 // (`anthropic-a5b8`), NOT factory ids — agents bind to
232 // yaml-key instances that map to a factory via
233 // `factory_type`. Mirror the boot validation path in
234 // `src/main.rs::validate_agents_with_providers`.
235 let known_providers =
236 crate::agent::KnownProviders::new(cfg.llm.providers.keys().map(String::as_str));
237 if let Err(e) = crate::agent::validate_agents_with_providers(
238 &cfg.agents.agents,
239 &cfg.plugins,
240 &crate::agent::KnownTools::default(),
241 &known_providers,
242 ) {
243 telemetry::inc_config_reload_rejected();
244 tracing::warn!(error = %e, "config reload: validation failed, keeping previous snapshot");
245 rejected.push(ReloadRejection {
246 agent_id: None,
247 reason: format!("validation: {e}"),
248 });
249 let current = *self.version.lock().await;
250 return ReloadOutcome {
251 version: current,
252 applied,
253 rejected,
254 elapsed_ms: started.elapsed().as_millis() as u64,
255 };
256 }
257
258 // 3. Bump version + build snapshots per agent present in BOTH
259 // old (registered handles) and new (config). Agents that
260 // disappear or appear are out of scope — we skip them
261 // with a rejection entry so the operator sees the diff.
262 let mut version_guard = self.version.lock().await;
263 *version_guard += 1;
264 let new_version = *version_guard;
265 drop(version_guard);
266
267 for agent_cfg in &cfg.agents.agents {
268 // Phase 81.32 c8 — unknown agent id (wizard create
269 // path). Try the installed spawner first; fall back to
270 // the legacy rejection only when no spawner was wired
271 // (test harnesses / minimal embeddings). Closure
272 // invocation drops the lock guard via `self.runtimes
273 // .get(...)` returning `Some` only when an entry
274 // already exists; the spawner branch runs OUTSIDE the
275 // guard scope to keep `register(...)` reentrant.
276 if !self.runtimes.contains_key(&agent_cfg.id) {
277 let Some(spawner) = self.spawner() else {
278 rejected.push(ReloadRejection {
279 agent_id: Some(agent_cfg.id.clone()),
280 reason: "adding a new agent at runtime is not supported; \
281 set a spawner via ConfigReloadCoordinator::set_spawner"
282 .into(),
283 });
284 continue;
285 };
286 match spawner.call(agent_cfg.clone()).await {
287 Ok(spawned) => {
288 self.register(
289 spawned.agent_id.clone(),
290 spawned.reload_tx,
291 spawned.known_tools,
292 );
293 applied.push(spawned.agent_id.clone());
294 // Best-effort firehose notification — operators
295 // tail the broker events stream to see when a
296 // wizard-created agent goes live.
297 if let Some(b) = self.broker.load_full() {
298 let evt = nexo_broker::Event::new(
299 "events.runtime.agent.spawned",
300 "config_reload",
301 serde_json::json!({
302 "agent_id": spawned.agent_id,
303 "version": new_version,
304 }),
305 );
306 let _ = b.publish("events.runtime.agent.spawned", evt).await;
307 }
308 tracing::info!(
309 agent = %agent_cfg.id,
310 "hot-spawned agent via ConfigReloadCoordinator",
311 );
312 }
313 Err(e) => {
314 rejected.push(ReloadRejection {
315 agent_id: Some(agent_cfg.id.clone()),
316 reason: format!("spawn: {e}"),
317 });
318 }
319 }
320 continue;
321 }
322 let Some(handle) = self.runtimes.get(&agent_cfg.id) else {
323 // Race window between `contains_key` + `get` —
324 // operator hot-removed the agent mid-reload. Treat
325 // as a rejection for this cycle; the next reload
326 // re-detects.
327 rejected.push(ReloadRejection {
328 agent_id: Some(agent_cfg.id.clone()),
329 reason: "agent vanished between hot-spawn check and snapshot build".into(),
330 });
331 continue;
332 };
333
334 // Per-agent post-assembly tool-name validation. Mirrors
335 // the boot-path second-pass check so a binding's typo'd
336 // `allowed_tools` rejects the reload instead of silently
337 // landing a config that the runtime then has to translate
338 // into a "tool not available" error every turn.
339 let known_strs: Vec<&str> = handle.known_tools.iter().map(|s| s.as_str()).collect();
340 let catalog = crate::agent::KnownTools::new(known_strs);
341 if let Err(e) = crate::agent::validate_agent(agent_cfg, &cfg.plugins, &catalog) {
342 rejected.push(ReloadRejection {
343 agent_id: Some(agent_cfg.id.clone()),
344 reason: format!("post-assembly validation: {e}"),
345 });
346 continue;
347 }
348
349 let snap = match RuntimeSnapshot::build(
350 Arc::new(agent_cfg.clone()),
351 &self.llm_registry,
352 &cfg.llm,
353 new_version,
354 ) {
355 Ok(s) => Arc::new(s),
356 Err(e) => {
357 rejected.push(ReloadRejection {
358 agent_id: Some(agent_cfg.id.clone()),
359 reason: format!("snapshot build: {e}"),
360 });
361 continue;
362 }
363 };
364
365 match handle.reload_tx.send(ReloadCommand::Apply(snap)).await {
366 Ok(()) => applied.push(agent_cfg.id.clone()),
367 Err(e) => rejected.push(ReloadRejection {
368 agent_id: Some(agent_cfg.id.clone()),
369 reason: format!("dispatch: {e}"),
370 }),
371 }
372 }
373
374 // 4. Detect removed agents (registered but absent from new cfg).
375 // Phase 81.32 c9 — hot-teardown. Send `ReloadCommand::Shutdown`
376 // to the per-agent runtime so it drops broker subs +
377 // heartbeat tasks cleanly, then `unregister` the handle so
378 // future reloads can `hot-spawn` the same id without
379 // colliding with stale state. Best-effort event publish so
380 // operators watching the firehose see hot-remove distinctly
381 // from a regular reload.
382 //
383 // Collect removed ids first (can't mutate the map while
384 // iterating it; DashMap reentrant remove panics on the same
385 // shard).
386 let removed_ids: Vec<String> = self
387 .runtimes
388 .iter()
389 .filter_map(|entry| {
390 let id = entry.key();
391 if !cfg.agents.agents.iter().any(|a| &a.id == id) {
392 Some(id.clone())
393 } else {
394 None
395 }
396 })
397 .collect();
398 for id in removed_ids {
399 let Some(handle) = self.unregister(&id) else {
400 continue;
401 };
402 // Best-effort `Shutdown` dispatch. A full mailbox /
403 // closed channel here means the runtime task already
404 // exited — log + carry on so the operator still sees
405 // the agent disappear from `runtimes`.
406 if let Err(e) = handle.reload_tx.send(ReloadCommand::Shutdown).await {
407 tracing::warn!(
408 agent = %id,
409 error = %e,
410 "hot-remove: runtime mailbox dispatch failed (task may have exited already)",
411 );
412 }
413 if let Some(b) = self.broker.load_full() {
414 let evt = nexo_broker::Event::new(
415 "events.runtime.agent.removed",
416 "config_reload",
417 serde_json::json!({
418 "agent_id": id,
419 "version": new_version,
420 }),
421 );
422 let _ = b.publish("events.runtime.agent.removed", evt).await;
423 }
424 applied.push(id.clone());
425 tracing::info!(agent = %id, "hot-removed agent via ConfigReloadCoordinator");
426 }
427
428 let elapsed_ms = started.elapsed().as_millis() as u64;
429 telemetry::observe_config_reload_latency_ms(elapsed_ms);
430
431 if !applied.is_empty() {
432 telemetry::inc_config_reload_applied();
433 tracing::info!(
434 version = new_version,
435 applied = ?applied,
436 rejected_count = rejected.len(),
437 elapsed_ms,
438 "config reload applied",
439 );
440 // Run cache-invalidation hooks (e.g. PairingGate flush)
441 // before publishing the reload event, so consumers see
442 // the new state cleanly. We hold the lock briefly — the
443 // gate above already prevents overlapping reloads, the
444 // post-hooks lock just guards the registration list.
445 let hooks = self.post_hooks.lock().await;
446 for hook in hooks.iter() {
447 hook();
448 }
449 drop(hooks);
450 // Broadcast the event so extensions
451 // and dashboards can react without polling. Non-fatal if
452 // the publish fails; the metrics + log already record the
453 // swap.
454 if let Some(broker) = self.broker.load_full() {
455 let payload = serde_json::json!({
456 "version": new_version,
457 "applied": &applied,
458 "rejected": &rejected,
459 "elapsed_ms": elapsed_ms,
460 });
461 let topic = "events.runtime.config.reloaded";
462 let evt = nexo_broker::Event::new(topic, "config-reload", payload);
463 if let Err(e) = broker.publish(topic, evt).await {
464 tracing::warn!(error = %e, "failed to publish events.runtime.config.reloaded");
465 }
466 }
467 }
468 if !rejected.is_empty() {
469 tracing::warn!(
470 version = new_version,
471 rejected = ?rejected,
472 "config reload: partial rejects",
473 );
474 }
475
476 ReloadOutcome {
477 version: new_version,
478 applied,
479 rejected,
480 elapsed_ms,
481 }
482 }
483
484 /// Start the watcher + broker subscriber. Returns immediately; the
485 /// work runs on spawned tasks that honour `self.shutdown`.
486 pub async fn start(
487 self: Arc<Self>,
488 broker: AnyBroker,
489 reload: nexo_config::RuntimeReloadConfig,
490 ) -> anyhow::Result<()> {
491 if !reload.enabled {
492 tracing::info!("config hot-reload disabled via runtime.yaml");
493 return Ok(());
494 }
495
496 // Stash the broker so reload() can emit
497 // `events.runtime.config.reloaded` regardless of whether the
498 // trigger came from the file watcher or the CLI.
499 self.broker.store(Some(Arc::new(broker.clone())));
500
501 // File watcher → debounced notifications.
502 let watcher_rx = crate::config_watch::spawn_config_watcher(
503 self.config_dir.clone(),
504 reload.extra_watch_paths.clone(),
505 Duration::from_millis(reload.debounce_ms),
506 self.shutdown.clone(),
507 )?;
508 let coord_watcher = Arc::clone(&self);
509 tokio::spawn(async move {
510 let mut rx = watcher_rx;
511 while let Some(()) = rx.recv().await {
512 if coord_watcher.shutdown.is_cancelled() {
513 break;
514 }
515 let _ = coord_watcher.reload().await;
516 }
517 });
518
519 // Broker subscriber — manual triggers from `agent reload`.
520 let mut sub = broker.subscribe("control.reload").await?;
521 let coord_broker = Arc::clone(&self);
522 let broker_clone = broker.clone();
523 tokio::spawn(async move {
524 loop {
525 if coord_broker.shutdown.is_cancelled() {
526 break;
527 }
528 let Some(_event) = sub.next().await else {
529 break;
530 };
531 let outcome = coord_broker.reload().await;
532 let ack_topic = "control.reload.ack";
533 let payload = serde_json::to_value(&outcome)
534 .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() }));
535 let evt = nexo_broker::Event::new(ack_topic, "config-reload", payload);
536 if let Err(e) = broker_clone.publish(ack_topic, evt).await {
537 tracing::warn!(error = %e, "failed to publish control.reload.ack");
538 }
539 }
540 });
541
542 Ok(())
543 }
544
545 /// Current monotonic version (for telemetry / tests).
546 pub async fn version(&self) -> u64 {
547 *self.version.lock().await
548 }
549}
550
551#[cfg(test)]
552impl ConfigReloadCoordinator {
553 /// Test-only: count of registered post hooks.
554 /// Used by `register_plugin_registry_reload_hook` to verify
555 /// it pushes exactly one hook.
556 pub async fn post_hooks_len_for_test(&self) -> usize {
557 self.post_hooks.lock().await.len()
558 }
559
560 /// Test-only: fire every registered post-hook in FIFO order.
561 /// Mirrors the production fire path but skips the gate + reload
562 /// itself; callers exercise the hook contract, not the reload
563 /// mechanics.
564 pub async fn fire_post_hooks_for_test(&self) {
565 let hooks = self.post_hooks.lock().await;
566 for hook in hooks.iter() {
567 hook();
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[tokio::test]
577 async fn reload_with_no_config_dir_falls_back_to_defaults() {
578 // Phase 93 — `AppConfig::load` tolerates a missing config dir
579 // by returning `Default::default()` (same as the daemon's
580 // zero-config boot path). A hot-reload pointed at a
581 // nonexistent dir is therefore a clean no-op: defaults
582 // validate, there are 0 agents to hot-swap, nothing rejected.
583 let coord = Arc::new(ConfigReloadCoordinator::new(
584 PathBuf::from("/nonexistent-config-dir-xyz"),
585 Arc::new(LlmRegistry::with_builtins()),
586 CancellationToken::new(),
587 ));
588 let outcome = coord.reload().await;
589 assert!(
590 outcome.rejected.is_empty(),
591 "a missing config dir is tolerated, not rejected: {:?}",
592 outcome.rejected
593 );
594 assert!(
595 outcome.applied.is_empty(),
596 "default config has no agents to apply: {:?}",
597 outcome.applied
598 );
599 }
600
601 #[tokio::test]
602 async fn version_starts_at_zero() {
603 let coord = ConfigReloadCoordinator::new(
604 PathBuf::from("."),
605 Arc::new(LlmRegistry::with_builtins()),
606 CancellationToken::new(),
607 );
608 assert_eq!(coord.version().await, 0);
609 }
610
611 #[tokio::test]
612 async fn post_hooks_register_and_can_be_invoked_in_order() {
613 // Verify the hook list grows and runs in
614 // registration order. The reload() success path that fires
615 // them needs a full AppConfig on disk; that's covered by the
616 // boot smoke tests. Here we just check the storage / FIFO.
617 use std::sync::atomic::{AtomicUsize, Ordering};
618 let coord = ConfigReloadCoordinator::new(
619 PathBuf::from("."),
620 Arc::new(LlmRegistry::with_builtins()),
621 CancellationToken::new(),
622 );
623 let order = Arc::new(AtomicUsize::new(0));
624 let a_witness = Arc::new(AtomicUsize::new(0));
625 let b_witness = Arc::new(AtomicUsize::new(0));
626 {
627 let order = Arc::clone(&order);
628 let w = Arc::clone(&a_witness);
629 coord
630 .register_post_hook(Box::new(move || {
631 w.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
632 }))
633 .await;
634 }
635 {
636 let order = Arc::clone(&order);
637 let w = Arc::clone(&b_witness);
638 coord
639 .register_post_hook(Box::new(move || {
640 w.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
641 }))
642 .await;
643 }
644 let hooks = coord.post_hooks.lock().await;
645 assert_eq!(hooks.len(), 2);
646 for hook in hooks.iter() {
647 hook();
648 }
649 drop(hooks);
650 assert_eq!(a_witness.load(Ordering::SeqCst), 1);
651 assert_eq!(b_witness.load(Ordering::SeqCst), 2);
652 }
653}