Skip to main content

plecto_control/
reload.rs

1//! The reload-trigger seam (ADR 000008: static declarative config + hot reload). `Control`
2//! already swaps the active set atomically; this module is *what tells it when to*. The
3//! trigger is deliberately content-free: it says "re-read the manifest from disk", never
4//! "here is the new config". That keeps Plecto on the static-manifest side of ADR 000008
5//! (the operator edits the on-disk manifest and pushes a reload; there is no xDS-style
6//! dynamic config push).
7//!
8//! `ReloadSource` is the seam (cf. `ArtifactStore`): the unix SIGHUP adapter is the real
9//! one, and a test injects a fake so the loop is exercised without process-global signals.
10
11use crate::Control;
12
13/// Outcome of a single disk reload. A *failed* reload is not represented here — it is the
14/// `Err` arm of `reload_from_disk`, and leaves the running set untouched (all-or-nothing,
15/// fail-closed). The success arm distinguishes "nothing to do" from "swapped".
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ReloadOutcome {
18    /// The on-disk manifest is byte-for-meaning identical to the running one (same
19    /// `content_hash`); no rebuild, no drain, no swap happened.
20    Unchanged,
21    /// A new filter set + chain was built and swapped in atomically. Carries the new
22    /// `config version` (the manifest content hash) for logging / audit.
23    Reloaded { hash: String },
24}
25
26/// A source of reload triggers. `recv` blocks until the next trigger fires, or returns `None`
27/// when the source is exhausted / shut down (the loop then ends). Implementors carry no
28/// config: a trigger only ever means "re-read the manifest now".
29pub trait ReloadSource {
30    /// Block until the next reload trigger; `None` ends the reload loop.
31    fn recv(&mut self) -> Option<()>;
32}
33
34/// Drive `control` from a `ReloadSource`: on every trigger, re-read the on-disk manifest and
35/// reload. Returns when the source is exhausted. A reload failure (parse / resolve / verify /
36/// load) is logged and the **current set stays live** (fail-closed) — a bad edit never takes
37/// the proxy down. An unchanged manifest is a cheap no-op.
38///
39/// This is a blocking loop; an embedder runs it on a dedicated thread alongside the data
40/// plane. The trait makes it testable without sending real signals.
41pub fn serve_reloads(control: &Control, source: &mut dyn ReloadSource) {
42    while source.recv().is_some() {
43        match control.reload_from_disk() {
44            Ok(ReloadOutcome::Unchanged) => {
45                tracing::debug!("reload trigger: manifest unchanged, keeping current set");
46            }
47            Ok(ReloadOutcome::Reloaded { hash }) => {
48                tracing::info!(config_version = %hash, "reload: swapped to new manifest");
49            }
50            Err(err) => {
51                // Fail-closed: keep serving the old set; surface the error, do not crash. A
52                // registered PLECTO-E diagnostic (ADR 000065 decision 5) rides along as
53                // structured fields — the reload log is where a signature failure surfaces.
54                match crate::diagnose(&err) {
55                    Some(d) => tracing::error!(
56                        error = %err,
57                        code = d.code,
58                        suggestion = d.suggestion,
59                        docs = d.docs,
60                        "reload failed; keeping current set"
61                    ),
62                    None => {
63                        tracing::error!(error = %err, "reload failed; keeping current set")
64                    }
65                }
66            }
67        }
68    }
69}
70
71/// The real trigger: SIGHUP, the operator's "I edited the manifest, pick it up" signal. Unix
72/// only — signals are a unix concept. Construct once, then hand to `serve_reloads`.
73#[cfg(unix)]
74pub struct SignalReloadSource {
75    signals: signal_hook::iterator::Signals,
76}
77
78#[cfg(unix)]
79impl SignalReloadSource {
80    /// Register a SIGHUP handler. Fails if the handler cannot be installed (e.g. the signal is
81    /// already taken by another consumer in-process).
82    pub fn sighup() -> std::io::Result<Self> {
83        let signals = signal_hook::iterator::Signals::new([signal_hook::consts::SIGHUP])?;
84        Ok(Self { signals })
85    }
86}
87
88#[cfg(unix)]
89impl ReloadSource for SignalReloadSource {
90    fn recv(&mut self) -> Option<()> {
91        // `forever()` blocks for the next delivered signal; pending signals are buffered in
92        // `signals`, not the iterator, so recreating it per call loses nothing.
93        self.signals.forever().next().map(|_| ())
94    }
95}