Skip to main content

decdn_node/runtime/
reload.rs

1//! Hot-reload of mutable configuration fields on SIGHUP.
2//!
3//! Reloadable fields (applied in place; no restart required):
4//!   - `observability.log_level`
5//!   - `cache.pinned_hashes`
6//!   - all of `security.*` — the live `ConnectionLimiter` swaps its
7//!     `Arc<Semaphore>` wholesale on reload (already-held permits drain
8//!     into the previous semaphore on drop; new acquires hit the new
9//!     one) and rebuilds its keyed [`governor`] rate limiter from the
10//!     new quota, swapping it in under an `RwLock`. Token-bucket state
11//!     is *not* preserved across the rebuild. `0` in any `security.*`
12//!     field disables that layer.
13//!   - the `[content]` denylist (`content.denied_hashes`,
14//!     `content.denied_origins`) — hot reload here is load-bearing, not a
15//!     convenience: ADR 011 sizes this mechanism to the one-hour removal
16//!     clock, so a takedown must never wait on a daemon restart.
17//!   - all of `[load_shed]` — the live `LoadShedController` swaps its
18//!     policy in place.
19//!
20//! This list is the authority; `NodeCommand::Reload`'s help text in
21//! `decdn-common` repeats it for operators, so extend both together.
22//!
23//! Any non-reloadable field the file carries gets a "requires restart"
24//! message (logged on presence, not on change) — the runtime would
25//! otherwise need to tear down the iroh endpoint, the metrics listener,
26//! etc., which is far beyond the scope of a quick reload.
27//!
28//! The reload entry point ([`RuntimeReloadState::reload`]) is also
29//! called directly by tests, so its only side effects are mutating
30//! values inside [`RuntimeReloadState`] and emitting `tracing` events.
31//! It does not touch any global state.
32//!
33//! On parse error, previous values are retained: the reload is
34//! best-effort and a malformed file must never crash a running node.
35//!
36//! ## Section trait
37//!
38//! Each reloadable knob is a `ReloadableSection` impl. The trait
39//! drives a fixed three-phase iteration in [`RuntimeReloadState::reload`]:
40//!
41//! 1. **Resolve every section.** A single [`ConfigDiagnostics`] is threaded
42//!    through every section's `resolve`, so an operator who broke
43//!    multiple fields sees them all in one error rather than fixing them
44//!    one SIGHUP at a time, and collapsed once at the end; any problem
45//!    → return early with previous values retained for *every* section
46//!    (the all-or-nothing contract). Resolved values
47//!    are stashed in a per-section buffer cell
48//!    (`Mutex<Option<Self::Resolved>>`) so the trait stays `dyn`-safe
49//!    despite each section having its own `Resolved` type; every section
50//!    populates its buffer on every resolve (a sentinel placeholder if
51//!    it pushed to the bag). The early return skips the swap phase, and
52//!    the next reload's `clear_buffer` evicts any leftover sentinel.
53//! 2. **Run every `fallible_commit`.** The log-level filter swap is the
54//!    only currently-fallible commit. This phase is the rollback
55//!    boundary: commits already applied stay applied; a later failure
56//!    aborts the rest. The trait makes this boundary explicit.
57//! 3. **Run every `infallible_swap`.** Atomic stores, `ArcSwap` swaps,
58//!    `ConnectionLimiter::reload`, and the per-section "applied"
59//!    tracing event live here. None can fail.
60//!
61//! The buffer cell is emptied at the start of every `reload()`, filled
62//! by `resolve`, read by `fallible_commit`, and drained by
63//! `infallible_swap`. A panic between `resolve` and `infallible_swap`
64//! would leave a buffer populated; the next `reload()`'s clear step
65//! evicts it before anything else runs.
66
67use std::path::Path;
68use std::sync::Arc;
69
70use crate::dispatch::ConnectionLimiter;
71use anyhow::Context;
72use decdn_common::cli::common::{LogFormat, LogLevel};
73use decdn_common::cli::run::ObservabilityArgs;
74use decdn_common::config::{
75    ConfigDiagnostics, ConfigNotice, ConfigNoticeLevel, FileConfig, ResolvedLoadShed,
76    ResolvedObservability, ResolvedSecurity, load_file_config, parse_pinned_hashes,
77    resolve_load_shed_into, resolve_observability_into, resolve_security_into,
78};
79
80/// Read-only snapshot of the reloadable fields, returned by
81/// [`RuntimeReloadState::current`]. Used by `admin_v1_reload` to report
82/// what's running after a successful reload — operators get back the
83/// values their RPC just applied without having to scrape logs.
84///
85/// `log_level` is `Option` for the same reason `current_log_level` is on
86/// the parent state: no `LogLevel` is running before the first reload
87/// commits one, and none ever runs while `RUST_LOG` drives the live filter.
88#[derive(Debug, Clone, Copy)]
89pub struct ReloadSnapshot {
90    /// The live log level. `None` before the first reload commits one, and
91    /// for the life of the process while `RUST_LOG` drives the live filter.
92    pub log_level: Option<decdn_common::cli::common::LogLevel>,
93}
94
95/// What a [`LogLevelSetter`] did with a requested level.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum LogLevelApply {
98    /// The live filter now runs at the requested level.
99    Installed,
100    /// The live filter came from `RUST_LOG` and stays in place: `RUST_LOG`
101    /// wins over the config file for the life of the process.
102    KeptRustLog,
103}
104
105/// Closure that swaps the live `EnvFilter` to one matching `level`.
106///
107/// Boxed so the runtime can hold it without naming the (large, layered)
108/// concrete subscriber type that `tracing_subscriber::reload::Handle` is
109/// generic over. Returning `anyhow::Result` lets the closure surface
110/// filter-parse failures from the new directive string; the
111/// [`LogLevelApply`] says whether the level was installed at all.
112pub type LogLevelSetter =
113    Box<dyn Fn(LogLevel) -> anyhow::Result<LogLevelApply> + Send + Sync + 'static>;
114
115// ---------------------------------------------------------------------------
116// Reloadable-section trait + section impls
117// ---------------------------------------------------------------------------
118
119/// One reloadable config section.
120///
121/// The three phases (`resolve` → `fallible_commit` → `infallible_swap`)
122/// are driven in lockstep by [`RuntimeReloadState::reload`]: phase N runs
123/// for *every* section before phase N+1 starts. That ordering is what
124/// gives the all-or-nothing contract — a section that fails to resolve
125/// aborts the reload before any other section's commit runs.
126///
127/// Each impl owns a `Mutex<Option<Resolved>>` buffer cell. `resolve`
128/// fills it; `fallible_commit` reads it; `infallible_swap` drains it.
129/// `clear_buffer` empties the cell at the start of every reload to
130/// recover from a panic-mid-reload that left a stale value behind. The
131/// associated `Resolved` type stays internal to each impl, so the
132/// trait stays `dyn`-safe.
133pub(crate) trait ReloadableSection: Send + Sync {
134    /// Stable identifier for tracing event keys. Returning `&'static str`
135    /// keeps it cheap and non-allocating in the hot path.
136    fn name(&self) -> &'static str;
137
138    /// Drop any value lingering in the buffer cell.
139    ///
140    /// Called once per `reload()` before phase 1 to keep the cell
141    /// invariant ("populated only between `resolve` and `infallible_swap`")
142    /// robust across previous panic-mid-reload paths. A poisoned mutex
143    /// is recovered in place — losing the previous (now stale) value
144    /// is the desired outcome.
145    fn clear_buffer(&self);
146
147    /// Phase 1: re-resolve from file, store the result in the section's
148    /// buffer cell. Problems are pushed to the shared `bag` rather than
149    /// returned as a `Result`, so every section's resolve runs before the
150    /// aggregated bag is collapsed in `reload()`. Implementations must
151    /// always populate the buffer (with a sentinel/default value if
152    /// validation failed) — the buffer is only read downstream when the
153    /// bag is empty, so a placeholder there is never observed by the
154    /// commit/swap phases on the failure path.
155    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics);
156
157    /// Phase 2: any commit step that can fail (e.g. swapping the live
158    /// tracing filter). Default: no-op. The order of `fallible_commit`
159    /// across sections is the same registration order as `resolve` and
160    /// `infallible_swap`. **Rollback boundary:** once one section's
161    /// `fallible_commit` returns `Ok`, that side-effect stays applied
162    /// even if a later section's `fallible_commit` fails. The trait makes
163    /// this boundary explicit.
164    fn fallible_commit(&self) -> anyhow::Result<()> {
165        Ok(())
166    }
167
168    /// Phase 3: drain the buffer and apply the change to live state.
169    /// Must not fail — atomic stores, `Arc` swaps, and `ConnectionLimiter::reload`
170    /// are the only operations allowed here.
171    fn infallible_swap(&self);
172}
173
174/// Emit each resolve-time notice a config resolve recorded, one event apiece.
175///
176/// Shared by the two paths that own a subscriber: `commands::run` replays what
177/// `resolve_config` handed back once `init_tracing` has installed one, and
178/// [`RuntimeReloadState::reload`] emits what the reloading sections recorded.
179/// The SIGHUP half is the one only the caller can serve — a reload re-runs the
180/// section resolvers against a daemon whose subscriber has been live for hours,
181/// and only this layer holds it. Draining the shared bag rather than reaching
182/// into one section means a future reloadable section is covered for free.
183///
184/// Severity comes from the notice: a `Warn` marks a value that weakens a
185/// safety property and an operator alerting on `WARN` should see it, while an
186/// `Info` marks a deliberate opt-out working as configured. `field` rides as a
187/// `tracing` field rather than inside the message, so a JSON stream can be
188/// filtered on it.
189///
190/// Two properties an operator has to know about. Delivery is subject to the
191/// active filter: a node on `log_level = "error"`, or on a `RUST_LOG` that
192/// names other targets, receives none of these. And every reload re-resolves
193/// every section, so an unchanged notice repeats on each SIGHUP — the same
194/// best-effort shape [`warn_restart_required_sections`] has.
195pub(crate) fn emit_config_notices(notices: &[ConfigNotice]) {
196    for notice in notices {
197        match notice.level {
198            ConfigNoticeLevel::Warn => {
199                tracing::warn!(field = %notice.field, "{}", notice.message);
200            }
201            ConfigNoticeLevel::Info => {
202                tracing::info!(field = %notice.field, "{}", notice.message);
203            }
204        }
205    }
206}
207
208/// Set a cache-engine slot owned by a `ReloadableSection`, recovering
209/// the inner value on a poisoned mutex. Used by [`RuntimeReloadState::attach_cache`]
210/// to populate both the pinned-hashes and origin-retry sections from a
211/// single attach call.
212///
213/// The poison flag is intentionally *not* cleared. Reachable poison
214/// here implies a panic in some other code path that held the lock —
215/// extremely rare since the lock's only callers are `attach_cache`
216/// (this function) and the section's `infallible_swap`, both of which
217/// are panic-free under workspace lints. The recovery write is
218/// belt-and-braces: it ensures the engine reference is at least stored
219/// (so e.g. the `pinned.engine` slot still receives the new engine if
220/// it had been transiently poisoned), but the next `infallible_swap`
221/// will see `lock()` return `Err` again and emit the section's
222/// `tracing::error!` and skip its swap. This matches the codebase's
223/// established "recover-the-data, log-loudly, do-not-clear-poison"
224/// pattern (see also `attach_limiter` and the trait doc on
225/// `ReloadableSection::infallible_swap`).
226fn attach_engine_to_section(
227    slot: &std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
228    engine: Option<decdn_cache::CacheEngine>,
229    section: &'static str,
230) {
231    match slot.lock() {
232        Ok(mut guard) => *guard = engine,
233        Err(poisoned) => {
234            tracing::error!(
235                section,
236                "runtime reload cache mutex poisoned during attach; recovering inner state"
237            );
238            *poisoned.into_inner() = engine;
239        }
240    }
241}
242
243/// Helper for the buffer-cell pattern. Each section keeps a
244/// `Mutex<Option<Resolved>>`; this expression captures the "drain or log
245/// and skip" idiom without each `infallible_swap` re-implementing it.
246fn drain_or_log<T>(slot: &std::sync::Mutex<Option<T>>, section: &'static str) -> Option<T> {
247    match slot.lock() {
248        Ok(mut g) => g.take(),
249        Err(poisoned) => {
250            tracing::error!(
251                section,
252                "section buffer mutex poisoned during infallible_swap; \
253                 swap skipped (previous value retained on the live path)"
254            );
255            // Drop the (possibly poisoned) value defensively.
256            let _ = poisoned.into_inner().take();
257            None
258        }
259    }
260}
261
262// ----- log_level ----------------------------------------------------------
263
264/// Reloadable observability log level. The full `[observability]`
265/// section isn't reloadable (`metrics_port`, `log_format`, etc. all
266/// need a restart), but the level is. The other sub-fields get a
267/// "requires restart" warning from [`Self::warn_restart_required_changes`]
268/// when their resolved value differs from the one the node started with.
269struct LogLevelSection {
270    cli: ObservabilityArgs,
271    setter: LogLevelSetter,
272    /// Restart-required observability values the node started with.
273    startup: StartupObservability,
274    /// Cached log level last applied. `None` until a reload installs one —
275    /// the live `EnvFilter` at startup may be a `RUST_LOG` directive we
276    /// cannot reflect back into a `LogLevel`, so a `None` forces the setter
277    /// to run and only a cached level suppresses no-op writes. While
278    /// `RUST_LOG` drives the filter the setter keeps it
279    /// ([`LogLevelApply::KeptRustLog`]) and this stays `None`, so every
280    /// reload asks the setter again.
281    current: std::sync::Mutex<Option<LogLevel>>,
282    buf: std::sync::Mutex<Option<ResolvedObservability>>,
283    /// Set by `fallible_commit` so `infallible_swap` knows whether the
284    /// setter actually ran (we only update the cached `current` on a
285    /// real apply, not on a skip — keeps "first reload always applies"
286    /// honest).
287    swap_applied: std::sync::Mutex<bool>,
288}
289
290impl ReloadableSection for LogLevelSection {
291    fn name(&self) -> &'static str {
292        "log_level"
293    }
294    fn clear_buffer(&self) {
295        if let Ok(mut g) = self.buf.lock() {
296            *g = None;
297        }
298        if let Ok(mut g) = self.swap_applied.lock() {
299            *g = false;
300        }
301    }
302    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
303        let resolved = resolve_observability_into(&self.cli, file.observability.as_ref(), bag);
304        if let Ok(mut g) = self.buf.lock() {
305            *g = Some(resolved);
306        }
307    }
308    fn fallible_commit(&self) -> anyhow::Result<()> {
309        // Read (don't drain) the buffer — `infallible_swap` still needs
310        // the value to update the cached `current` and emit the
311        // tracing line.
312        let new_level = {
313            let g = self
314                .buf
315                .lock()
316                .map_err(|_| anyhow::anyhow!("log-level buffer mutex poisoned"))?;
317            match g.as_ref() {
318                Some(r) => r.log_level,
319                // `resolve` populates the buffer on success; if it's
320                // empty here something upstream skipped the section,
321                // which is a bug. Fail loud rather than silently no-op.
322                None => return Err(anyhow::anyhow!("log-level buffer empty in fallible_commit")),
323            }
324        };
325        // Lock the cached level for the duration of the change so a
326        // racing reload can't observe a half-updated state.
327        let mut current = self
328            .current
329            .lock()
330            .map_err(|_| anyhow::anyhow!("log-level mutex poisoned"))?;
331        // First reload (current is `None`) always applies — see the
332        // struct's `current` doc.
333        let log_level_changed = match *current {
334            None => true,
335            Some(prev) => prev != new_level,
336        };
337        if !log_level_changed {
338            return Ok(());
339        }
340        match (self.setter)(new_level) {
341            Err(err) => {
342                tracing::warn!(error = %err, ?new_level, "failed to apply new log level; previous level retained");
343                return Err(err);
344            }
345            // `RUST_LOG` still drives the live filter, so no file level is
346            // running: leave `current` at `None` and the snapshot reports none.
347            Ok(LogLevelApply::KeptRustLog) => return Ok(()),
348            Ok(LogLevelApply::Installed) => {}
349        }
350        {
351            *current = Some(new_level);
352            if let Ok(mut g) = self.swap_applied.lock() {
353                *g = true;
354            }
355        }
356        Ok(())
357    }
358    fn infallible_swap(&self) {
359        let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
360            return;
361        };
362        let log_level_changed = self.swap_applied.lock().is_ok_and(|g| *g);
363        tracing::info!(
364            section = self.name(),
365            log_level = %resolved.log_level,
366            log_level_changed,
367            "config reload section applied"
368        );
369    }
370}
371
372impl LogLevelSection {
373    /// On each reload, warn for each restart-required observability field whose
374    /// resolved value (CLI/env > file > default) differs from the startup value.
375    /// A field that stays changed warns again on every reload. Reads
376    /// the buffer `resolve` filled, so it runs after the commits and before the
377    /// swap drains it.
378    /// Names fields only: `otlp_endpoint` can carry credentials.
379    fn warn_restart_required_changes(&self) {
380        let Ok(guard) = self.buf.lock() else {
381            return;
382        };
383        let Some(resolved) = guard.as_ref() else {
384            return;
385        };
386        for field in observability_restart_required_changes(&self.startup, resolved) {
387            tracing::warn!(
388                field,
389                "config reload: ignoring change to observability.{field} (requires restart)"
390            );
391        }
392    }
393}
394
395/// The restart-required `[observability]` values a node started with.
396#[derive(Debug)]
397struct StartupObservability {
398    log_format: LogFormat,
399    metrics_port: u16,
400    metrics_bind: std::net::IpAddr,
401    admin_port: Option<u16>,
402    otlp_endpoint: Option<String>,
403}
404
405impl StartupObservability {
406    fn from_resolved(o: &ResolvedObservability) -> Self {
407        Self {
408            log_format: o.log_format,
409            metrics_port: o.metrics_port,
410            metrics_bind: o.metrics_bind,
411            admin_port: o.admin_port,
412            otlp_endpoint: o.otlp_endpoint.clone(),
413        }
414    }
415}
416
417/// Names of the restart-required observability fields whose reloaded value
418/// differs from startup. `log_level` is hot-reloadable and never listed.
419/// Exhaustively destructured so a new field must be classified here.
420fn observability_restart_required_changes(
421    startup: &StartupObservability,
422    reloaded: &ResolvedObservability,
423) -> Vec<&'static str> {
424    let ResolvedObservability {
425        log_level: _, // hot-reloadable
426        log_format,
427        metrics_port,
428        metrics_bind,
429        admin_port,
430        otlp_endpoint,
431    } = reloaded;
432    [
433        ("log_format", startup.log_format != *log_format),
434        ("metrics_port", startup.metrics_port != *metrics_port),
435        ("metrics_bind", startup.metrics_bind != *metrics_bind),
436        ("admin_port", startup.admin_port != *admin_port),
437        ("otlp_endpoint", startup.otlp_endpoint != *otlp_endpoint),
438    ]
439    .into_iter()
440    .filter_map(|(field, changed)| changed.then_some(field))
441    .collect()
442}
443
444// ----- pinned_hashes ------------------------------------------------------
445
446/// Reloadable `cache.pinned_hashes`. The rest of `cache.*`
447/// (`cache_dir`, sizes, origin, `decompress`) is not hot-reloadable.
448struct PinnedHashesSection {
449    /// Optional handle to the live cache engine. `None` in unit tests
450    /// that exercise reload semantics without a real engine; populated
451    /// at runtime via [`RuntimeReloadState::attach_cache`] before the
452    /// SIGHUP select loop runs.
453    engine: std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
454    buf: std::sync::Mutex<Option<decdn_cache::PinnedHashes>>,
455    /// `cache.cache_size_mb` at boot, for the reload-time pin-budget re-check.
456    /// Restart-required, so the boot value stays authoritative.
457    cache_size_mb: u64,
458}
459
460impl ReloadableSection for PinnedHashesSection {
461    fn name(&self) -> &'static str {
462        "pinned_hashes"
463    }
464    fn clear_buffer(&self) {
465        if let Ok(mut g) = self.buf.lock() {
466            *g = None;
467        }
468    }
469    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
470        // Same shape as `resolve_cache_into`: bag-push on parse failure,
471        // empty placeholder so later sections still run. `reload()`'s
472        // early return guarantees the placeholder never reaches swap.
473        let resolved = bag
474            .try_with(
475                "cache.pinned_hashes",
476                parse_pinned_hashes(file.cache.as_ref().and_then(|c| c.pinned_hashes.as_deref()))
477                    .context("invalid cache.pinned_hashes"),
478            )
479            .unwrap_or_else(decdn_cache::PinnedHashes::empty);
480        if let Ok(mut g) = self.buf.lock() {
481            *g = Some(resolved);
482        }
483    }
484    fn infallible_swap(&self) {
485        let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
486            return;
487        };
488        let pinned_count = resolved.len();
489        // The engine slot lock can be poisoned (see
490        // `attach_cache`'s recovery path). On poison we forfeit the
491        // swap rather than panic — operationally indistinguishable
492        // from "cache not yet attached" and the same dedicated tracing
493        // line covers it.
494        let pin_diff = if let Ok(g) = self.engine.lock() {
495            g.as_ref().map(|engine| {
496                let diff = engine.set_pinned(&resolved);
497                // Refresh the origin-held index so a newly-added pin (or a file
498                // dropped into the fs origin) is announced on this reload rather
499                // than only at the next periodic rescan (#1130). Detached so we
500                // honor reload()'s no-await invariant; rescan is idempotent.
501                let engine = engine.clone();
502                let cache_size_mb = self.cache_size_mb;
503                tokio::spawn(async move {
504                    engine.rescan_origins().await;
505                    // Re-check the pin budget here too, not just at boot: a reload
506                    // is the one moment the pin set can grow past the cache ceiling
507                    // on a running node.
508                    super::warn_if_pins_exceed_cache(&engine, cache_size_mb);
509                });
510                diff
511            })
512        } else {
513            tracing::error!(
514                section = self.name(),
515                "cache engine mutex poisoned in infallible_swap; pinning skipped"
516            );
517            None
518        };
519        let pinned_skipped_no_cache_attached = pin_diff.is_none();
520        tracing::info!(
521            section = self.name(),
522            pinned_hashes = pinned_count,
523            pinned_added = pin_diff.map(|d| d.added),
524            pinned_removed = pin_diff.map(|d| d.removed),
525            pinned_skipped_no_cache_attached,
526            cache_attached = pin_diff.is_some(),
527            "config reload section applied"
528        );
529    }
530}
531
532// ----- content ------------------------------------------------------------
533
534/// Reloadable `[content]` section — the ADR 011 local denylist.
535///
536/// Hot-reloadable is the whole point, not a convenience: ADR 011 §One-hour
537/// removal orders sizes this mechanism to the EU TCO one-hour clock, and a
538/// restart-only denylist would put a daemon bounce (dropping every in-flight
539/// paid stream) on the critical path of discharging a legal order.
540///
541/// Unlike the other sections there is no `attach_*` handle to be missing: the
542/// deny-set is constructed before the handler is, and both hold the same `Arc`.
543/// A swap is therefore always effective.
544struct ContentSection {
545    deny: Arc<crate::content_deny::ContentDenylist>,
546    /// Live cache engine, for the hash half of the denylist. Same lifecycle as
547    /// [`PinnedHashesSection::engine`] — attached via
548    /// [`RuntimeReloadState::attach_cache`] before the reload loop runs.
549    ///
550    /// Hashes go to the engine rather than to `deny` because "will this node
551    /// serve/announce/acquire this hash" has four consumers and ADR 011 needs
552    /// one answer for all of them; see `CacheEngine::refuses`. `deny` keeps the
553    /// origin half, which the cache knows nothing about.
554    engine: std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
555    buf: std::sync::Mutex<Option<decdn_common::config::ResolvedContent>>,
556}
557
558impl ReloadableSection for ContentSection {
559    fn name(&self) -> &'static str {
560        "content"
561    }
562    fn clear_buffer(&self) {
563        if let Ok(mut g) = self.buf.lock() {
564            *g = None;
565        }
566    }
567    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
568        // Same shape as the other sections: bag-push on parse failure, empty
569        // placeholder so later sections still run. `reload()`'s early return
570        // guarantees the placeholder never reaches swap — which matters more
571        // here than elsewhere, since swapping an empty placeholder would
572        // silently UN-deny everything the operator had denied.
573        let denied_hashes = bag
574            .try_with(
575                "content.denied_hashes",
576                decdn_common::config::parse_denied_hashes(
577                    file.content
578                        .as_ref()
579                        .and_then(|c| c.denied_hashes.as_deref()),
580                )
581                .context("invalid content.denied_hashes"),
582            )
583            .unwrap_or_default();
584        let denied_origins = bag
585            .try_with(
586                "content.denied_origins",
587                decdn_common::config::parse_denied_origins(
588                    file.content
589                        .as_ref()
590                        .and_then(|c| c.denied_origins.as_deref()),
591                )
592                .context("invalid content.denied_origins"),
593            )
594            .unwrap_or_default();
595        if let Ok(mut g) = self.buf.lock() {
596            *g = Some(decdn_common::config::ResolvedContent {
597                denied_hashes,
598                denied_origins,
599            });
600        }
601    }
602    fn infallible_swap(&self) {
603        let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
604            return;
605        };
606        let denied_origins = self.deny.set_local_origins(&resolved);
607        let Ok(engine_guard) = self.engine.lock() else {
608            // Same poison handling as `PinnedHashesSection`: forfeit the swap
609            // rather than panic. Unlike pinning, a forfeited swap here leaves a
610            // takedown undischarged, so it is ERROR and says so.
611            tracing::error!(
612                section = self.name(),
613                "cache engine mutex poisoned; the denied-hash set was NOT updated and any \
614                 takedown in this reload is undischarged"
615            );
616            return;
617        };
618        let hash_diff = engine_guard
619            .as_ref()
620            .map(|engine| engine.set_denied(&resolved.denied_hashes));
621        tracing::info!(
622            section = self.name(),
623            denied_hashes = resolved.denied_hashes.len(),
624            denied_hashes_added = hash_diff.map(|d| d.added),
625            denied_hashes_removed = hash_diff.map(|d| d.removed),
626            denied_hashes_skipped_no_cache_attached = hash_diff.is_none(),
627            denied_origins,
628            "config reload section applied"
629        );
630    }
631}
632
633// ----- security -----------------------------------------------------------
634
635/// Reloadable `[security]` section.
636struct SecuritySection {
637    /// Optional handle to the live `ConnectionLimiter`. Same lifecycle
638    /// rules as `PinnedHashesSection::engine` — populated via
639    /// [`RuntimeReloadState::attach_limiter`] before the select loop.
640    limiter: std::sync::Mutex<Option<Arc<ConnectionLimiter>>>,
641    buf: std::sync::Mutex<Option<ResolvedSecurity>>,
642}
643
644impl ReloadableSection for SecuritySection {
645    fn name(&self) -> &'static str {
646        "security"
647    }
648    fn clear_buffer(&self) {
649        if let Ok(mut g) = self.buf.lock() {
650            *g = None;
651        }
652    }
653    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
654        let resolved = resolve_security_into(file.security.as_ref(), bag);
655        if let Ok(mut g) = self.buf.lock() {
656            *g = Some(resolved);
657        }
658    }
659    fn infallible_swap(&self) {
660        let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
661            return;
662        };
663        let security_attached = if let Ok(g) = self.limiter.lock() {
664            if let Some(lim) = g.as_ref() {
665                lim.reload(&resolved);
666                true
667            } else {
668                false
669            }
670        } else {
671            tracing::error!(
672                section = self.name(),
673                "limiter mutex poisoned in infallible_swap; security swap skipped"
674            );
675            false
676        };
677        tracing::info!(
678            section = self.name(),
679            security_attached,
680            max_concurrent_handlers = resolved.max_concurrent_handlers,
681            per_source_rate_per_sec = resolved.per_source_rate_per_sec,
682            max_tracked_sources = resolved.max_tracked_sources,
683            "config reload section applied"
684        );
685    }
686}
687
688// ----- load shed -----------------------------------------------------------
689
690/// Reloadable `[load_shed]` section: resolves the config block into a
691/// buffer, then swaps the live `LoadShedController`'s policy on
692/// `infallible_swap`.
693struct LoadShedSection {
694    /// Optional handle to the live `LoadShedController`. Same lifecycle
695    /// rules as `SecuritySection::limiter` — populated via
696    /// [`RuntimeReloadState::attach_load_shed`] before the select loop.
697    controller: std::sync::Mutex<Option<Arc<crate::load_shed::LoadShedController>>>,
698    buf: std::sync::Mutex<Option<ResolvedLoadShed>>,
699}
700
701impl ReloadableSection for LoadShedSection {
702    fn name(&self) -> &'static str {
703        "load_shed"
704    }
705    fn clear_buffer(&self) {
706        if let Ok(mut g) = self.buf.lock() {
707            *g = None;
708        }
709    }
710    fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
711        let resolved = resolve_load_shed_into(file.load_shed.as_ref(), bag);
712        if let Ok(mut g) = self.buf.lock() {
713            *g = Some(resolved);
714        }
715    }
716    fn infallible_swap(&self) {
717        let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
718            return;
719        };
720        let load_shed_attached = if let Ok(g) = self.controller.lock() {
721            if let Some(ctrl) = g.as_ref() {
722                ctrl.reload(&resolved);
723                true
724            } else {
725                false
726            }
727        } else {
728            tracing::error!(
729                section = self.name(),
730                "controller mutex poisoned in infallible_swap; load-shed swap skipped"
731            );
732            false
733        };
734        tracing::info!(
735            section = self.name(),
736            load_shed_attached,
737            policy = ?resolved.policy,
738            egress_budget_mbps = resolved.egress_budget_mbps,
739            max_concurrent_serves_high = resolved.max_concurrent_serves_high,
740            max_concurrent_serves_low = resolved.max_concurrent_serves_low,
741            per_client_serve_cap = resolved.per_client_serve_cap,
742            "config reload section applied"
743        );
744    }
745}
746
747// ---------------------------------------------------------------------------
748// RuntimeReloadState
749// ---------------------------------------------------------------------------
750
751/// Shared, mutable handles for the fields the runtime can hot-reload.
752///
753/// Held inside an `Arc` so the SIGHUP handler and the `ProbeHandler` can
754/// both observe updates. Each section is registered as both a concrete
755/// `Arc<SectionStruct>` (so `attach_*` and accessors can reach into its
756/// state) and as `Arc<dyn ReloadableSection>` (so `reload()` drives the
757/// three-phase iteration without a central match arm per section).
758///
759/// Adding a new reloadable knob: implement `ReloadableSection` for a
760/// new struct, `Arc` it once at construction, and append to the
761/// `sections` vec — no central touch of `RuntimeReloadState::reload`.
762pub struct RuntimeReloadState {
763    log_level: Arc<LogLevelSection>,
764    pinned: Arc<PinnedHashesSection>,
765    security: Arc<SecuritySection>,
766    content: Arc<ContentSection>,
767    load_shed: Arc<LoadShedSection>,
768    /// Iteration order for the three-phase reload: `log_level`,
769    /// `pinned_hashes`, `security`, `content`, then `load_shed`. The order
770    /// matters for reproducibility (operator-visible tracing event order)
771    /// and for the rollback-boundary contract documented on the trait —
772    /// moving `log_level` earlier or later would change which pre-`log_level`
773    /// commits survive a setter failure.
774    sections: Vec<Arc<dyn ReloadableSection>>,
775    /// Serialises concurrent reloads. A SIGHUP racing an `admin_v1_reload`
776    /// (both call [`Self::reload`]) waits here so the two-phase commit of
777    /// one reload never interleaves with another's. Guards no data — the
778    /// per-section buffer cells own the in-flight state — so a poisoned
779    /// lock is recovered in place rather than surfaced: a prior
780    /// panic-mid-reload must not wedge every future reload.
781    reload_lock: std::sync::Mutex<()>,
782    /// Counts failed reloads into `decdn_config_reload_failures_total`. Set
783    /// once by [`Self::attach_metrics`]; a reload before that is not counted.
784    metrics: std::sync::OnceLock<Arc<crate::metrics::Metrics>>,
785}
786
787impl std::fmt::Debug for RuntimeReloadState {
788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        f.debug_struct("RuntimeReloadState")
790            .field("current_log_level", &self.log_level.current)
791            .finish_non_exhaustive()
792    }
793}
794
795impl RuntimeReloadState {
796    /// Build a reload state from the resolved-at-startup values plus the
797    /// closure that mutates the live tracing filter.
798    ///
799    /// `current_log_level` is intentionally seeded to `None` rather than
800    /// to `initial.observability.log_level`: at startup the live
801    /// `EnvFilter` may have been built from `RUST_LOG` (see
802    /// `commands::run`'s `try_from_default_env`), in which case the
803    /// resolved config value is *not* what's running. Forcing the first
804    /// reload to apply unconditionally is simpler and more correct than
805    /// trying to reflect the env-filter directive back into a `LogLevel`.
806    pub fn new(
807        observability_cli: ObservabilityArgs,
808        initial: &decdn_common::config::ResolvedConfig,
809        log_level_setter: LogLevelSetter,
810    ) -> Self {
811        let log_level = Arc::new(LogLevelSection {
812            cli: observability_cli,
813            setter: log_level_setter,
814            startup: StartupObservability::from_resolved(&initial.observability),
815            current: std::sync::Mutex::new(None),
816            buf: std::sync::Mutex::new(None),
817            swap_applied: std::sync::Mutex::new(false),
818        });
819        let pinned = Arc::new(PinnedHashesSection {
820            engine: std::sync::Mutex::new(None),
821            buf: std::sync::Mutex::new(None),
822            cache_size_mb: initial.cache.cache_size_mb,
823        });
824        let security = Arc::new(SecuritySection {
825            limiter: std::sync::Mutex::new(None),
826            buf: std::sync::Mutex::new(None),
827        });
828        let content = Arc::new(ContentSection {
829            deny: Arc::new(crate::content_deny::ContentDenylist::new(&initial.content)),
830            engine: std::sync::Mutex::new(None),
831            buf: std::sync::Mutex::new(None),
832        });
833        let load_shed = Arc::new(LoadShedSection {
834            controller: std::sync::Mutex::new(None),
835            buf: std::sync::Mutex::new(None),
836        });
837        // Registration order sets the commit order: log_level,
838        // pinned_hashes, security, content, load_shed. The order matters
839        // for reproducibility (operator-visible tracing event order) and
840        // for the rollback-boundary contract documented on the trait —
841        // moving log_level later or earlier would change which
842        // pre-log_level commits survive a setter failure.
843        let sections: Vec<Arc<dyn ReloadableSection>> = vec![
844            Arc::clone(&log_level) as _,
845            Arc::clone(&pinned) as _,
846            Arc::clone(&security) as _,
847            Arc::clone(&content) as _,
848            Arc::clone(&load_shed) as _,
849        ];
850        Self {
851            log_level,
852            pinned,
853            security,
854            content,
855            load_shed,
856            sections,
857            reload_lock: std::sync::Mutex::new(()),
858            metrics: std::sync::OnceLock::new(),
859        }
860    }
861
862    /// Attach the node metrics so failed reloads are counted. Call once, before
863    /// the SIGHUP select loop runs; a second call keeps the first handle.
864    pub fn attach_metrics(&self, metrics: Arc<crate::metrics::Metrics>) {
865        let _already_attached = self.metrics.set(metrics);
866    }
867
868    /// Attach the live cache engine after it's been built. Must be called
869    /// before the SIGHUP select loop runs — see `runtime::run`.
870    /// Detaching is permitted (pass `None`) but the runtime never needs
871    /// to: the engine outlives the reload state by construction.
872    ///
873    /// **Poison handling.** A poisoned mutex is recovered by replacing
874    /// the inner value via `PoisonError::into_inner()`, but the poison
875    /// flag is *not* cleared — `Mutex::lock()` will return `Err` again
876    /// the next time anyone tries to take the guard. The first
877    /// subsequent `reload()` will therefore skip the pinned-set swap
878    /// for that section (see `PinnedHashesSection::infallible_swap`).
879    /// Recovery here ensures the new engine is at least stored for the
880    /// (non-reload-driven) live path.
881    pub fn attach_cache(&self, engine: Option<decdn_cache::CacheEngine>) {
882        attach_engine_to_section(&self.content.engine, engine.clone(), "content");
883        attach_engine_to_section(&self.pinned.engine, engine, "pinned_hashes");
884    }
885
886    /// The live ADR 011 deny-set, for wiring into the client handler.
887    ///
888    /// Deliberately a getter rather than an `attach_*`: the deny-set is owned
889    /// here and shared out, so there is no window in which the handler holds a
890    /// deny-set the reload path cannot reach. The `attach_*` pattern exists for
891    /// handles built *after* this state; this one is built *with* it.
892    pub fn content_denylist(&self) -> Arc<crate::content_deny::ContentDenylist> {
893        Arc::clone(&self.content.deny)
894    }
895
896    /// Attach the live `ConnectionLimiter` after it's been built. Same
897    /// shape as [`Self::attach_cache`]: must be called before the SIGHUP
898    /// select loop, supports `None` for tests, recovers from a poisoned
899    /// mutex by replacing the inner state.
900    pub fn attach_limiter(&self, limiter: Option<Arc<ConnectionLimiter>>) {
901        match self.security.limiter.lock() {
902            Ok(mut guard) => *guard = limiter,
903            Err(poisoned) => {
904                tracing::error!(
905                    "runtime reload limiter mutex poisoned during attach; recovering inner state"
906                );
907                *poisoned.into_inner() = limiter;
908            }
909        }
910    }
911
912    /// Attach the live `LoadShedController` after it's been built. Same
913    /// shape as [`Self::attach_limiter`]: must be called before the SIGHUP
914    /// select loop, supports `None` for tests, recovers from a poisoned
915    /// mutex by replacing the inner state.
916    pub fn attach_load_shed(&self, controller: Option<Arc<crate::load_shed::LoadShedController>>) {
917        match self.load_shed.controller.lock() {
918            Ok(mut guard) => *guard = controller,
919            Err(poisoned) => {
920                tracing::error!(
921                    "runtime reload load-shed controller mutex poisoned during attach; \
922                     recovering inner state"
923                );
924                *poisoned.into_inner() = controller;
925            }
926        }
927    }
928
929    /// Build a state for tests outside `runtime::reload::tests` that need
930    /// to drive `reload()` end-to-end (e.g. `admin::tests` exercising
931    /// `admin_v1_reload`). Centralised here rather than duplicated per
932    /// test module so the boilerplate `ResolvedConfig` for which fields
933    /// reload reads stays in one place — drift between two copies would
934    /// give different test surfaces for the same code path.
935    #[cfg(test)]
936    // One flat `ResolvedConfig` literal enumerating every reload-relevant field;
937    // it crossed 100 lines once both #651 (`discovery`) and #831 (node-pull cache
938    // knobs) added fields. Splitting the single struct literal across helpers
939    // would obscure which fields the reload path reads, not clarify it.
940    #[allow(clippy::too_many_lines)]
941    pub(crate) fn for_test_with_setter(
942        level: decdn_common::cli::common::LogLevel,
943        log_level_setter: LogLevelSetter,
944    ) -> Self {
945        use std::path::PathBuf;
946
947        use decdn_common::config::{
948            ResolvedBlockchain, ResolvedCache, ResolvedConfig, ResolvedIdentity, ResolvedNetwork,
949            ResolvedObservability, ResolvedPayment, ResolvedSecurity,
950        };
951
952        let cfg = ResolvedConfig {
953            identity: ResolvedIdentity {
954                data_dir: PathBuf::from("/tmp/decdn-test"),
955                region: None,
956            },
957            network: ResolvedNetwork {
958                bind_port: 4433,
959                relay_urls: Vec::new(),
960                discovery: decdn_common::config::ResolvedDiscovery::default(),
961            },
962            blockchain: ResolvedBlockchain {
963                origin_assignment_address: None,
964                origin_directory_positive_ttl_sec:
965                    decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC,
966                origin_directory_negative_ttl_sec:
967                    decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC,
968                origin_directory_cache_capacity:
969                    decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY,
970                publisher_registry_address: None,
971                rpc_url: "http://localhost:8545".into(),
972                eth_keystore: PathBuf::from("/tmp/keystore.json"),
973                keystore_password_file: None,
974                payment_pool_address: "0x0000000000000000000000000000000000000001".into(),
975                capacity_bond_address: "0x0000000000000000000000000000000000000002".into(),
976                rpc_watchdog_interval_sec: 30,
977                event_poll_interval_ms: 7000,
978                get_logs_max_block_span: decdn_common::config::DEFAULT_GET_LOGS_MAX_BLOCK_SPAN,
979                fee_shares_poll_interval_sec: 3600,
980                redeem_threshold_micro_usdc: 1_000_000,
981                redeem_max_vouchers_per_tx: 300,
982                redeem_interval_secs: 300,
983                buyer_working_deposit_micro_usdc: 10_000_000,
984                buyer_max_approve: true,
985                pool_min_remaining_deposit_micro_usdc: 1_000_000,
986                pool_floor_signer_live_windows: 8,
987                slash_judge_address: "0x0000000000000000000000000000000000000003".to_string(),
988                content_blacklist_address: None,
989                content_blacklist_poll_interval_sec: 600,
990                chain_staleness_grace_sec: 1800,
991                chain_id: decdn_common::config::DEFAULT_CHAIN_ID,
992            },
993            cache: ResolvedCache {
994                cache_dir: PathBuf::from("/tmp/cache"),
995                cache_size_mb: 1024,
996                disk_headroom_mb: 8192,
997                max_blob_size_mb: 128,
998                max_rate_per_mb: 0,
999                origins: Vec::new(),
1000                pinned_hashes: decdn_cache::PinnedHashes::empty(),
1001                origin_retry: decdn_cache::RetryPolicy::default(),
1002                circuit_breaker: decdn_cache::CircuitBreakerPolicy::default(),
1003                user_agent: decdn_cache::DEFAULT_USER_AGENT.to_string(),
1004                gc_interval_sec: 0,
1005                fs_rescan_interval_sec: 0,
1006                origin_probe_ttl_sec: decdn_common::config::DEFAULT_ORIGIN_PROBE_TTL_SEC,
1007                origin_probe_negative_ttl_sec:
1008                    decdn_common::config::DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC,
1009                origin_probe_fault_ttl_sec:
1010                    decdn_common::config::DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC,
1011                origin_probe_timeout_ms: decdn_common::config::DEFAULT_ORIGIN_PROBE_TIMEOUT_MS,
1012                origin_probe_memo_capacity:
1013                    decdn_common::config::DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
1014                eviction_high_water_pct: 90,
1015                eviction_target_pct: 80,
1016                eviction_per_sweep_budget: 16,
1017                eviction_tick_secs: 1,
1018                max_probe_holds: decdn_common::config::DEFAULT_MAX_PROBE_HOLDS,
1019                stake_lane_reserved_holds: decdn_common::config::DEFAULT_STAKE_LANE_RESERVED_HOLDS,
1020                node_to_node_pull_through_enabled: false,
1021                relay_foreign_namespaces: decdn_common::config::DEFAULT_RELAY_FOREIGN_NAMESPACES,
1022                node_pull_probe_fanout: decdn_common::config::DEFAULT_NODE_PULL_PROBE_FANOUT,
1023                node_pull_timeout_sec: decdn_common::config::DEFAULT_NODE_PULL_TIMEOUT_SEC,
1024                node_pull_stall_window_sec:
1025                    decdn_common::config::DEFAULT_NODE_PULL_STALL_WINDOW_SEC,
1026                node_pull_min_throughput_bps:
1027                    decdn_common::config::DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS,
1028                eviction_policy: decdn_common::config::DEFAULT_EVICTION_POLICY.to_string(),
1029                admission_policy: decdn_common::config::DEFAULT_ADMISSION_POLICY.to_string(),
1030                tinylfu: decdn_common::config::ResolvedTinyLfu {
1031                    sketch_bytes: decdn_common::config::DEFAULT_TINYLFU_SKETCH_BYTES,
1032                    promotion_threshold: decdn_common::config::DEFAULT_TINYLFU_PROMOTION_THRESHOLD,
1033                    probation_target_pct:
1034                        decdn_common::config::DEFAULT_TINYLFU_PROBATION_TARGET_PCT,
1035                    aging_halflife_sec: decdn_common::config::DEFAULT_TINYLFU_AGING_HALFLIFE_SEC,
1036                },
1037                serve_economics: decdn_common::config::ResolvedServeEconomics {
1038                    policy: decdn_common::config::DEFAULT_SERVE_ECONOMICS_POLICY.to_string(),
1039                    discount_bps: decdn_common::config::DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS,
1040                    n_max: decdn_common::config::DEFAULT_SERVE_ECONOMICS_N_MAX,
1041                    warming_budget: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET,
1042                    warming_refill: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_REFILL,
1043                },
1044            },
1045            // Placeholder rate — `payment.*` is restart-required.
1046            payment: ResolvedPayment {
1047                rate_per_mb: 1,
1048                credit_max: decdn_common::config::DEFAULT_CREDIT_MAX,
1049                frame_target_bytes: decdn_common::config::DEFAULT_FRAME_TARGET_BYTES,
1050                credit_ramp_divisor: decdn_common::config::DEFAULT_CREDIT_RAMP_DIVISOR,
1051                voucher_commit_interval_ms:
1052                    decdn_common::config::DEFAULT_VOUCHER_COMMIT_INTERVAL_MS,
1053            },
1054            observability: ResolvedObservability {
1055                log_level: level,
1056                log_format: decdn_common::cli::common::LogFormat::Pretty,
1057                metrics_port: 9090,
1058                metrics_bind: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
1059                admin_port: Some(9191),
1060                otlp_endpoint: None,
1061            },
1062            security: ResolvedSecurity {
1063                max_concurrent_handlers: 256,
1064                per_source_rate_per_sec: 100.0,
1065                per_source_burst: 200,
1066                max_tracked_sources: 4096,
1067            },
1068            load_shed: decdn_common::config::ResolvedLoadShed::default(),
1069            dht: decdn_common::config::ResolvedDht::default(),
1070            probe: decdn_common::config::ResolvedProbe::default(),
1071            receipts: decdn_common::config::ResolvedReceipts::default(),
1072            content: decdn_common::config::ResolvedContent::default(),
1073        };
1074        Self::new(
1075            decdn_common::cli::run::ObservabilityArgs {
1076                log_level: None,
1077                log_format: None,
1078                metrics_port: None,
1079                metrics_bind: None,
1080                admin_port: None,
1081                otlp_endpoint: None,
1082            },
1083            &cfg,
1084            log_level_setter,
1085        )
1086    }
1087
1088    /// Snapshot the post-reload values an operator wants to confirm: the
1089    /// most recently applied log level.
1090    ///
1091    /// `log_level` is `None` until the first successful reload — the
1092    /// startup `EnvFilter` may have been built from `RUST_LOG` rather
1093    /// than the resolved config (see [`Self::new`]'s docs), so reporting
1094    /// the resolved-at-startup level there would be misleading. After
1095    /// any successful reload the field tracks what the setter actually
1096    /// applied.
1097    ///
1098    /// A poisoned `current_log_level` mutex collapses to `None` rather
1099    /// than propagating: the caller is `admin_v1_reload`, which has
1100    /// already received an `Ok(())` from `reload()`; the snapshot is
1101    /// best-effort metadata.
1102    pub fn current(&self) -> ReloadSnapshot {
1103        let log_level = self.log_level.current.lock().ok().and_then(|guard| *guard);
1104        ReloadSnapshot { log_level }
1105    }
1106
1107    /// Re-read the config file at `path` and apply changes to reloadable
1108    /// fields. Triggered by SIGHUP (see `runtime::run`'s select loop). On
1109    /// parse error the previous values are retained and the error is
1110    /// logged; the caller (the SIGHUP arm of the select) discards the
1111    /// returned error so a malformed reload never propagates and stops
1112    /// the node.
1113    ///
1114    /// Three phases, each driven over the registered `sections` in registration
1115    /// order:
1116    ///
1117    /// 1. **Resolve.** Each section re-derives its `Resolved` value from
1118    ///    `file` (and any CLI overrides held on the section struct) and
1119    ///    stashes it in its buffer cell. Any failure aborts the reload
1120    ///    before any side-effect runs — the all-or-nothing contract.
1121    /// 2. **Fallible commit.** The log-level filter swap is the only
1122    ///    currently-fallible commit. **Rollback boundary:** commits
1123    ///    that already succeeded stay applied even if a later section's
1124    ///    `fallible_commit` fails. The trait makes this boundary
1125    ///    explicit.
1126    /// 3. **Infallible swap.** Atomic stores, `Arc` swaps, and
1127    ///    `ConnectionLimiter::reload`. Each section also emits a
1128    ///    `config reload section applied` tracing event keyed by
1129    ///    `section = name()`.
1130    ///
1131    /// A single "requires restart" info line is emitted for each
1132    /// non-reloadable section that carries a non-reloadable field in the
1133    /// new file (see `warn_restart_required_sections`) so an operator who
1134    /// edited one of those fields sees "you changed X but it needs a
1135    /// restart". It fires on presence of the field, not on a change to it
1136    /// (no diff against the previous file) — best-effort operator
1137    /// guidance, not a correctness gate.
1138    ///
1139    /// A failed reload counts into `decdn_config_reload_failures_total` once
1140    /// [`Self::attach_metrics`] has run.
1141    // `async` is preserved even though no body is currently `.await`ed:
1142    // the runtime select loop awaits this future inside `tokio::select!`
1143    // (see `runtime::run`), so the signature is part of the contract
1144    // with the caller. A future revision adding `tokio::fs::read_to_string`
1145    // for the config file would also need it.
1146    #[allow(clippy::unused_async)]
1147    pub async fn reload(&self, path: &Path) -> anyhow::Result<()> {
1148        let result = self.reload_sections(path);
1149        if result.is_err()
1150            && let Some(metrics) = self.metrics.get()
1151        {
1152            metrics.config_reload_failure();
1153        }
1154        result
1155    }
1156
1157    /// The body of [`Self::reload`]: load, resolve, validate and commit every
1158    /// section.
1159    #[allow(clippy::cognitive_complexity)] // A few short phase loops; reads better as one unit than split apart.
1160    fn reload_sections(&self, path: &Path) -> anyhow::Result<()> {
1161        let file = match load_file_config(Some(path)) {
1162            Ok(f) => f,
1163            Err(err) => {
1164                tracing::warn!(error = %err, path = %path.display(), "config reload aborted (file load failed); previous values retained for every section");
1165                return Err(err);
1166            }
1167        };
1168
1169        // Serialise concurrent reloads before *any* shared-state mutation.
1170        // `reload()` has no `.await` points, so on a multi-threaded runtime
1171        // two callers (a SIGHUP racing an `admin_v1_reload`) can execute in
1172        // true parallel; the per-section buffer cells are cleared in phase 1
1173        // and drained in phase 3, so the guard must cover clear+resolve
1174        // through commit+swap — otherwise a second reload could clear/
1175        // overwrite the first's freshly-resolved buffers mid-flight and make
1176        // it swap a mixed set. Only the file load above (no shared state)
1177        // stays outside. The lock guards no data itself, so a poisoned lock
1178        // is recovered in place: a prior panic-mid-reload must not wedge
1179        // every future reload. Recovery is logged loudly (this file's
1180        // "recover-the-data, log-loudly, do-not-clear-poison" pattern — see
1181        // `attach_engine_to_section` / `drain_or_log`): a poisoned
1182        // `reload_lock` is the sole surviving evidence that a previous reload
1183        // panicked mid-commit, and swallowing it silently would erase it.
1184        let _reload_guard = match self.reload_lock.lock() {
1185            Ok(guard) => guard,
1186            Err(poisoned) => {
1187                tracing::error!(
1188                    "runtime reload_lock poisoned by a prior panic-mid-reload; \
1189                     recovering and proceeding (an earlier reload may have \
1190                     partially applied before panicking)"
1191                );
1192                poisoned.into_inner()
1193            }
1194        };
1195
1196        // Clear every section's buffer up-front so a panic-mid-reload
1197        // from a previous attempt can't leave stale `Resolved` data
1198        // behind. Each `clear_buffer` is infallible (poison-recovering).
1199        for section in &self.sections {
1200            section.clear_buffer();
1201        }
1202
1203        // Phase 1: resolve every section into one shared bag, then
1204        // collapse it once. Non-empty bag → return early, no side-effects
1205        // committed (the all-or-nothing contract).
1206        let mut bag = ConfigDiagnostics::new();
1207        for section in &self.sections {
1208            section.resolve(&file, &mut bag);
1209        }
1210        // Drain before `into_result` consumes the bag. Emitting waits until
1211        // every gate that can abort the reload has passed (below), because an
1212        // aborted reload retains the previous values and its notices would
1213        // describe a config the node is not running.
1214        let notices = bag.take_notices();
1215        let problem_count = bag.problem_count();
1216        if let Err(err) = bag.into_result() {
1217            tracing::warn!(
1218                error = %err,
1219                problem_count,
1220                "config reload aborted; entire reload rolled back \
1221                 (all-or-nothing) — all reloadable sections retained at \
1222                 their previous values",
1223            );
1224            return Err(err);
1225        }
1226
1227        // Phase 2: fallible commits. The log-level section is the only
1228        // one that can fail here today; future sections may add more.
1229        // Rollback boundary: a section that already returned `Ok`
1230        // stays applied even if a later section fails. Surface the
1231        // first error and let the caller log/return.
1232        for section in &self.sections {
1233            if let Err(err) = section.fallible_commit() {
1234                // Sections that already committed in this loop stay
1235                // applied — that's the rollback boundary. We do *not*
1236                // run any infallible_swap to avoid driving sections
1237                // partway through a phase, so a setter failure returns
1238                // before any atomic swap (e.g. the rate swap) runs.
1239                tracing::warn!(
1240                    error = %err,
1241                    section = section.name(),
1242                    "config reload fallible_commit failed; later sections skipped"
1243                );
1244                return Err(err);
1245            }
1246        }
1247
1248        // Past every gate that can abort: phase 1 collapsed clean, phase 2
1249        // committed, and phase 3 below cannot fail. A notice emitted earlier
1250        // would describe values a `fallible_commit` error left unapplied —
1251        // that path returns before any `infallible_swap`, which is where the
1252        // notice-bearing sections put their values.
1253        emit_config_notices(&notices);
1254        // The same holds for the "requires restart" notices: one for each
1255        // non-reloadable section the file carries, and one for each
1256        // restart-required observability field whose resolved value differs
1257        // from startup. `warn_restart_required_changes` reads the observability
1258        // buffer, so it runs before phase 3 drains it.
1259        warn_restart_required_sections(&file);
1260        self.log_level.warn_restart_required_changes();
1261
1262        // Phase 3: infallible swaps. None can fail.
1263        for section in &self.sections {
1264            section.infallible_swap();
1265        }
1266
1267        // The single completion marker for the whole reload. The
1268        // per-section `config reload section applied` events carry
1269        // the structured fields; this one says every section landed.
1270        tracing::info!("config reload applied");
1271        Ok(())
1272    }
1273}
1274
1275/// Emit a single info line naming a non-reloadable field group.
1276fn warn_ignored(field: &'static str) {
1277    tracing::info!(
1278        field,
1279        "config reload: ignoring change to {field} (requires restart)"
1280    );
1281}
1282
1283/// Emit a "requires restart" notice for each non-reloadable field the
1284/// operator can't hot-apply. Fully non-reloadable sections (including
1285/// `payment`) warn whenever they are *present*; `cache` warns only when it
1286/// sets a field *outside* its reloadable subset, so the common
1287/// `cache.pinned_hashes`-only reload stays quiet. `security` is fully
1288/// reloadable and never warns. `observability` is not handled here: its
1289/// resolved values are diffed against startup by
1290/// [`LogLevelSection::warn_restart_required_changes`]. Best-effort operator
1291/// guidance, not a correctness gate — this does not diff against the previous
1292/// file, so a present-but-unchanged non-reloadable field still warns on every
1293/// reload.
1294fn warn_restart_required_sections(file: &decdn_common::config::FileConfig) {
1295    if file.identity.is_some() {
1296        warn_ignored("identity.* (data_dir, region)");
1297    }
1298    if file.network.is_some() {
1299        warn_ignored("network.* (bind_port, relay_urls, relay_url, discovery)");
1300    }
1301    if file.blockchain.is_some() {
1302        warn_ignored(
1303            "blockchain.* (rpc_url, eth_keystore, contract addresses, \
1304             pool_floor_signer_live_windows)",
1305        );
1306    }
1307    if file
1308        .cache
1309        .as_ref()
1310        .is_some_and(cache_has_restart_required_field)
1311    {
1312        warn_ignored("cache.* (cache_dir, sizes, origin, decompress, max_probe_holds)");
1313    }
1314    if file.payment.is_some() {
1315        // The whole `[payment]` section is restart-required. `rate_per_mb` is
1316        // the served price — an economic commitment on the wire — so it takes
1317        // effect only at startup; reprice by draining and restarting. The other
1318        // serve knobs are read once at handler construction.
1319        warn_ignored(
1320            "payment.* (rate_per_mb, credit_max, \
1321             credit_ramp_divisor, frame_target_bytes, voucher_commit_interval_ms)",
1322        );
1323    }
1324    if file.dht.is_some() {
1325        // `dht.*` (rate-limit caps) is not currently hot-reloadable — the
1326        // limiter is constructed once at startup.
1327        // Reloadability would follow the dispatch-limiter pattern
1328        // (`security.*`) once the limiter grows an ArcSwap on its inner
1329        // state.
1330        warn_ignored("dht.* (rate-limit)");
1331    }
1332    if file.probe.is_some() {
1333        // `probe.rate_limit` is read once at startup; changing it requires
1334        // a restart (same footing as `dht.*`).
1335        warn_ignored("probe.* (rate_limit)");
1336    }
1337    if file.receipts.is_some() {
1338        // `receipts.*` (rotation cap, retained backups) is read once when
1339        // `JsonlReceiptLog` is opened at bring-up; changing it requires a
1340        // restart.
1341        warn_ignored("receipts.* (max_file_bytes, retained_files)");
1342    }
1343    // `security.*` is fully reloadable — see `RuntimeReloadState::reload`'s
1344    // commit step. Invalid values reject the entire reload via
1345    // `resolve_security` upstream rather than landing here.
1346}
1347
1348/// Whether the file's `[cache]` section sets any field that a restart is
1349/// required to apply — i.e. anything other than the hot-reloadable
1350/// `pinned_hashes` (#276). Destructured exhaustively so that adding a new
1351/// `CacheConfig` field is a compile error here until it's classified
1352/// reloadable-or-not, rather than silently escaping the restart notice.
1353const fn cache_has_restart_required_field(c: &decdn_common::config::types::CacheConfig) -> bool {
1354    let decdn_common::config::types::CacheConfig {
1355        pinned_hashes: _, // the only hot-reloadable cache field
1356        cache_dir,
1357        cache_size_mb,
1358        disk_headroom_mb,
1359        max_blob_size_mb,
1360        max_rate_per_mb,
1361        origin,
1362        origins,
1363        origin_retry,
1364        circuit_breaker,
1365        user_agent,
1366        gc_interval_sec,
1367        fs_rescan_interval_sec,
1368        origin_probe_ttl_sec,
1369        origin_probe_negative_ttl_sec,
1370        origin_probe_fault_ttl_sec,
1371        origin_probe_timeout_ms,
1372        origin_probe_memo_capacity,
1373        eviction_high_water_pct,
1374        eviction_target_pct,
1375        eviction_per_sweep_budget,
1376        eviction_tick_secs,
1377        max_probe_holds,
1378        stake_lane_reserved_holds,
1379        node_to_node_pull_through_enabled,
1380        relay_foreign_namespaces,
1381        node_pull_probe_fanout,
1382        node_pull_timeout_sec,
1383        node_pull_stall_window_sec,
1384        node_pull_min_throughput_bps,
1385        eviction_policy,
1386        admission_policy,
1387        tinylfu,
1388        serve_economics,
1389    } = c;
1390    cache_dir.is_some()
1391        || cache_size_mb.is_some()
1392        // The disk-headroom clamp is read once into the eviction driver's params
1393        // at bring-up (#1930); changing it needs a restart.
1394        || disk_headroom_mb.is_some()
1395        || max_blob_size_mb.is_some()
1396        || max_rate_per_mb.is_some()
1397        || origin.is_some()
1398        || origins.is_some()
1399        || origin_retry.is_some()
1400        || circuit_breaker.is_some()
1401        || user_agent.is_some()
1402        || gc_interval_sec.is_some()
1403        // The rescan *cadence* needs a restart to rebuild the interval timer;
1404        // a reload still re-runs one rescan to pick up newly-added files.
1405        || fs_rescan_interval_sec.is_some()
1406        // Live-origin probe memo (#1130 pt3) is built once at bring-up, so any
1407        // of its knobs changing needs a restart.
1408        || origin_probe_ttl_sec.is_some()
1409        || origin_probe_negative_ttl_sec.is_some()
1410        || origin_probe_fault_ttl_sec.is_some()
1411        || origin_probe_timeout_ms.is_some()
1412        || origin_probe_memo_capacity.is_some()
1413        || eviction_high_water_pct.is_some()
1414        || eviction_target_pct.is_some()
1415        || eviction_per_sweep_budget.is_some()
1416        || eviction_tick_secs.is_some()
1417        || max_probe_holds.is_some()
1418        || stake_lane_reserved_holds.is_some()
1419        || node_to_node_pull_through_enabled.is_some()
1420        || relay_foreign_namespaces.is_some()
1421        || node_pull_probe_fanout.is_some()
1422        || node_pull_timeout_sec.is_some()
1423        || node_pull_stall_window_sec.is_some()
1424        || node_pull_min_throughput_bps.is_some()
1425        // Admission/eviction policy selection and its tuning knobs (ADR 040)
1426        // are wired once at bring-up — the estimator and policy objects are
1427        // constructed in `build_infra` and injected into the engine/driver;
1428        // changing them requires a restart.
1429        || eviction_policy.is_some()
1430        || admission_policy.is_some()
1431        || tinylfu.is_some()
1432        // Refuse-to-serve economics (ADR 041) is wired once at bring-up
1433        // alongside the admission/eviction policy objects; changing it
1434        // requires a restart.
1435        || serve_economics.is_some()
1436}
1437
1438#[cfg(test)]
1439#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1440mod tests;