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}