decdn_node/runtime/mod.rs
1//! Node runtime: owns the iroh endpoint, metrics server, and protocol router.
2
3mod addr_publish;
4pub mod eviction;
5mod reachability;
6pub mod reload;
7
8pub(crate) use reload::emit_config_notices;
9pub use reload::{LogLevelApply, LogLevelSetter, ReloadSnapshot, RuntimeReloadState};
10
11use crate::chain_events::boot_retry::{BOOT_CHAIN_RETRY_BUDGET, BootRetry};
12use crate::chain_events::shared_head::{HeadSource, SharedHead};
13
14use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
15use std::num::NonZeroUsize;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19
20use anyhow::Context;
21use decdn_cache::{
22 CacheEngine, FilesystemOrigin, HttpOrigin, Origin, S3Credentials, S3Origin, S3OriginConfig,
23};
24use decdn_common::config::{ResolvedDiscovery, ResolvedOrigin, ResolvedS3Credentials};
25use iroh::address_lookup::{DnsAddressLookup, MemoryLookup, PkarrPublisher};
26use iroh::endpoint::{BindOpts, IdleTimeout, QuicTransportConfig, VarInt, presets};
27use iroh::protocol::Router;
28use iroh::{Endpoint, EndpointAddr, PublicKey, RelayMap, RelayMode, RelayUrl, SecretKey};
29
30use addr_publish::node_publish_filter;
31use noq_proto::congestion::Bbr3Config;
32use tokio::sync::oneshot;
33use tokio::task::JoinSet;
34use tokio_util::sync::CancellationToken;
35
36use crate::admin;
37use crate::channel_store::PersistentPoolStateStore;
38use crate::dht::{DhtRateLimiter, RecordStore, RecordStoreConfig, StakerSet};
39use crate::dispatch::ConnectionLimiter;
40use crate::handlers::client::{ClientHandler, ClientProtocol, MAX_CLIENT_STREAMS};
41use crate::handlers::dht::DhtHandler;
42use crate::handlers::probe::{ProbeHandler, StakeLanePolicy as ProbeStakeLanePolicy};
43use crate::handlers::probe_rate_limit::ProbeRateLimiter;
44use crate::metrics;
45use crate::payment_settlement::PoolSettlementService;
46use crate::stop_handle::StopHandle;
47use alloy::network::EthereumWallet;
48use alloy::primitives::U256;
49use alloy::providers::{Provider, ProviderBuilder};
50use alloy::signers::local::PrivateKeySigner;
51use decdn_common::address::parse_nonzero_address;
52use decdn_common::config::ResolvedConfig;
53use decdn_common::redact::{
54 redact_userinfo, sanitize_err_chain, sanitize_error_sources, sanitize_rpc_display,
55};
56use decdn_incentive::PoolStateStore;
57use decdn_incentive::eth_identity;
58
59/// Ceiling on each bounded shutdown stage: the router close ([`close_router`]),
60/// the final redeem sweep, and the drain of spawned tasks after the endpoint and
61/// metrics server have been signalled to stop. Sized comfortably larger than the
62/// sum of the probe handler's accept + close timeouts (see
63/// `handlers::probe::ACCEPT_BI_TIMEOUT` + `PROBE_READ_TIMEOUT` +
64/// `PROBE_CLOSE_TIMEOUT`) so in-flight handlers finish naturally, and than an
65/// endpoint close's `3 * PTO` wait; each stage's give-up branch only fires as a
66/// safety net.
67const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(15);
68
69/// Period between dispatch per-source rate-limiter GC sweeps (#440).
70/// The acquire path already prunes opportunistically when the keyspace
71/// exceeds `cap + cap/10`, but a node whose connection rate falls below
72/// the over-cap threshold can carry millions of stale buckets
73/// indefinitely. 60s is comfortably larger than the longest realistic
74/// bucket refill window, so the sweep is essentially free when the
75/// keyspace is empty.
76const DISPATCH_GC_INTERVAL: Duration = Duration::from_mins(1);
77
78/// Sampling period for the load-shed controller's egress-bytes EWMA. One
79/// second keeps the `ResourcePressure` policy's egress-budget check
80/// responsive to short bursts, without sampling so often that the EWMA
81/// mostly reflects request-arrival jitter.
82const EGRESS_EWMA_INTERVAL: Duration = Duration::from_secs(1);
83
84/// Interval between periodic GC sweeps of the DHT rate-limiter's per-IP
85/// and per-peer keyed maps (#645). 60s matches `DISPATCH_GC_INTERVAL` —
86/// the two limiters share the same operator mental model for keyspace
87/// cleanup cadence. Separate constant (rather than reusing
88/// `DISPATCH_GC_INTERVAL`) so a future tune to one limiter doesn't drag
89/// the other along.
90const DHT_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
91
92/// Interval between periodic GC sweeps of the probe rate-limiter's per-IP and
93/// per-peer keyed maps (#645, #982). Matches `DHT_RATE_LIMIT_GC_INTERVAL` — the
94/// probe and DHT keyed limiters share the same keyspace-cleanup mental model.
95/// Separate constant so a future tune to one limiter doesn't drag the other.
96const PROBE_RATE_LIMIT_GC_INTERVAL: Duration = Duration::from_mins(1);
97
98/// Period between periodic sweeps of the local per-peer reputation store
99/// (ADR 008 §Local Score Calculation). `LocalReputation::evict` drops
100/// entries for peers that have been idle long enough that their EWMA is
101/// no longer a useful ranking signal, so the store does not grow
102/// unbounded with every peer ever observed. 1h matches the DHT
103/// bucket-refresh cadence — reputation and routing-table hygiene share
104/// the same operator mental model for peer-state cleanup.
105const REPUTATION_EVICT_INTERVAL: Duration = Duration::from_hours(1);
106
107/// QUIC-level idle timeout: the transport closes a connection if no
108/// packets arrive for this long. Set to match ADR 005's 30s
109/// connection-lifetime ceiling.
110///
111/// Because [`QUIC_KEEP_ALIVE_INTERVAL`] (10s) is shorter than this
112/// timeout, healthy peers keep refreshing it via PING ACKs and the QUIC
113/// idle reaper rarely fires on its own — that is the spec's intent (see
114/// ADR 005: "below the idle timeout to prevent NAT middleboxes from
115/// dropping the mapping"). The QUIC timer is a defense-in-depth floor
116/// for genuinely silent paths (e.g. peer crash / network partition); the
117/// "close 30s after last stream and no unacked vouchers" rule is
118/// application-layer and lives in the cdn/client/v1 handler's serve loop
119/// (`handlers::client::APP_IDLE_TIMEOUT`), which reaps keep-alive'd-but-
120/// streamless connections the transport timer cannot.
121pub(crate) const QUIC_MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
122
123/// Interval between QUIC PING keep-alive frames. Per ADR 005 §Connection
124/// lifetime, endpoints SHOULD send PINGs at 10s intervals — strictly
125/// less than [`QUIC_MAX_IDLE_TIMEOUT`] so a single dropped probe doesn't
126/// trip the idle reaper, and well below the typical NAT mapping timeout
127/// so middleboxes don't drop the path under quiet load. Quinn applies
128/// this on every connection regardless of role, so this endpoint emits
129/// keep-alives on both client- and server-initiated connections.
130const QUIC_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
131
132/// Maximum number of concurrent bidirectional streams a peer may open on
133/// a single connection. Sized for `cdn/client/v1` (the largest cap in
134/// ADR 005 §Concurrent stream limits: client=100, probe=1, dht=1).
135/// `QuicTransportConfig` is per-endpoint, not per-ALPN, so the QUIC
136/// layer can only enforce the union of these caps; tighter per-ALPN
137/// limits are enforced inside each handler. The probe handler already
138/// does this implicitly by calling `accept_bi()` exactly once per
139/// connection and then closing.
140const QUIC_MAX_CONCURRENT_BIDI_STREAMS: u32 = 100;
141
142/// The `FeeRouter` contract's own `OPERATOR_BPS_FLOOR` (`contracts/src/FeeRouter.sol`):
143/// the minimum operator share governance can configure. Used as the startup
144/// fallback for the live operator fee-share seed (ADR 041) — a conservative
145/// UNDER-estimate of the true operator share, which only makes the `margin`
146/// serve-economics policy's buy ceiling more conservative (it assumes MORE
147/// future serve revenue is skimmed by fees than governance has actually set),
148/// never a griefing surface.
149const FEE_ROUTER_OPERATOR_BPS_FLOOR: u16 = 4000;
150
151/// The most of the boot deadline the best-effort fee-share reads may spend on
152/// retries. It covers a short provider blip; a longer outage falls back to the
153/// floor share and leaves the rest of the deadline to the fail-closed reads.
154const FEE_SHARE_BOOT_BUDGET: Duration = Duration::from_mins(1);
155
156/// Apply an explicit client poll interval to a freshly built provider,
157/// overriding alloy's localhost-detected 250 ms default (#1011).
158///
159/// Despite the config knob's name, this does not touch event watching: the
160/// multiplexed poller ticks on its own `poll_interval` and never reads the
161/// client interval. The one consumer still reachable from this node is
162/// `PendingTransactionBuilder::get_receipt`'s heartbeat (alloy-provider
163/// `heart.rs`), which the node awaits in `payment_settlement` and
164/// `buyer_channel` — so this bounds how fast a node awaiting a mined settlement /
165/// channel tx re-polls for its receipt, and alloy's 250 ms localhost default
166/// would otherwise hammer a dev anvil for the life of every pending tx.
167///
168/// `set_poll_interval` uses interior mutability, so this applies to the
169/// already-constructed provider and returns it unchanged in type —
170/// wallet/nonce-filler providers route through it too, because `client()` is a
171/// default `Provider` trait method available on every provider. The interval
172/// comes from `blockchain.event_poll_interval_ms`.
173fn with_poll_interval<P: Provider>(provider: P, interval: Duration) -> P {
174 provider.client().set_poll_interval(interval);
175 provider
176}
177
178/// Keep every iroh ALPN listener closed until the blacklist watcher reports one
179/// clean boot enumeration + operator-scope enforcement pass. The closure is the explicit seam
180/// that makes it impossible to construct the `Router` on either pending or
181/// failed readiness.
182async fn gate_listener_on_blacklist_sync<T>(
183 readiness: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
184 start_listener: impl FnOnce() -> T,
185) -> anyhow::Result<T> {
186 readiness
187 .await
188 .context("blacklist watcher exited before initial sync completed")?
189 .map_err(anyhow::Error::msg)
190 .context("initial ContentBlacklist sync unavailable")?;
191 Ok(start_listener())
192}
193
194type HttpUrl = alloy::transports::http::reqwest::Url;
195
196/// Construct the four HTTP-provider roles owned by the node runtime (#1252).
197///
198/// Keeping these constructors together makes the pending-transaction poll
199/// interval and wallet nonce policy single-sourced while preserving separate
200/// provider instances for every consumer. The shared-head provider is the one
201/// deliberate exception to the poll override: it never creates pending
202/// transactions and therefore keeps Alloy's transport default.
203///
204/// Every provider sends through [`crate::rpc_metrics::metered_http_client`],
205/// so each JSON-RPC request the node makes counts on
206/// `decdn_rpc_requests_total`.
207#[derive(Clone)]
208struct ProviderFactory {
209 url: HttpUrl,
210 metrics: Arc<metrics::Metrics>,
211}
212
213impl ProviderFactory {
214 const fn new(url: HttpUrl, metrics: Arc<metrics::Metrics>) -> Self {
215 Self { url, metrics }
216 }
217
218 fn client(&self) -> alloy::rpc::client::RpcClient {
219 crate::rpc_metrics::metered_http_client(self.url.clone(), &self.metrics)
220 }
221
222 fn read_only(&self, interval: Duration) -> impl Provider + Clone + use<> {
223 with_poll_interval(
224 ProviderBuilder::new().connect_client(self.client()),
225 interval,
226 )
227 }
228
229 fn shared_head(&self) -> impl Provider + Clone + use<> {
230 ProviderBuilder::new().connect_client(self.client())
231 }
232
233 fn seller_wallet(
234 &self,
235 signer: PrivateKeySigner,
236 interval: Duration,
237 ) -> impl Provider + Clone + use<> {
238 self.wallet(signer, interval)
239 }
240
241 fn buyer_wallet(
242 &self,
243 signer: PrivateKeySigner,
244 interval: Duration,
245 ) -> impl Provider + Clone + use<> {
246 self.wallet(signer, interval)
247 }
248
249 fn wallet(
250 &self,
251 signer: PrivateKeySigner,
252 interval: Duration,
253 ) -> impl Provider + Clone + use<> {
254 with_poll_interval(
255 ProviderBuilder::new()
256 .with_simple_nonce_management()
257 .wallet(EthereumWallet::from(signer))
258 .connect_client(self.client()),
259 interval,
260 )
261 }
262}
263
264/// Runtime [`QuicTransportConfig`]. Tests build their own config via
265/// the same builder when they need to shorten the idle timeout to keep
266/// the test runtime under a second.
267fn quic_transport_config() -> anyhow::Result<QuicTransportConfig> {
268 let idle_timeout: IdleTimeout = QUIC_MAX_IDLE_TIMEOUT.try_into().map_err(|e| {
269 anyhow::anyhow!(
270 "BUG: QUIC_MAX_IDLE_TIMEOUT={QUIC_MAX_IDLE_TIMEOUT:?} not representable as IdleTimeout: {e}"
271 )
272 })?;
273 Ok(QuicTransportConfig::builder()
274 .max_idle_timeout(Some(idle_timeout))
275 .keep_alive_interval(QUIC_KEEP_ALIVE_INTERVAL)
276 .max_concurrent_bidi_streams(VarInt::from_u32(QUIC_MAX_CONCURRENT_BIDI_STREAMS))
277 // BBR3 over the default Cubic. Congestion control lives at the sender, so
278 // this governs the bytes the node serves to a downstream client or node.
279 // BBR paces to the path's measured bandwidth and round trip instead of
280 // treating loss as the sole congestion signal, so a high-latency,
281 // lossy long-haul link — where a single Cubic flow backs off hard — keeps
282 // the pipe full without needing many parallel flows to compensate.
283 .congestion_controller_factory(Arc::new(Bbr3Config::default()))
284 .build())
285}
286
287/// Spawn a periodic task onto `tasks`: burn the first tick, then run `tick`
288/// every `interval` until the returned stop signal is sent (or its sender is
289/// dropped). Every tick body here is synchronous, so `tick` is a plain
290/// `FnMut()` run to completion inside the task.
291///
292/// The `biased; stop-before-tick` select keeps shutdown prompt: the stop arm is
293/// polled first, so a signalled stop returns without waiting out the current
294/// `interval`. A stop arm that failed to return would instead stall shutdown by
295/// up to one full `interval`, which the runtime's normal path would mask as a
296/// "task slow to drain" rather than a bug. `spawn_periodic_exits_promptly_on_shutdown`
297/// pins that a signalled stop returns promptly and that the first tick is
298/// burned; `spawn_periodic_fires_the_tick_body` pins that ticks otherwise run.
299///
300/// The first tick is burned so the first pass lands one interval after startup
301/// rather than on the same tick as bring-up. `name` labels the shutdown log.
302fn spawn_periodic<F>(
303 tasks: &mut JoinSet<()>,
304 name: &'static str,
305 interval: Duration,
306 mut tick: F,
307) -> oneshot::Sender<()>
308where
309 F: FnMut() + Send + 'static,
310{
311 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
312 tasks.spawn(async move {
313 let mut ticker = tokio::time::interval(interval);
314 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
315 ticker.tick().await;
316 loop {
317 tokio::select! {
318 biased;
319 _ = &mut stop_rx => {
320 tracing::debug!(task = name, "periodic task shutdown signal received");
321 return;
322 }
323 _ = ticker.tick() => tick(),
324 }
325 }
326 });
327 stop_tx
328}
329
330/// Warn when the pin set cannot fit under the cache ceiling (#1130).
331///
332/// Pinned hashes are LRU-exempt (`eviction_candidates` filters them out), so a
333/// pin set larger than `cache.cache_size_mb` makes the eviction driver's
334/// high-water mark permanently unreachable: it will starve every tick while the
335/// disk keeps growing. The hazard applies to every node however the pinned
336/// content arrived, so this runs unconditionally at startup and on reload.
337///
338/// Warn rather than fail or truncate. The operator's pin set is a deliberate
339/// statement about what this node must serve, and silently warming only part of
340/// it would be worse than warming all of it noisily. Sizes come from the
341/// origin-held index, so a hash the index cannot size contributes nothing and
342/// the total is a **lower bound**. That matters: an origin that blocks `HEAD`
343/// leaves the index empty and this check silently never fires. It is a cheap
344/// early hint, not the authoritative signal — that is
345/// `decdn_cache_evictions_starved_total` with `decdn_cache_bytes` at
346/// `decdn_cache_size_limit_bytes`.
347fn warn_if_pins_exceed_cache(cache: &decdn_cache::CacheEngine, cache_size_mb: u64) {
348 let pinned = cache.pinned_snapshot();
349 let pinned_bytes: u64 = pinned
350 .iter()
351 .filter_map(|h| cache.origin_held_size(decdn_cache::Hash::from_bytes(*h.as_bytes())))
352 .fold(0u64, u64::saturating_add);
353 let limit_bytes = cache_size_mb.saturating_mul(1024 * 1024);
354 if pinned_bytes > limit_bytes {
355 tracing::warn!(
356 pinned_count = pinned.len(),
357 pinned_bytes,
358 cache_size_mb,
359 limit_bytes,
360 "cache.pinned_hashes is larger than cache.cache_size_mb; pinned blobs are \
361 LRU-exempt, so the eviction driver can never reach its high-water target and \
362 disk use will exceed the configured ceiling. Raise cache_size_mb or trim the \
363 pin set (#1130)"
364 );
365 }
366}
367
368/// Emit one `debug` line for a keyed rate-limiter GC sweep that pruned buckets.
369/// Shared by the DHT and probe rate-limiter GC ticks, whose per-layer
370/// (`per_ip` / `per_peer`) log arms are otherwise byte-identical.
371fn log_keyspace_gc(kind: &'static str, layer: &'static str, sweep: Option<(usize, usize)>) {
372 if let Some((before, after)) = sweep {
373 tracing::debug!(
374 layer,
375 before,
376 after,
377 dropped = before.saturating_sub(after),
378 "{kind} rate-limit GC sweep complete"
379 );
380 }
381}
382
383/// Runtime infrastructure built during the front bring-up phase of [`run`].
384///
385/// A plain by-value bundle of the long-lived handles the rest of `run` (and the
386/// shutdown sequence) consumes. `run` destructures it immediately so every
387/// downstream call site keeps referring to the same-named locals.
388struct Infra {
389 node_metrics: Arc<metrics::Metrics>,
390 secret_key: SecretKey,
391 eth_signer: Arc<PrivateKeySigner>,
392 /// The `PaymentPool` deployment the lane store is stamped with. The voucher
393 /// EIP-712 domain is derived from this same value, so the stamp and the
394 /// signature domain can never disagree.
395 payment_pool_deployment: crate::channel_store::Deployment,
396 concrete_channel_store: Arc<PersistentPoolStateStore>,
397 channel_state_store: Arc<dyn PoolStateStore>,
398 watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore>,
399 receipt_sink: Arc<dyn crate::receipt_log::ReceiptSink>,
400 /// Stop handle for the background receipt writer: cancelled after the
401 /// router drains on shutdown, then awaited in the drain phase so the audit
402 /// tail survives shutdown (#803).
403 receipt_writer: StopHandle,
404 /// Periodic lane-store flush timer (ADR 003 §Off-chain voucher state
405 /// persistence). Aborted after one final durable flush on shutdown.
406 lane_flush_task: tokio::task::JoinHandle<()>,
407 node_origin: Option<crate::node_origin::NodeOrigin>,
408 pull_through_origin: Option<Arc<crate::node_origin::NodeOrigin>>,
409 cache: CacheEngine,
410 /// Cache eviction policy selected by `cache.eviction_policy` (ADR 040).
411 /// Injected into the eviction driver at spawn time
412 /// ([`spawn_background_tasks`]); the engine itself never chooses a policy.
413 eviction_policy: Arc<dyn decdn_cache::EvictionPolicy>,
414 /// Shared ADR 040 frequency estimator, when built (`cache.eviction_policy`
415 /// / `cache.admission_policy` == `"tinylfu"`, or `cache.serve_economics.policy`
416 /// == `"margin"`). `None` when no consumer needs it. Threaded into
417 /// [`crate::node_origin::NodeOriginConfig`] at spawn time
418 /// ([`spawn_background_tasks`]) as the ADR 041 `margin` policy's heat input.
419 frequency_estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
420 ep: Endpoint,
421 limiter: Arc<ConnectionLimiter>,
422}
423
424/// Front bring-up phase: RPC preflight, eth keystore load, voucher-state
425/// and receipt stores, cache + origin chain, iroh endpoint, and the
426/// connection limiter. The two `reload_state` attach side effects run inline,
427/// at their points in the bring-up order, so a SIGHUP delivered mid-bring-up
428/// still finds a target.
429#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
430async fn build_infra(
431 cfg: &ResolvedConfig,
432 secret_key: SecretKey,
433 reload_state: &RuntimeReloadState,
434 node_metrics: Arc<metrics::Metrics>,
435) -> anyhow::Result<Infra> {
436 // Preflight: verify RPC endpoint is reachable before committing to
437 // port binding. A 5-second timeout keeps startup responsive on flaky
438 // networks while still catching typos and dead endpoints early.
439 check_rpc_reachability(&cfg.blockchain.rpc_url).await?;
440
441 // Registry-mandatory `decdn_probe_hold_slots_max` (ADR
442 // appendix-observability.md) — static, set once from config.
443 node_metrics.probe_hold_slots_max(cfg.cache.max_probe_holds);
444
445 // Issue #406: load the Ethereum keystore into a live `PrivateKeySigner`
446 // before the rest of startup so any password-source error (env unset,
447 // missing file, prompt aborted) fails fast with a clear message rather
448 // than after the cache + endpoint have been built. `decrypt_keystore`
449 // runs scrypt/argon2 (hundreds of milliseconds to multi-second under
450 // hardened KDF params), so it must run on a blocking thread to avoid
451 // stalling the tokio runtime.
452 let eth_signer = Arc::new(load_eth_signer(cfg).await?);
453 tracing::info!(address = %eth_signer.address(), "loaded eth keystore");
454
455 // Open the off-chain voucher-state store (issue #527, ADR 003
456 // §Off-chain voucher state persistence) before any handler that could
457 // accept a voucher comes online. `PersistentPoolStateStore::open`
458 // performs disk I/O (file create + mode tighten + redb header read), so
459 // run it on a blocking thread to avoid stalling the tokio runtime.
460 // Failure here MUST abort startup: continuing with a fresh in-memory
461 // map silently reopens the replay window the store exists to close.
462 let channel_store_data_dir = cfg.identity.data_dir.clone();
463 // The store binds to the configured `PaymentPool` deployment and drops the
464 // seller-side rows another deployment wrote: pool ids repeat across
465 // deployments, so a stale row would shadow a returning buyer's new pool.
466 let channel_store_deployment = crate::channel_store::Deployment {
467 chain_id: cfg.blockchain.chain_id,
468 payment_pool: parse_nonzero_address(
469 &cfg.blockchain.payment_pool_address,
470 "blockchain.payment_pool_address",
471 )?,
472 };
473 // Verify the configured deployment against the live chain BEFORE the store
474 // opens: `bind_deployment` drops the seller lane state when the stamp
475 // differs, so a typo'd chain id or contract address must abort bring-up
476 // here, while the store is untouched, rather than trigger an irreversible
477 // drop on a WARN.
478 // The URL is not echoed on a parse failure: it can embed a provider API
479 // key, and the reachability probe above has already reported unusable
480 // endpoints with a sanitized display.
481 let preflight_rpc_url: HttpUrl = cfg
482 .blockchain
483 .rpc_url
484 .parse()
485 .context("blockchain.rpc_url is not a valid URL (value redacted; check the config)")?;
486 check_deployment_preflight(
487 ProviderFactory::new(preflight_rpc_url, Arc::clone(&node_metrics)).shared_head(),
488 channel_store_deployment,
489 &BootRetry::new(DEPLOYMENT_PREFLIGHT_BUDGET, Arc::clone(&node_metrics)),
490 )
491 .await?;
492 // Keep the concrete store `Arc` so it can back the seller
493 // `ChannelStateStore` (channel_state_v1 table), the pending-settle store
494 // (pending_settle_v1 table, PR #743 review), and the buyer
495 // `BuyerChannelStore` (buyer_channel_state_v2 table, #744) — redb forbids a
496 // second `Database` handle to the same file, so one shared store owns all.
497 let concrete_channel_store: Arc<PersistentPoolStateStore> = Arc::new(
498 tokio::task::spawn_blocking(move || {
499 PersistentPoolStateStore::open(&channel_store_data_dir, channel_store_deployment)
500 })
501 .await
502 .context("channel state store open task panicked")?
503 .context("failed to open channel state store (issue #527 voucher replay guard)")?,
504 );
505 // The one redb-backed store implements the voucher-state trait (for the
506 // handler + #527 replay guard), the pending-settle trait (for the on-chain
507 // settlement sweep, PR #743 review), and the buyer-channel trait (#744).
508 // Derive trait-object handles from the single concrete store so every write
509 // family shares one open store and one fsync discipline (each family commits
510 // on its own per-family redb file); `concrete_channel_store` stays bound for
511 // the buyer handle built further below.
512 let channel_state_store: Arc<dyn PoolStateStore> = concrete_channel_store.clone();
513 // Debounce the scan-checkpoint writes (#784, keyed in #1092): each persisted
514 // watcher (settlement `ChannelOpened`, origin `Origin`) advances its cursor
515 // once per completed `eth_getLogs` window — on the live tail, once per poll
516 // tick with new confirmed blocks — and the directly-durable store fsyncs on
517 // each. The persisted value is only a *floor* for the resume backfill
518 // (`resolve_persisted_start` rewinds it by the reorg margin; the sinks are
519 // idempotent), so coarsening the write cadence is safe — and the one key
520 // still persisted (settlement) is force-flushed on graceful shutdown by its
521 // own service, so steady-state progress is not lost. The origin directory
522 // stopped persisting a cursor in #1504; it enumerates on every boot.
523 // Wrapping here (the wiring layer) keeps the domain trait and the disk store
524 // free of the debounce policy.
525 let watcher_checkpoint_store: Arc<dyn decdn_incentive::KeyedCheckpointStore> = Arc::new(
526 crate::payment_settlement::DebouncedCheckpointStore::new(concrete_channel_store.clone()),
527 );
528 // Boot-time smoke test: read every persisted record so startup fails
529 // fast on corruption / forward-incompatible schema, well before the
530 // `cdn/client/v1` handler is constructed further down the bring-up.
531 // That handler calls `load_all` again to bootstrap its in-memory channel
532 // map — the duplicate read is by design. Infra bring-up hands the handler
533 // the *store*, not a snapshot: a pre-built map threaded through
534 // `ClientHandlerDeps` would couple this stage to the handler's internal
535 // channel representation. Cost: one extra `load_all` on startup.
536 let persisted_count = tokio::task::spawn_blocking({
537 let store = Arc::clone(&channel_state_store);
538 move || store.load_all()
539 })
540 .await
541 .context("channel state store load task panicked")?
542 .context("failed to hydrate persisted channel state")?
543 .len();
544 tracing::info!(
545 channels = persisted_count,
546 "channel state store ready (issue #527 replay guard active)",
547 );
548
549 // Open the append-only download-receipt audit log (issue #248). Unlike the
550 // channel store, a failure here is NON-fatal: the receipt log is an audit
551 // artifact (revenue reconciliation, dispute evidence), not the replay
552 // guard, so the node still serves paid delivery — falling back to a
553 // discard-only log — rather than refusing to start. The open does a small
554 // amount of disk I/O (create + chmod), so run it on the blocking pool.
555 let receipt_log_data_dir = cfg.identity.data_dir.clone();
556 let receipt_log_policy = crate::receipt_log::RotationPolicy::from(&cfg.receipts);
557 let receipt_log: Arc<dyn crate::receipt_log::ReceiptLog> =
558 match tokio::task::spawn_blocking(move || {
559 crate::receipt_log::JsonlReceiptLog::open(&receipt_log_data_dir, receipt_log_policy)
560 })
561 .await
562 .context("download-receipt log open task panicked")?
563 {
564 Ok(log) => {
565 tracing::info!(path = %log.path().display(), "download-receipt log ready");
566 Arc::new(log)
567 }
568 Err(e) => {
569 tracing::error!(
570 error = %e,
571 event = "download_receipt_log_open_failed",
572 "failed to open download-receipt log; continuing without the audit log \
573 (paid delivery is unaffected; revenue reconciliation will be incomplete)",
574 );
575 Arc::new(crate::receipt_log::NoopReceiptLog)
576 }
577 };
578
579 // Decouple the audit write from the paid-delivery hot path (#803): a single
580 // background task owns the receipt log and drains a bounded queue, so the
581 // voucher-accept path only does a non-blocking enqueue before it continues
582 // delivery and a slow/full disk can never back-pressure delivery. The
583 // writer's stop handle is cancelled after the router drains on shutdown
584 // (below) so the writer flushes its tail before exiting.
585 let (receipt_sink, receipt_writer) =
586 crate::receipt_log::spawn_receipt_writer(receipt_log, Arc::clone(&node_metrics));
587
588 // Background lane-store flush (ADR 003 §Off-chain voucher state persistence):
589 // mirror the in-memory voucher watermark to disk every
590 // `payment.voucher_commit_interval_ms`. A failed flush retains the dirty set
591 // for the next tick; it never blocks delivery.
592 let flush_store = Arc::clone(&channel_state_store);
593 let flush_metrics = Arc::clone(&node_metrics);
594 let flush_interval =
595 std::time::Duration::from_millis(cfg.payment.voucher_commit_interval_ms.max(1));
596 let lane_flush_task = tokio::spawn(async move {
597 let mut ticker = tokio::time::interval(flush_interval);
598 ticker.tick().await; // skip the immediate first tick
599 loop {
600 ticker.tick().await;
601 let store = Arc::clone(&flush_store);
602 let res = tokio::task::spawn_blocking(move || store.flush()).await;
603 match res {
604 Ok(Ok(())) => {}
605 Ok(Err(err)) => {
606 flush_metrics.lane_flush_failure();
607 tracing::warn!(error = %err, "lane store background flush failed; retrying next tick");
608 }
609 Err(join_err) => {
610 flush_metrics.lane_flush_failure();
611 tracing::warn!(%join_err, "lane store flush task join failed");
612 }
613 }
614 }
615 });
616
617 // Node-to-node pull-through origin (#831, ADR 001/022). Constructed empty up
618 // front so it can be appended to the cache's origin chain here; its
619 // dependencies (DHT, buyer channel, reputation handles) don't exist yet and
620 // are injected via `provision` once bring-up completes (below). When the
621 // feature is off it is never created and never enters the chain. The handle
622 // is retained to provision later.
623 let node_origin = cfg
624 .cache
625 .node_to_node_pull_through_enabled
626 .then(crate::node_origin::NodeOrigin::new);
627 // A shared `Arc<NodeOrigin>` handle for the window-paced serve path (#856).
628 // `NodeOrigin` is `Clone` over an `Arc<OnceLock<deps>>`, so this clone sees
629 // the dependencies `provision`ed (below) on the chain's copy. Held so the
630 // client handler can drive progressive pulls directly, bypassing the buffered
631 // `populate` for the serve-miss pull-through path.
632 let pull_through_origin = node_origin.clone().map(Arc::new);
633 let cache = build_cache(
634 cfg,
635 Arc::clone(&node_metrics),
636 node_origin.clone().map(|o| Arc::new(o) as Arc<dyn Origin>),
637 )
638 .await?;
639 // Attach the cache to the reload state so SIGHUP handlers can swap
640 // the pinned-hashes set atomically (#276). Done immediately after
641 // `build_cache` succeeds so a SIGHUP delivered during the rest of
642 // startup will still find a target.
643 reload_state.attach_cache(Some(cache.clone()));
644 // `decdn_probe_hold_slots_used` samples this cache on every scrape.
645 node_metrics.attach_probe_holds(cache.clone());
646
647 // Admission/eviction policy selection (ADR 040) plus the ADR 041 estimator
648 // decoupling (see `wire_cache_policies`).
649 let (eviction_policy, estimator) = wire_cache_policies(&cfg.cache, &cache);
650
651 let retry = cfg.cache.origin_retry;
652 tracing::info!(
653 cache_dir = %cfg.cache.cache_dir.display(),
654 has_origin = !cfg.cache.origins.is_empty(),
655 origin_count = cfg.cache.origins.len(),
656 origin_kinds = %origin_kinds_label(&cfg.cache.origins),
657 pinned_hashes = cfg.cache.pinned_hashes.len(),
658 // Origin retry policy (#285). Logged once at startup so operators
659 // can audit the active resilience budget without hitting an RPC.
660 retry_max_retries = retry.max_retries,
661 retry_initial_backoff_ms = retry.initial_backoff_ms,
662 retry_max_backoff_ms = retry.max_backoff_ms,
663 retry_jitter_ratio = retry.jitter_ratio,
664 "cache engine ready",
665 );
666
667 let transport_config =
668 quic_transport_config().context("failed to build QUIC transport config")?;
669 let ep = build_endpoint(
670 &secret_key,
671 cfg.network.bind_port,
672 &cfg.network.relay_urls,
673 &cfg.network.discovery,
674 transport_config,
675 )
676 .await
677 .context("failed to build iroh endpoint")?;
678
679 node_metrics
680 .register_iroh_endpoint(&ep)
681 .context("failed to register iroh metrics")?;
682
683 let limiter = Arc::new(ConnectionLimiter::new(
684 &cfg.security,
685 Arc::clone(&node_metrics),
686 ));
687 // Attach the limiter to the reload state so SIGHUP / admin reloads
688 // can forward `[security]` changes via `ConnectionLimiter::reload`
689 // (#235). Done immediately after construction so a SIGHUP delivered
690 // during the rest of startup still finds a target.
691 reload_state.attach_limiter(Some(Arc::clone(&limiter)));
692 reload_state.attach_metrics(Arc::clone(&node_metrics));
693
694 Ok(Infra {
695 node_metrics,
696 secret_key,
697 eth_signer,
698 payment_pool_deployment: channel_store_deployment,
699 concrete_channel_store,
700 channel_state_store,
701 watcher_checkpoint_store,
702 receipt_sink,
703 receipt_writer,
704 lane_flush_task,
705 node_origin,
706 pull_through_origin,
707 cache,
708 eviction_policy,
709 frequency_estimator: estimator,
710 ep,
711 limiter,
712 })
713}
714
715/// By-value bundle of the endpoint, handlers, limiter, and blacklist
716/// readiness receiver consumed when [`serve_until_shutdown`] builds the router.
717/// Every field is moved into the router builder (or its gate), so none is
718/// referenced by [`run`] after the serve call returns.
719struct ServeInputs {
720 ep: Endpoint,
721 probe_handler: Arc<ProbeHandler>,
722 client_handler: Arc<ClientHandler>,
723 dht_handler: Arc<DhtHandler>,
724 blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
725}
726
727/// Serve phase of [`run`] (#1253): build the paid-delivery router behind the
728/// fail-closed blacklist gate, install the signal + SIGHUP streams, and run the
729/// select loop until a shutdown signal arrives. Returns the live [`Router`] and
730/// the observed [`ShutdownSignal`] so [`run`] can drive the teardown sequence.
731/// `drain_trigger` is borrowed, not consumed, because [`run`] still passes it to
732/// [`shutdown`] afterwards.
733#[allow(clippy::cognitive_complexity)]
734async fn serve_until_shutdown(
735 reload_state: &RuntimeReloadState,
736 config_path: Option<&Path>,
737 drain_trigger: &Arc<admin::DrainTrigger>,
738 serve_in: ServeInputs,
739) -> anyhow::Result<(Router, ShutdownSignal)> {
740 let ServeInputs {
741 ep,
742 probe_handler,
743 client_handler,
744 dht_handler,
745 blacklist_ready_rx,
746 } = serve_in;
747
748 // No Probe, Client, or DHT ALPN is registered before the mandatory first
749 // global + operator-region blacklist enumeration and enforcement pass
750 // succeeds. That pass runs inline in `build_chain_and_handlers`, before the
751 // metrics and admin listeners bind, and has always resolved by the time
752 // control reaches this gate; a boot that retries it has nothing listening
753 // yet. The gate is the structural seam: the `Router` cannot be built on a
754 // pending or failed sync. A *failed* initial sync is fail-closed the hard
755 // way: the gate returns `Err`, `run` propagates it, and the process exits
756 // rather than ever serving un-vetted content.
757 let router = gate_listener_on_blacklist_sync(blacklist_ready_rx, || {
758 Router::builder(ep.clone())
759 .accept(ProbeHandler::ALPN, probe_handler)
760 .accept(ClientProtocol::ALPN, ClientProtocol::new(client_handler))
761 .accept(DhtHandler::ALPN, dht_handler)
762 .spawn()
763 })
764 .await?;
765
766 // Install signal streams once, before entering the select loop.
767 // tokio docs are explicit that `Signal::recv` is the supported way
768 // to await repeated signals, and re-creating the stream per signal
769 // is not — see `tokio::signal::unix::signal` for the reasoning.
770 let mut shutdown_streams = ShutdownStreams::install();
771 let mut hup_stream = HupStream::install();
772
773 let signal = loop {
774 tokio::select! {
775 sig = shutdown_streams.recv() => break sig,
776 () = hup_stream.recv() => {
777 match config_path {
778 Some(path) => {
779 if let Err(err) = reload_state.reload(path).await {
780 tracing::warn!(error = %err, "config reload error");
781 }
782 }
783 None => {
784 tracing::warn!(
785 "SIGHUP received but no config file path is in use; ignoring"
786 );
787 }
788 }
789 }
790 () = drain_trigger.wait() => {
791 tracing::info!("admin_v1_drain received; initiating graceful shutdown");
792 break ShutdownSignal::AdminDrain;
793 }
794 }
795 };
796 tracing::info!(signal = %signal, "shutdown signal received; closing router");
797
798 Ok((router, signal))
799}
800
801/// Chain providers, event watchers, and paid-delivery handlers built during the
802/// middle phase of [`run`] (#1253). A by-value bundle of the long-lived handles
803/// the background-task, serve, and shutdown phases consume, mirroring [`Infra`].
804/// Generic over the seller-wallet provider `P` (an opaque `impl Provider`
805/// threaded through [`PoolSettlementService`]); [`run`] infers `P` at the call
806/// site and hands it to [`ShutdownHandles`].
807struct ChainHandlers<P: Provider + Clone + 'static> {
808 providers: ProviderFactory,
809 event_poll_interval: Duration,
810 slash_domain: alloy::dyn_abi::Eip712Domain,
811 bind_domain: alloy::dyn_abi::Eip712Domain,
812 staker_set: Arc<dyn StakerSet>,
813 /// The single multiplexed chain-event poller driving every registered
814 /// watcher route — six with the fee-shares route, which registers only
815 /// when the startup `feeRouter()` read succeeds.
816 /// Stopped once, late in shutdown (after `router.shutdown`), because the
817 /// capacity-bond route's staker set gates DHT admission through drain.
818 poller: crate::chain_events::resumable_watcher::WatcherHandle,
819 slash_store: crate::slash_watcher::SlashStore,
820 node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>>,
821 probe_rate_limiter: Arc<ProbeRateLimiter>,
822 probe_handler: Arc<ProbeHandler>,
823 dht_rate_limiter: Arc<DhtRateLimiter>,
824 record_store: Arc<std::sync::Mutex<RecordStore>>,
825 origin_directory: Arc<dyn crate::dht::origin::OriginDirectory>,
826 dht_handler: Arc<DhtHandler>,
827 dht_routing: Arc<std::sync::Mutex<crate::dht::RoutingTable>>,
828 /// The `CapacityBond` registry's `NodeId → regionHint` projection (ADR 030),
829 /// threaded to the node-origin pull path for the region-latency penalty.
830 registry_regions: Arc<std::sync::RwLock<std::collections::HashMap<crate::dht::NodeId, String>>>,
831 client_handler: Arc<ClientHandler>,
832 /// Shared with the reload state (`attach_load_shed`) and the egress-EWMA
833 /// sampling tick spawned in [`spawn_background_tasks`], so all three see
834 /// the same live policy.
835 shed_controller: Arc<crate::load_shed::LoadShedController>,
836 payment_service: PoolSettlementService<P>,
837 blacklist_ready_rx: oneshot::Receiver<crate::blacklist_watcher::InitialSyncResult>,
838 /// Bring-up node-id binding self-check (#1034), carried through to
839 /// `AdminState` so `admin_v1_health` can report it. A plain value, not a
840 /// handle: the binding only moves by an explicit operator transaction, so
841 /// there is nothing live to keep.
842 binding_report: crate::binding_check::BindingReport,
843 /// ADR 041 buy-side profitability gate policy, selected from
844 /// `cache.serve_economics.policy`.
845 serve_economics: Arc<dyn crate::serve_economics::ServeEconomicsPolicy>,
846 /// Live operator fee-share (basis points) cell, seeded from chain at
847 /// startup and kept current by the fee-shares watcher.
848 operator_shares: crate::fee_shares::OperatorShares,
849 /// ADR 041 per-source warming allowance. The SAME `Arc` the eviction path
850 /// holds (to forget a dropped hash's tag) and the warming-credit aggregator
851 /// applies serve credits to, threaded to
852 /// [`crate::node_origin::NodeOriginConfig`] for the buy-side debit. The
853 /// client handler reaches it only through a
854 /// [`crate::warming_allowance::WarmingCreditSink`], never directly.
855 warming: Arc<crate::warming_allowance::WarmingAllowance>,
856 /// Stop handle for the ADR 041 warming-credit aggregator. Cancelled after
857 /// the router drains on shutdown so the aggregator flushes the credits
858 /// already queued by in-flight serves, then awaited — held rather than
859 /// detached so a task that died shows up as a join error instead of as
860 /// credits that quietly stop landing.
861 warming_creditor: StopHandle,
862}
863
864/// The chain-event poller builder with its config-driven settings: the
865/// `eth_getLogs` block-span ceiling (`blockchain.get_logs_max_block_span`) and
866/// the span, range-rejection and window-retry metrics. Routes are added by the
867/// caller.
868fn chain_poller_builder(
869 head: Arc<dyn HeadSource>,
870 poll_interval: Duration,
871 blockchain: &decdn_common::config::ResolvedBlockchain,
872 node_metrics: &Arc<metrics::Metrics>,
873) -> crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder {
874 let span_metrics = Arc::clone(node_metrics);
875 crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::new(head, poll_interval)
876 .max_backfill_span(blockchain.get_logs_max_block_span)
877 .on_span_change(Box::new(move |span| span_metrics.chain_get_logs_span(span)))
878 .on_range_rejection(metrics::metric_hook(
879 node_metrics,
880 metrics::Metrics::chain_get_logs_range_rejected,
881 ))
882 .on_window_retry(metrics::metric_hook(
883 node_metrics,
884 metrics::Metrics::chain_get_logs_retried,
885 ))
886 .on_window_deferred(metrics::metric_hook(
887 node_metrics,
888 metrics::Metrics::chain_get_logs_deferred,
889 ))
890}
891
892/// Middle phase of [`run`] (#1253): parse the chain contract addresses and
893/// EIP-712 domains, bootstrap the `CapacityBond` registry / slash / origin
894/// watchers, and construct the probe, client, and DHT handlers plus the
895/// seller-side [`PoolSettlementService`]. Borrows [`Infra`]; the buyer-side
896/// provider/store construction and the background tasks belong to
897/// `spawn_background_tasks`. Returns [`ChainHandlers`], whose seller-wallet provider `P` [`run`]
898/// infers and threads into [`ShutdownHandles`].
899#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
900async fn build_chain_and_handlers(
901 cfg: &ResolvedConfig,
902 reload_state: &RuntimeReloadState,
903 infra: &Infra,
904) -> anyhow::Result<ChainHandlers<impl Provider + Clone + 'static>> {
905 // SlashJudge EIP-712 domain for probe `slash_sig` (ADR 014 §1–2). Parsed
906 // through the shared zero-address guard (#1219) for a uniform "every daemon
907 // contract address is non-zero" invariant. `resolve_blockchain` already
908 // rejects a zero or malformed `slash_judge_address` at config load, so here
909 // the guard is defense-in-depth rather than the sole check.
910 let slash_judge_addr = parse_nonzero_address(
911 &cfg.blockchain.slash_judge_address,
912 "blockchain.slash_judge_address",
913 )?;
914 let slash_domain =
915 decdn_incentive::slash_judge_domain(cfg.blockchain.chain_id, slash_judge_addr);
916
917 // Chain-backed active-staker set. Bootstrap failure is fatal on a
918 // deterministic fault or once its retries are exhausted (see the boot
919 // budget below): an
920 // empty set silently rejects every inbound `Store`, and once the
921 // iterative `FindValue` lookup filter exists it would drop every
922 // responder. Built here (ahead of the probe handler) so the probe
923 // handler can consult it for stake-lane probe-acceptance (#757); the
924 // DHT handler below shares the same `Arc`.
925 let rpc_url: HttpUrl = cfg.blockchain.rpc_url.parse().with_context(|| {
926 format!(
927 "blockchain.rpc_url {:?} is not a valid URL",
928 cfg.blockchain.rpc_url
929 )
930 })?;
931 let capacity_bond_addr = parse_nonzero_address(
932 &cfg.blockchain.capacity_bond_address,
933 "blockchain.capacity_bond_address",
934 )?;
935 // Mandatory for paid delivery. Config resolution rejects absence; this
936 // runtime guard preserves fail-closed behavior for directly constructed
937 // `ResolvedConfig` values as well.
938 let content_blacklist_addr = parse_nonzero_address(
939 cfg.blockchain
940 .content_blacklist_address
941 .as_deref()
942 .context("missing mandatory blockchain.content_blacklist_address")?,
943 "blockchain.content_blacklist_address",
944 )?;
945 let providers = ProviderFactory::new(rpc_url, Arc::clone(&infra.node_metrics));
946 // One value, two consumers (#1011/#1106): the multiplexed poller's merged
947 // `eth_getLogs` tick cadence, and — through `with_poll_interval` below — the
948 // pending-tx receipt heartbeat, overriding alloy's 250 ms localhost default
949 // that would hammer a dev anvil.
950 let event_poll_interval = Duration::from_millis(cfg.blockchain.event_poll_interval_ms);
951 let chain_provider = providers.read_only(event_poll_interval);
952 // One `eth_blockNumber` per TTL window for ALL watchers, instead of one per
953 // watcher per tick. Plain read-only provider: a head read needs no wallet or
954 // nonce filler, and coupling it to the signer stack would give every watcher's
955 // head read a dependency on it. Deliberately NOT wrapped in
956 // `with_poll_interval` — that only sets the pending-tx receipt heartbeat, and
957 // this provider never builds a `PendingTransactionBuilder`.
958 let head: Arc<dyn HeadSource> = Arc::new(SharedHead::new(
959 providers.shared_head(),
960 event_poll_interval,
961 ));
962 // Every on-chain watcher registers a `Route` on ONE shared multiplexed
963 // poller instead of running its own `eth_getLogs` loop: six watchers scan
964 // disjoint `(address, topic0)` slices of the same four contracts, so one
965 // merged `get_logs` per tick — demuxed after the fact — replaces six. The
966 // routes are collected as each watcher bootstraps below and spawned once (see
967 // `build`/`spawn` near the end of this function). The poller mints one
968 // shutdown token; the runtime drives graceful stop through the single handle.
969 let mut poller_routes: Vec<crate::chain_events::multiplexed_poller::Route> = Vec::new();
970 // One read-only provider for the poller's merged `get_logs` (each sink still
971 // holds its own contract instance for its follow-up reads/writes).
972 let poller_provider = providers.read_only(event_poll_interval);
973
974 // One boot deadline for the four fail-closed chain bootstraps below — the
975 // registry, slash, `usdc()` self-check and blacklist reads (#2159). Each
976 // retries its reads on transient provider errors and fails only on a
977 // deterministic fault or once the shared deadline passes. The best-effort
978 // fee-share reads retry on a capped sub-budget of it and fall back to the
979 // floor share instead of failing boot; the binding check does not use it.
980 // Startup stays fail-closed: a retry delays readiness, and the ALPN router
981 // waits on the blacklist enumeration and enforcement.
982 let boot = BootRetry::new(BOOT_CHAIN_RETRY_BUDGET, Arc::clone(&infra.node_metrics));
983
984 // One CapacityBond enumeration + one route feeding both registry projections
985 // (#1110). The bindings half is built only when pull-through is on; it derives
986 // from page data already read here, so it has no RPC that can fail on its own.
987 let registry = crate::dht::capacity_bond_registry::bootstrap(
988 chain_provider,
989 capacity_bond_addr,
990 Arc::clone(&head),
991 cfg.cache.node_to_node_pull_through_enabled,
992 Arc::clone(&infra.node_metrics),
993 &boot,
994 )
995 .await
996 .with_context(|| format!("CapacityBond registry bootstrap at {capacity_bond_addr}"))?;
997 let staker_set: Arc<dyn StakerSet> = registry.staker_set;
998 let registry_regions = Arc::clone(®istry.regions);
999 // The shared `operator address → NodeId` reverse projection (#1110), kept
1000 // current by the same registry route. The lazy `ChainOriginDirectory`
1001 // resolves against it directly rather than maintaining its own binding cache.
1002 let operator_to_node = Arc::clone(®istry.operator_to_node);
1003 // Registry multiaddrs as an endpoint address lookup (ADR 001 § Node
1004 // Discovery): every bare-`NodeId` dial resolves the node's registry
1005 // addresses alongside its pkarr record, so a peer stays directly dialable
1006 // when the pkarr server is unreachable or its record is not yet published.
1007 // The same registry route keeps the lookup current.
1008 infra
1009 .ep
1010 .address_lookup()
1011 .map_err(|e| anyhow::anyhow!("endpoint address lookup unavailable: {e}"))?
1012 .add(registry.dial_addrs.lookup());
1013 let own_id = crate::dht::routing::NodeId::from_bytes(*infra.ep.id().as_bytes());
1014 reachability::report(
1015 &infra.node_metrics,
1016 cfg.network.bind_port,
1017 ®istry.dial_addrs.get(&own_id).unwrap_or_default(),
1018 );
1019 // The registry route MUST stay live through drain (its staker set gates DHT
1020 // admission), which forces the single poller stop to the LATE point below.
1021 poller_routes.push(registry.route);
1022
1023 // Slash-detection watcher (#1032, G-NODE-05): enumerate this operator's
1024 // still-appealable slashes from `CapacityBond` and follow `SlashRecorded`, so
1025 // a slash surfaces over `admin_v1_slashes` (+ the `decdn_slashes_detected_total`
1026 // metric) and the operator can file `decdn appeal slash` in time. Read-only;
1027 // held to `run()`'s end so its background task lives as long as the daemon.
1028 // The boot enumeration retries on the shared boot budget, like the
1029 // CapacityBond registry bootstrap above; thereafter a tail blip retries and
1030 // the periodic resync heals drift, so detection is never disabled for the
1031 // daemon's lifetime.
1032 let (slash_store, slash_route) = crate::slash_watcher::bootstrap(
1033 providers.read_only(event_poll_interval),
1034 capacity_bond_addr,
1035 infra.eth_signer.address(),
1036 Arc::clone(&head),
1037 Arc::clone(&infra.node_metrics),
1038 &boot,
1039 )
1040 .await
1041 .context("bootstrap the slash-detection watcher")?;
1042 poller_routes.push(slash_route);
1043
1044 // NodeId → bonded operator address resolver for node-to-node pulls (#831),
1045 // produced by the same CapacityBond bootstrap as the staker set above and
1046 // `Some` exactly when pull-through is enabled. Held to provision the
1047 // `NodeOrigin` below.
1048 let node_address_resolver: Option<Arc<dyn crate::dht::NodeAddressResolver>> =
1049 if cfg.cache.node_to_node_pull_through_enabled {
1050 registry.node_addresses
1051 } else {
1052 None
1053 };
1054
1055 // Stake-lane probe-acceptance reservation (#757, ADR 003 §Admission and
1056 // Priority). Strictly operator opt-in: a policy is built only for a
1057 // non-zero `cache.stake_lane_reserved_holds` (the `NonZeroUsize` gate
1058 // makes the "off" case unrepresentable in `StakeLanePolicy`), so the
1059 // default single-lane node passes `None` and the probe handler's hot
1060 // path is unchanged. A reservation paired with disabled holds
1061 // (`max_probe_holds == 0`) can never fire — surface that misconfig as a
1062 // warning and leave the lane off rather than wire a dead per-probe lookup.
1063 let stake_lane_policy =
1064 NonZeroUsize::new(cfg.cache.stake_lane_reserved_holds).and_then(|reserved| {
1065 if cfg.cache.max_probe_holds == 0 {
1066 tracing::warn!(
1067 reserved_holds = reserved.get(),
1068 "cache.stake_lane_reserved_holds is set but cache.max_probe_holds=0; \
1069 the stake-lane reservation has no effect (probe holds are disabled)"
1070 );
1071 None
1072 } else {
1073 Some(ProbeStakeLanePolicy::new(
1074 Arc::clone(&staker_set),
1075 reserved,
1076 cfg.cache.max_probe_holds,
1077 ))
1078 }
1079 });
1080
1081 // ADR 005 §Probe rate limiting three-layer token-bucket limiter for
1082 // `cdn/probe/v1`. Built from `[probe.rate_limit]` and run *in addition* to
1083 // the shared `ConnectionLimiter` (see `probe_rate_limit` module docs); the
1084 // per-peer (NodeId) layer it adds is the gap #982 closed.
1085 // `from_resolved` rather than `new`: both limiter configs are aliases of
1086 // the same struct, so passing `&cfg.dht` here would compile (#1457).
1087 let probe_rate_limiter = Arc::new(ProbeRateLimiter::from_resolved(
1088 &cfg.probe,
1089 Arc::clone(&infra.node_metrics),
1090 ));
1091
1092 // Chain-read liveness (ADR 011 §Serving while chain-stale). One handle,
1093 // stamped by the blacklist watcher (boot enumeration + every successful poll
1094 // tick) and read by the probe and serve paths, which refuse once the node
1095 // has been unable to reach the chain for longer than the grace window. This
1096 // runtime path always wires the blacklist watcher — `content_blacklist_address`
1097 // is mandatory at node config resolution — so the gate is always live here;
1098 // the `Option` the handlers hold exists for the test paths that build a
1099 // handler with no chain.
1100 let chain_freshness = crate::chain_freshness::ChainFreshness::new(Duration::from_secs(
1101 cfg.blockchain.chain_staleness_grace_sec,
1102 ));
1103
1104 let probe_handler = Arc::new(ProbeHandler::new(
1105 infra.secret_key.public(),
1106 cfg.payment.rate_per_mb,
1107 Arc::clone(&infra.node_metrics),
1108 Arc::clone(&infra.limiter),
1109 Arc::clone(&probe_rate_limiter),
1110 infra.cache.clone(),
1111 Arc::clone(&infra.eth_signer),
1112 slash_domain.clone(),
1113 stake_lane_policy,
1114 cfg.cache.relay_foreign_namespaces,
1115 Some(chain_freshness.clone()),
1116 ));
1117
1118 // `cdn/dht/v1` handler (ADR 022 / #320): serves FindNode, FindValue, and
1119 // Store. Bootstrap, bucket refresh, and the republish scheduler run as
1120 // background tasks below. Three-layer rate limiter operates at the full
1121 // ADR 022 spec.
1122 let dht_rate_limiter = Arc::new(DhtRateLimiter::from_resolved(
1123 &cfg.dht,
1124 Arc::clone(&infra.node_metrics),
1125 ));
1126 // Record store sized from the ADR 022 defaults; per-publisher /
1127 // global / per-hash caps are pinned by the protocol and only the
1128 // TTL field is plausibly operator-tunable, but no knob is exposed
1129 // yet — operators with non-default needs should file a follow-up
1130 // rather than tune in TOML.
1131 let record_store = Arc::new(std::sync::Mutex::new(RecordStore::new(
1132 RecordStoreConfig::default(),
1133 )));
1134 // Origin directory for the node-origin FIND_VALUE reads — the last-resort
1135 // fallback when the DHT returns no providers, and the supplement when a ranged
1136 // pull's probed holders do not span the blob (ADR 022 §FIND_VALUE Flow; #912,
1137 // #2195) — and for the reactive pull-through gate. When the operator configures the
1138 // OriginAssignment + PublisherRegistry addresses, use the chain-backed
1139 // `ChainOriginDirectory` — a lazy TTL cache resolving hash → namespace →
1140 // authorized origin → active NodeId on demand, reusing the
1141 // already-bootstrapped `staker_set` and `operator_to_node` reverse
1142 // projection for operator liveness and binding. No bootstrap RPC: the
1143 // cache populates on the first lookup miss per namespace.
1144 // Without those addresses this is an `EmptyOriginDirectory`: the FIND_VALUE
1145 // fallback resolves nothing.
1146 let origin_directory: Arc<dyn crate::dht::origin::OriginDirectory> =
1147 if let Some(origin_addr) = cfg.blockchain.origin_assignment_address.as_deref() {
1148 let origin_assignment_addr =
1149 parse_nonzero_address(origin_addr, "blockchain.origin_assignment_address")?;
1150 Arc::new(crate::dht::ChainOriginDirectory::new(
1151 providers.read_only(event_poll_interval),
1152 origin_assignment_addr,
1153 Arc::clone(&operator_to_node),
1154 Arc::clone(&staker_set),
1155 reload_state.content_denylist(),
1156 cfg.blockchain.origin_directory_cache_capacity,
1157 Duration::from_secs(cfg.blockchain.origin_directory_positive_ttl_sec),
1158 Duration::from_secs(cfg.blockchain.origin_directory_negative_ttl_sec),
1159 Arc::clone(&infra.node_metrics),
1160 ))
1161 } else {
1162 Arc::new(crate::dht::origin::EmptyOriginDirectory)
1163 };
1164 let dht_handler = Arc::new(DhtHandler::new(
1165 infra.secret_key.public(),
1166 Arc::clone(&dht_rate_limiter),
1167 Arc::clone(&infra.limiter),
1168 Arc::clone(&infra.node_metrics),
1169 Arc::clone(&staker_set),
1170 Arc::clone(&record_store),
1171 ));
1172
1173 // The DHT handler builds its own routing table internally; grab a
1174 // shared handle so the bootstrap path + republish + bucket-refresh
1175 // tasks can all operate on the same instance.
1176 let dht_routing = dht_handler.routing_table();
1177
1178 // `cdn/client/v1` paid-delivery handler (#317). The voucher EIP-712 domain
1179 // binds to the `PaymentPool` deployment the lane store is stamped with
1180 // (`infra.payment_pool_deployment` — one parse, one source of truth); the
1181 // ephemeral-binding domain to the `CapacityBond` deployment
1182 // (== `capacity_bond_addr`, which holds the NodeId↔address mappings). The
1183 // handler hydrates per-channel voucher state from `channel_state_store` so
1184 // a restart cannot replay an already-accepted voucher (#527).
1185 let payment_pool_addr = infra.payment_pool_deployment.payment_pool;
1186
1187 // Live operator fee-share seed (ADR 041 / ADR 016 § Tunable Economics):
1188 // `PaymentPool.feeRouter()` resolves the router address, then
1189 // `FeeRouter.getShares()` reads the current 3-way split, narrowed to the
1190 // operator's bps. Unlike the fail-closed reads, NO leg of this chain is fatal
1191 // to boot: a failure falls back to `FEE_ROUTER_OPERATOR_BPS_FLOOR` and the
1192 // node still comes up. Both reads retry transient errors; the `feeRouter()`
1193 // retry matters most, because `feeRouter` is immutable and a missed read
1194 // leaves the `SharesUpdated` route below unregistered until restart. They
1195 // retry on a `FEE_SHARE_BOOT_BUDGET` sub-budget of the boot deadline, so a
1196 // flapping provider cannot spend the time the later `usdc()` and blacklist
1197 // reads need.
1198 let crate::fee_shares::FeeShareSeed {
1199 router: fee_router_addr,
1200 operator_bps: seed_operator_bps,
1201 } = crate::fee_shares::seed_from_chain(
1202 providers.read_only(event_poll_interval),
1203 payment_pool_addr,
1204 FEE_ROUTER_OPERATOR_BPS_FLOOR,
1205 &boot.capped(FEE_SHARE_BOOT_BUDGET),
1206 )
1207 .await;
1208 infra
1209 .node_metrics
1210 .fee_shares_watcher_unregistered(fee_router_addr.is_none());
1211 let operator_shares = crate::fee_shares::OperatorShares::new(seed_operator_bps);
1212 tracing::info!(
1213 bps = seed_operator_bps,
1214 "seeded live operator fee share for serve-economics"
1215 );
1216
1217 // ADR 041 buy-side profitability gate for cache-miss relay legs. Node-local,
1218 // config-selectable; `off` disables the economic ceiling entirely.
1219 let serve_economics = crate::serve_economics::select_policy(&cfg.cache.serve_economics);
1220
1221 // ADR 041 per-source warming allowance, bounding the loss from speculative
1222 // above-floor buys per upstream source. Built once and shared by the buy loop
1223 // (debit), the serve path (credit realized margin), and the eviction path
1224 // (forget a dropped hash's tag).
1225 let warming = Arc::new(crate::warming_allowance::WarmingAllowance::new(
1226 cfg.cache.serve_economics.warming_budget,
1227 cfg.cache.serve_economics.warming_refill,
1228 ));
1229
1230 // Bring-up self-check: is the key we are about to serve under the one bound
1231 // to this operator on-chain? A node that answers "no" is UNSLASHABLE
1232 // (`SlashJudge` resolves the accused through `nodeIdOf`), and nothing else
1233 // in the runtime would notice — so the check is here rather than left to an
1234 // operator to think of. Advisory: it never blocks startup, because a daemon
1235 // that refused to boot on a transient RPC failure could not be used to run
1236 // the rotation that repairs the binding. See `crate::binding_check`.
1237 let binding_report = crate::binding_check::check(
1238 providers.read_only(event_poll_interval),
1239 capacity_bond_addr,
1240 infra.eth_signer.address(),
1241 *infra.secret_key.public().as_bytes(),
1242 )
1243 .await;
1244
1245 let voucher_domain = infra.payment_pool_deployment.voucher_domain();
1246 let bind_domain =
1247 decdn_incentive::bind_node_id_domain(cfg.blockchain.chain_id, capacity_bond_addr);
1248
1249 // Reactive LOCAL-origin pull-through (#1116). Arm a local-only populate on the
1250 // serve-miss path whenever the operator configured any origin (`[cache.origin]`),
1251 // INDEPENDENT of `node_to_node_pull_through_enabled`: a cache-only operator must
1252 // be able to reactively serve content it holds in its OWN fs/http/s3 origin, and
1253 // — with node→node on — that local origin is preferred over the paid peer window
1254 // path. The handler still gates it on proven channel ownership
1255 // (`pull_authorized`), so it fronts no free egress, and `populate_local` never
1256 // consults the paid `Peer` node→node origin. The node→node
1257 // window/buffered/governor/gate paths below stay flag-gated.
1258 //
1259 // A single local origin-chain walk (fs/http/s3), NOT a provider fan-out — so
1260 // budget it at the per-attempt `node_pull_timeout_sec`, not `outer_pull_deadline`
1261 // (which budgets the sequential node→node pull). Using the outer deadline would
1262 // let a wedged local origin block many times longer (>7× at defaults) before
1263 // falling through to the node→node paths.
1264 let local_populate = (!cfg.cache.origins.is_empty())
1265 .then(|| Duration::from_secs(cfg.cache.node_pull_timeout_sec));
1266
1267 // Node→node pull-through deps (#831/#856/#821), all gated on
1268 // `node_to_node_pull_through_enabled`. Computed as `Option`s here so the handler's
1269 // wiring is one construction-time literal; the gates (and their nesting) are
1270 // preserved exactly.
1271 let mut pull_through = None;
1272 let mut pull_through_origin = None;
1273 if cfg.cache.node_to_node_pull_through_enabled {
1274 // The outer deadline bounds how long a miss blocks the delivery path before
1275 // falling back to `NotFound`. It is *derived* from the per-candidate budget
1276 // (`node_pull_timeout_sec`) rather than equal to it: the handler wraps the
1277 // whole `discover → probe → rank → pull` fetch in one `tokio::time::timeout`,
1278 // so an outer deadline equal to the per-candidate timeout would cancel the
1279 // fetch the instant candidate #1 stalls, before the `MAX_PROVIDER_ATTEMPTS`
1280 // fallback loop ever reaches candidates #2..N (#859). `outer_pull_deadline`
1281 // budgets all three of each candidate's sequential stages — channel open,
1282 // stream open, and one silent-streaming window — plus one-time discovery
1283 // slack. Whether the pull can actually succeed additionally depends on the
1284 // `NodeOrigin` being provisioned; an unprovisioned origin just makes the
1285 // `get` a fast miss.
1286 let per_candidate = Duration::from_secs(cfg.cache.node_pull_timeout_sec);
1287 let stall = Duration::from_secs(cfg.cache.node_pull_stall_window_sec);
1288 let outer_deadline = crate::selection::outer_pull_deadline(per_candidate, stall);
1289 pull_through = Some(outer_deadline);
1290 // Window-paced pull-through (#856, ADR 037): when the `NodeOrigin` is
1291 // available, serve cache misses by fusing the upstream pull with downstream
1292 // delivery, paced by the ramped credit window (ADR 003 §Credit window,
1293 // #1669) instead of the buffered `populate`.
1294 if let Some(origin) = &infra.pull_through_origin {
1295 pull_through_origin = Some(Arc::clone(origin));
1296 }
1297 }
1298
1299 // ADR 011 deny-set. Taken from the reload state rather than built here, so
1300 // the handler and the SIGHUP / `decdn node reload` path hold the SAME `Arc`
1301 // — a denylist entry added to the config file takes effect on reload with no
1302 // handler rebuild and no restart, which is what makes it usable against a
1303 // one-hour statutory clock. It is a compliance gate, so the deps literal
1304 // below names it like every other field: a wiring site that drops it fails
1305 // to compile rather than silently degrading to "deny nothing".
1306 let content_denylist = reload_state.content_denylist();
1307 // Redeem-hint channel (#327), created outside `PoolSettlementService::bootstrap`
1308 // so the sender can be cloned into the handler deps below while the service
1309 // takes the receiver. `redeem_tx` is cloned into the handler deps and also
1310 // handed to the service (so `redeem_hint_sender()` keeps working); `redeem_rx`
1311 // drives the service's redeemer loop.
1312 let (redeem_tx, redeem_rx) =
1313 tokio::sync::mpsc::channel(crate::payment_settlement::REDEEM_HINT_CAPACITY);
1314 // Overload-protection gate (load-shed): sheds new serves under resource
1315 // pressure so in-flight streams stay fast. Bound to a named local, not
1316 // inlined into the deps literal, so the reload section (`[load_shed]`,
1317 // hot-reloadable via `RuntimeReloadState::attach_load_shed`) and the
1318 // egress-EWMA sampling tick spawned in `spawn_background_tasks` both
1319 // reach the SAME controller.
1320 let shed_controller = crate::load_shed::LoadShedController::from_config(&cfg.load_shed);
1321 reload_state.attach_load_shed(Some(Arc::clone(&shed_controller)));
1322 // Coarse wall clock (issue #1792 item 4): a background task refreshes an
1323 // atomic every 500 ms so the voucher-accept path's two under-lock reads — the
1324 // capability-expiry gate and the `last_voucher_at` liveness stamp — cost a
1325 // relaxed atomic load instead of a `SystemTime::now()` syscall inside the
1326 // per-lane critical section. The refresher holds only a `Weak`, so it
1327 // self-terminates when the handler (the clock's last owner) drops at
1328 // shutdown and needs no stop handle.
1329 let coarse_clock = Arc::new(crate::coarse_clock::CoarseClock::new());
1330 crate::coarse_clock::CoarseClock::spawn_refresher(
1331 &coarse_clock,
1332 std::time::Duration::from_millis(500),
1333 );
1334 // Event-fed pool view (owner + remaining) for the floor-`M` solvency gate and
1335 // the ADR 011 funder gate. The settlement watcher below folds every
1336 // `PaymentPool` event into this projection, so a serve request reads
1337 // `{owner, remaining}` in-memory — no per-serve `getPool` `eth_call`. The same
1338 // instance is handed to the settlement service (its watcher is the writer).
1339 let pool_view = crate::pool_view::PoolProjection::new();
1340 // Wallet-filled provider + `PaymentPool` binding, created here so the handler's
1341 // pool-view (below) can confirm a projection-miss pool with a `getPool` at
1342 // admission. The same provider is moved into the settlement service, which
1343 // builds its own binding from the same address; the buyer path builds its own
1344 // provider separately.
1345 let wallet_provider = providers.seller_wallet((*infra.eth_signer).clone(), event_poll_interval);
1346 // Admit-path pool-view: reads the event-fed projection first and, on a miss
1347 // (a pool opened before the watcher's `ColdStart::Head` anchor), does ONE
1348 // `getPool` to confirm the pool is live and solvent before a serve is admitted
1349 // — folding a servable pool into the projection and refusing an absent/closed/
1350 // errored one. The mid-stream re-check reads the projection only.
1351 let resolving_pool_view = crate::payment_settlement::ResolvingPoolView::new(
1352 decdn_incentive::payment_pool::PaymentPool::new(payment_pool_addr, wallet_provider.clone()),
1353 pool_view.clone(),
1354 Arc::clone(&infra.node_metrics),
1355 );
1356 // ADR 041 serve-credit inputs: a non-blocking sink in front of the SAME warming
1357 // allowance the buy loop debits and the eviction path forgets, plus the live
1358 // operator fee-share cell. The background aggregator behind the sink is what
1359 // keeps a serve's final step off the bucket lock the buy loop takes. Eviction
1360 // shares the allowance but not that lock — `forget` touches the tag map only.
1361 let (warming_credit, warming_creditor) = crate::warming_allowance::spawn_warming_creditor(
1362 Arc::clone(&warming),
1363 Arc::clone(&infra.node_metrics),
1364 );
1365 // One exhaustive literal, never a setter chain (#1254). Every field is named
1366 // here, so adding one to `ClientHandlerDeps` fails this call site rather than
1367 // defaulting silently, and deleting a wiring line is a missing-field error
1368 // instead of a live node that quietly runs the inert default. That matters
1369 // most for the economic hooks: a dropped `warming_credit` degrades to
1370 // `NoopWarmingCreditSink` and a dropped `operator_shares` to a zero share,
1371 // and both lose money on a real node while every test stays green.
1372 let client_deps = crate::handlers::client::ClientHandlerDeps {
1373 node_id: infra.secret_key.public(),
1374 metrics: Arc::clone(&infra.node_metrics),
1375 limiter: Arc::clone(&infra.limiter),
1376 shed: Arc::clone(&shed_controller),
1377 cache: infra.cache.clone(),
1378 eth_signer: Arc::clone(&infra.eth_signer),
1379 slash_domain: slash_domain.clone(),
1380 voucher_domain: voucher_domain.clone(),
1381 bind_domain: bind_domain.clone(),
1382 channel_state_store: Arc::clone(&infra.channel_state_store),
1383 receipt_sink: Arc::clone(&infra.receipt_sink),
1384 pool_view: Some(Arc::new(resolving_pool_view) as Arc<dyn crate::pool_view::PoolView>),
1385 pool_min_remaining_deposit: U256::from(
1386 cfg.blockchain.pool_min_remaining_deposit_micro_usdc,
1387 ),
1388 rate_per_mb: cfg.payment.rate_per_mb,
1389 max_concurrent_streams: MAX_CLIENT_STREAMS,
1390 content_deny: Arc::clone(&content_denylist),
1391 chain_freshness: Some(chain_freshness.clone()),
1392 // Hint the settlement service on each accepted voucher so a lane's
1393 // accrued claim is planned into a chunk promptly rather than waiting the
1394 // self-tick.
1395 redeem_hint: Some(redeem_tx.clone()),
1396 pull_through,
1397 local_populate,
1398 pull_through_origin,
1399 // Downstream credit-window ramp (ADR 003 §Credit window, #1477, #1669).
1400 credit_max: cfg.payment.credit_max,
1401 credit_ramp_divisor: cfg.payment.credit_ramp_divisor,
1402 frame_target_bytes: cfg.payment.frame_target_bytes,
1403 idle_timeout: None,
1404 pool_recheck_interval: None,
1405 // Per-signer floor gate (ADR 003 §Pool solvency): the live concurrency cap
1406 // `k` underneath the per-pool `remaining − M` ceiling, which bounds how much
1407 // live un-vouchered floor one capability signer may hold at once.
1408 pool_floor_signer_live_windows: cfg.blockchain.pool_floor_signer_live_windows,
1409 warming_credit,
1410 operator_shares: operator_shares.clone(),
1411 // Origin-only policy (#1759): backend-authoritative own/foreign decision.
1412 relay_foreign_namespaces: cfg.cache.relay_foreign_namespaces,
1413 coarse_clock: Some(coarse_clock),
1414 // The capability-expiry margin covers one sweep of the redeemer below.
1415 redeem_interval_secs: cfg.blockchain.redeem_interval_secs,
1416 };
1417 let client_handler = Arc::new(ClientHandler::new(client_deps)?);
1418
1419 // On-chain seller-settlement service (#327). A wallet-filled provider
1420 // (the staker-set provider above is read-only) signs the `redeemMany`
1421 // transactions with the same eth keystore signer. The bootstrap
1422 // self-checks the contract via `usdc()`; the watcher folds `PaymentPool`
1423 // events so the serve path reads pool solvency in-memory, and forgets a
1424 // reclaimed pool's lanes. Redemption is purely periodic: a self-tick
1425 // flushes the lane store then sweeps every above-threshold lane.
1426 // Simple (re-fetch-each-send) nonce management, not alloy's default cached
1427 // manager (#904). The cached manager advances its in-memory nonce when it
1428 // *prepares* a tx; if that send then fails (e.g. its `eth_estimateGas`
1429 // reverts on an always-reverting settlement under contention), the tx never
1430 // lands but the cached nonce stays advanced, so every subsequent tx from
1431 // this wallet carries a gapped nonce, sits unmined, and wedges the lane
1432 // until restart. `SimpleNonceManager` stores nothing — each send re-reads
1433 // the pending nonce — so a failed send can't gap the lane. The buyer
1434 // provider below relies on this same property for the retried `reclaimExpired`.
1435 // `wallet_provider` was built above (the handler's admit-path pool-view shares
1436 // it); it moves into the service here.
1437 let (payment_service, settlement_route) = PoolSettlementService::bootstrap(
1438 wallet_provider,
1439 payment_pool_addr,
1440 infra.eth_signer.address(),
1441 Arc::clone(&infra.channel_state_store),
1442 Arc::clone(&infra.watcher_checkpoint_store),
1443 Arc::clone(&client_handler),
1444 U256::from(cfg.blockchain.redeem_threshold_micro_usdc),
1445 usize::try_from(cfg.blockchain.redeem_max_vouchers_per_tx).unwrap_or(usize::MAX),
1446 Duration::from_secs(cfg.blockchain.redeem_interval_secs),
1447 Arc::clone(&infra.node_metrics),
1448 &boot,
1449 pool_view,
1450 redeem_tx,
1451 redeem_rx,
1452 )
1453 .await
1454 .context("PaymentPool settlement service bootstrap")?;
1455 poller_routes.push(settlement_route);
1456
1457 // Blacklist compliance watcher (ADR 011/031, issue #1031). Its boot pass
1458 // ENUMERATES the current on-chain deny-set at one pinned block
1459 // (`blacklistedAddresses` ∪ per-region `blacklistedHashes`) rather than
1460 // replaying logs, then follows the live tail seeded at that block. It evicts
1461 // held blobs whose hash is blacklisted in scope for this operator, which
1462 // cascades to DHT-announce suppression (the republisher's `is_evicted` gate),
1463 // probe `has_blob:false`, and delivery refusal — the node's only local
1464 // protection against the slash for serving blacklisted content.
1465 // `blacklist_ready_rx` gates the ALPN router below on that first enumeration.
1466 // The route carries a shutdown-token-observing sink factory (its re-scope
1467 // polls the poller's token, #1236); the tail carries no durable cursor, so
1468 // teardown has no scan checkpoint to flush.
1469 let (blacklist_ready_tx, blacklist_ready_rx) = oneshot::channel();
1470 let blacklist_route = crate::blacklist_watcher::bootstrap(
1471 providers.read_only(event_poll_interval),
1472 content_blacklist_addr,
1473 infra.eth_signer.address(),
1474 infra.cache.clone(),
1475 Arc::clone(&warming),
1476 Arc::clone(&head),
1477 Duration::from_secs(cfg.blockchain.content_blacklist_poll_interval_sec),
1478 blacklist_ready_tx,
1479 &infra.node_metrics,
1480 Arc::clone(&content_denylist),
1481 chain_freshness,
1482 &boot,
1483 )
1484 .await
1485 .context("blacklist compliance watcher boot enumeration")?;
1486 poller_routes.push(blacklist_route);
1487
1488 // Fee-shares route (ADR 041 / ADR 016 § Tunable Economics): follows
1489 // `SharesUpdated` on the shared poller and re-reads `getShares()`
1490 // authoritatively every `fee_shares_poll_interval_sec` as a safety net,
1491 // storing into the same shared `operator_shares` cell seeded by the startup
1492 // read above. Only registered when the startup `feeRouter()` read succeeded
1493 // — with no router address there is nothing to watch, and the seed already
1494 // fell back to `FEE_ROUTER_OPERATOR_BPS_FLOOR`. Read-only, no durable cursor.
1495 if let Some(addr) = fee_router_addr {
1496 let fee_shares_route = crate::fee_shares_watcher::route(
1497 providers.read_only(event_poll_interval),
1498 addr,
1499 operator_shares.clone(),
1500 Duration::from_secs(cfg.blockchain.fee_shares_poll_interval_sec),
1501 &infra.node_metrics,
1502 );
1503 poller_routes.push(fee_shares_route);
1504 }
1505
1506 // Assemble and spawn the ONE poller for all routes. `build` fails fast if
1507 // two routes claim the same `(address, topic0)` — a wiring bug, not a
1508 // runtime condition — surfacing it at boot. Up to four contract addresses
1509 // (payment_pool, capacity_bond, content_blacklist, fee_router) with
1510 // disjoint topic0s per (address, topic0).
1511 let poller = chain_poller_builder(
1512 Arc::clone(&head),
1513 event_poll_interval,
1514 &cfg.blockchain,
1515 &infra.node_metrics,
1516 );
1517 let poller = poller_routes
1518 .into_iter()
1519 .fold(
1520 poller,
1521 crate::chain_events::multiplexed_poller::MultiplexedPollerBuilder::route,
1522 )
1523 .build()
1524 .context("assemble the multiplexed chain-event poller")?;
1525 let poller = crate::chain_events::multiplexed_poller::spawn(poller_provider, poller);
1526
1527 // Bootstrap (ADR 022 §Bootstrap): seed the routing table from the
1528 // active-staker set + parallel `FindNode(self.node_id)` against a
1529 // fan-out of seeds. Best-effort — failures here log but don't
1530 // abort startup.
1531 let bootstrap_outcome = crate::dht::bootstrap::bootstrap(
1532 &infra.ep,
1533 infra.secret_key.public(),
1534 &dht_routing,
1535 &staker_set,
1536 )
1537 .await;
1538 tracing::info!(
1539 seeds = bootstrap_outcome.seeds_seen,
1540 inserted = bootstrap_outcome.seeds_inserted,
1541 find_node_ok = bootstrap_outcome.find_node_ok,
1542 find_node_err = bootstrap_outcome.find_node_err,
1543 closer_added = bootstrap_outcome.closer_peers_inserted,
1544 "dht bootstrap complete"
1545 );
1546 infra.node_metrics.dht_bootstrap_find_node_failures(
1547 u64::try_from(bootstrap_outcome.find_node_err).unwrap_or(u64::MAX),
1548 );
1549 infra
1550 .node_metrics
1551 .dht_routing_table_size(crate::dht::chain_projection::with_lock(
1552 &dht_routing,
1553 "dht routing table",
1554 |table| table.len(),
1555 ));
1556
1557 Ok(ChainHandlers {
1558 providers,
1559 event_poll_interval,
1560 slash_domain,
1561 bind_domain,
1562 staker_set,
1563 poller,
1564 slash_store,
1565 node_address_resolver,
1566 probe_rate_limiter,
1567 probe_handler,
1568 dht_rate_limiter,
1569 record_store,
1570 origin_directory,
1571 dht_handler,
1572 dht_routing,
1573 registry_regions,
1574 client_handler,
1575 shed_controller,
1576 payment_service,
1577 blacklist_ready_rx,
1578 binding_report,
1579 serve_economics,
1580 operator_shares,
1581 warming,
1582 warming_creditor,
1583 })
1584}
1585
1586/// Owned handle bundle produced by [`spawn_background_tasks`]: every local born
1587/// in the background region that [`run`] still needs after it returns — the
1588/// stop signals, task handles, and drain trigger that flow into
1589/// [`serve_until_shutdown`] and [`ShutdownHandles`]. Non-generic: the buyer
1590/// provider is consumed inside the region and the settlement indexer stores a
1591/// concrete watcher handle, so no opaque provider type escapes.
1592struct Background {
1593 metrics_stop_tx: oneshot::Sender<()>,
1594 dispatch_gc_stop_tx: oneshot::Sender<()>,
1595 egress_ewma_stop_tx: oneshot::Sender<()>,
1596 /// Origin-held-index periodic rescan (#1130). `None` when
1597 /// `cache.fs_rescan_interval_sec == 0` disables it.
1598 origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
1599 record_store_gc_stop_tx: oneshot::Sender<()>,
1600 eviction_stop_tx: oneshot::Sender<()>,
1601 dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
1602 probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
1603 reputation_evict_stop_tx: oneshot::Sender<()>,
1604 republish_stop: CancellationToken,
1605 bucket_refresh_stop_tx: oneshot::Sender<()>,
1606 buyer_bootstrap_stop_tx: oneshot::Sender<()>,
1607 rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
1608 admin_stop_tx: Option<oneshot::Sender<()>>,
1609 drain_trigger: Arc<admin::DrainTrigger>,
1610 tasks: JoinSet<()>,
1611}
1612
1613/// Background-tasks phase of [`run`] (#1253): construct the buyer-side
1614/// provider/stores, spawn every periodic GC / DHT / metrics / admin task, and
1615/// emit the startup banner. Borrows [`Infra`] and [`ChainHandlers`]; the buyer
1616/// provider is built from `ch.providers`, and the three EIP-712 domains are
1617/// cloned out of the shared `ch` reference,
1618/// and each clone is consumed exactly once. Returns the [`Background`] handles
1619/// [`run`] threads into the serve call and [`ShutdownHandles`].
1620#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1621async fn spawn_background_tasks<P: Provider + Clone + 'static>(
1622 cfg: &ResolvedConfig,
1623 config_path: Option<&PathBuf>,
1624 reload_state: &Arc<RuntimeReloadState>,
1625 infra: &Infra,
1626 ch: &ChainHandlers<P>,
1627 started_at: std::time::Instant,
1628) -> anyhow::Result<Background> {
1629 // On-chain buyer-side service (#744). When this node pulls content from an
1630 // upstream provider on a cache miss it pays via the same channel mechanism,
1631 // acting as the client: a separate wallet-filled provider signs `approve` /
1632 // `openChannel` / `reclaimExpired`. It shares the persistent store (a
1633 // distinct `buyer_channel_state_v2` table) and re-derives the voucher domain
1634 // the handler consumed above. The cache-miss hook that *calls*
1635 // `open_or_reuse_channel` is the `NodeOrigin` provisioned below (#831), gated
1636 // on `cache.node_to_node_pull_through_enabled`; the service is also held for
1637 // the process lifetime so its reclaim sweep keeps running even when
1638 // pull-through is off. The backgrounded bootstrap task (#1109, below) owns
1639 // that binding via its `let _service` hold — and thus keeps the service's
1640 // `AbortOnDropHandle` reclaim task alive to shutdown.
1641 //
1642 // Unlike the seller service, a buyer-bootstrap failure is NON-fatal: buying
1643 // is opportunistic cost-recovery, so a failed startup `approve` tx (e.g.
1644 // insufficient gas) must not block the node's core seller function. Log and
1645 // continue with the buyer path disabled (and thus pull-through disabled).
1646 // Simple nonce management, for the reason given on the seller
1647 // `wallet_provider` above (#904). It matters most here: `reclaimExpired` is
1648 // *expected* to revert under host-clock-vs-chain skew and be retried, so a
1649 // reverting send must not leak a cached nonce and wedge the buyer lane. This
1650 // provider is shared across `approve`/`openChannel`/`topUp`/`reclaimExpired`
1651 // and the reclaim sweep runs concurrently with opens, so correctness relies
1652 // on `SimpleNonceManager` re-reading the pending nonce each send (a transient
1653 // racing collision just gets a fresh nonce on the next attempt), not on the
1654 // sends being strictly serialized.
1655 let buyer_wallet_provider = ch
1656 .providers
1657 .buyer_wallet((*infra.eth_signer).clone(), ch.event_poll_interval);
1658 let buyer_channel_store: Arc<dyn decdn_incentive::BuyerPoolStore> = Arc::new(
1659 crate::channel_store::BuyerPoolStoreHandle::new(Arc::clone(&infra.concrete_channel_store)),
1660 );
1661 // `admin_v1_pools` reads the SAME store the bootstrap below writes adoptions
1662 // and top-ups into. It has to be the same handle, not a second open of
1663 // `buyer.redb`: redb holds a process-exclusive lock on that file for the
1664 // daemon's lifetime, which is why no CLI can read it from disk (#2078).
1665 let buyer_channel_store_for_admin = Arc::clone(&buyer_channel_store);
1666 // The buyer-side PaymentPool bootstrap (whose on-chain round-trips —
1667 // notably the one-time USDC `approve` receipt — can block for many
1668 // minutes on a stuck tx, bounded by `APPROVE_RECEIPT_TIMEOUT`) and
1669 // the node-origin pull-through provisioning it feeds are BOTH deferred to a
1670 // background task spawned below (#1109), so the metrics/admin listeners and
1671 // the "node runtime ready" banner come up independent of any chain RPC. The
1672 // provider/store/pending handles built just above are moved into that task.
1673
1674 let metrics_addr = std::net::SocketAddr::new(
1675 cfg.observability.metrics_bind,
1676 cfg.observability.metrics_port,
1677 );
1678 let metrics_listener =
1679 metrics::bind(metrics_addr).context("failed to bind metrics listener")?;
1680
1681 let (metrics_stop_tx, metrics_stop_rx) = oneshot::channel::<()>();
1682
1683 let metrics_handle = Arc::clone(&infra.node_metrics);
1684 let mut tasks = JoinSet::new();
1685 tasks.spawn(async move {
1686 if let Err(err) = metrics::serve(metrics_listener, metrics_handle, metrics_stop_rx).await {
1687 tracing::error!(error = %err, "metrics server exited with error");
1688 }
1689 });
1690
1691 // Periodic dispatch-limiter GC (#440). The acquire path only prunes
1692 // under flood (when the keyspace exceeds `cap + cap/10`); a node
1693 // with bursty short-lived clients can otherwise accumulate stale
1694 // per-source buckets between bursts and never reclaim them until
1695 // restart. The sweep is `O(n)` over the live keyspace; on an idle
1696 // limiter `n = 0` so the steady-state cost is one mutex acquire
1697 // per minute. Stops on its own oneshot — same pattern as the
1698 // metrics and admin servers.
1699 let dispatch_gc_stop_tx = {
1700 let limiter = Arc::clone(&infra.limiter);
1701 spawn_periodic(&mut tasks, "dispatch_gc", DISPATCH_GC_INTERVAL, move || {
1702 if let Some((before, after)) = limiter.gc_per_source() {
1703 tracing::debug!(
1704 before,
1705 after,
1706 dropped = before.saturating_sub(after),
1707 "dispatch GC sweep complete"
1708 );
1709 }
1710 })
1711 };
1712
1713 // Periodic load-shed egress-EWMA sample: folds the last interval's served
1714 // bytes into the controller's smoothed rate and republishes it as the
1715 // gauges the `ResourcePressure` policy's egress-budget check reads live
1716 // from `try_admit`, and that operators watch via metrics.
1717 let egress_ewma_stop_tx = {
1718 let shed = Arc::clone(&ch.shed_controller);
1719 let warming = Arc::clone(&ch.warming);
1720 let metrics = Arc::clone(&infra.node_metrics);
1721 spawn_periodic(&mut tasks, "egress_ewma", EGRESS_EWMA_INTERVAL, move || {
1722 let bps = shed.sample_egress(EGRESS_EWMA_INTERVAL.as_secs());
1723 metrics.load_shed_egress_bps(i64::try_from(bps).unwrap_or(i64::MAX));
1724 metrics.load_shed_pressure_active(shed.pressure_active());
1725 // Live concurrency against the shed high-water mark (#load-shed
1726 // telemetry), and how many upstream sources warming has cut off
1727 // (ADR 041), sampled on the same interval as the egress EWMA.
1728 metrics.load_shed_streams_in_flight(shed.node_in_flight());
1729 metrics.warming_sources_blocked(warming.blocked_source_count());
1730 })
1731 };
1732
1733 // Periodic DHT record-store GC (ADR 022 §Content Records and TTL).
1734 // `RecordStore::providers_at` lazily scrubs expired records for the hash it
1735 // is queried about, but a hash nobody queries again keeps its expired
1736 // entries — they keep counting against the publisher's per-publisher quota
1737 // and against the global cap until this sweep fires. Without it a node that
1738 // publishes a one-shot blob can exhaust its 200-record quota and reject
1739 // every future `Store`. The sweep is `O(expired × log N)` over the global
1740 // LRU (front-walk in `RecordStore::gc`); default 60s is well below the
1741 // 1-hour record TTL.
1742 let record_store_gc_stop_tx = {
1743 let records = Arc::clone(&ch.record_store);
1744 spawn_periodic(
1745 &mut tasks,
1746 "record_store_gc",
1747 DISPATCH_GC_INTERVAL,
1748 move || {
1749 let now_us = std::time::SystemTime::now()
1750 .duration_since(std::time::UNIX_EPOCH)
1751 .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX));
1752 let removed = if let Ok(mut store) = records.lock() {
1753 store.gc(now_us)
1754 } else {
1755 tracing::error!("dht record-store mutex poisoned; skipping GC sweep this tick");
1756 0
1757 };
1758 if removed > 0 {
1759 tracing::debug!(removed, "dht record-store GC sweep complete");
1760 }
1761 },
1762 )
1763 };
1764
1765 // Cache-eviction driver (#1173, ADR 040). Async
1766 // (the sweep does `total_bytes()`/`release_for_eviction().await`), so it
1767 // cannot ride `spawn_periodic`'s sync `FnMut`; it spawns directly into the
1768 // JoinSet with its own oneshot stop, mirroring the metrics server. Enforces
1769 // the `cache.cache_size_mb` ceiling the write path leaves unbounded.
1770 let eviction_stop_tx = {
1771 // The driver's actuator is `release_for_eviction`, which only drops GC
1772 // protection — the iroh-blobs GC sweep is what actually reclaims disk.
1773 // With the sweep disabled the ceiling is unenforceable, and the symptom
1774 // (a cache pinned at 100% while the driver reports work) points at
1775 // pinning/probe-holds rather than at the real cause. Say so at boot.
1776 if cfg.cache.gc_interval_sec == 0 {
1777 tracing::warn!(
1778 cache_size_mb = cfg.cache.cache_size_mb,
1779 "eviction driver is running but cache.gc_interval_sec = 0: released blobs are \
1780 never reclaimed, so cache.cache_size_mb cannot be enforced. Set a nonzero \
1781 gc_interval_sec (default 300) to make eviction reclaim disk."
1782 );
1783 }
1784 let (eviction_stop_tx, eviction_stop_rx) = oneshot::channel::<()>();
1785 let params = eviction::EvictionParams {
1786 cache_size_mb: cfg.cache.cache_size_mb,
1787 disk_headroom_mb: cfg.cache.disk_headroom_mb,
1788 high_water_pct: cfg.cache.eviction_high_water_pct,
1789 target_pct: cfg.cache.eviction_target_pct,
1790 per_sweep_budget: cfg.cache.eviction_per_sweep_budget,
1791 tick: Duration::from_secs(cfg.cache.eviction_tick_secs),
1792 };
1793 tasks.spawn(eviction::run(
1794 infra.cache.clone(),
1795 infra.node_metrics.cache_metrics(),
1796 params,
1797 cfg.cache.cache_dir.clone(),
1798 infra.eviction_policy.clone(),
1799 // ADR 041: the SAME warming allowance the buy loop debits and the serve
1800 // path credits — the driver forgets an evicted hash's tag.
1801 Arc::clone(&ch.warming),
1802 eviction_stop_rx,
1803 ));
1804 eviction_stop_tx
1805 };
1806
1807 // Periodic DHT rate-limiter GC (#645). The acquire path opportunistically
1808 // prunes when the per-IP / per-peer keyed maps exceed `cap + cap/10`, but a
1809 // node whose DHT traffic falls below that threshold can carry millions of
1810 // stale buckets indefinitely — the same DoS shape the dispatch-limiter GC
1811 // prevents at the connection layer. `gc_per_ip` / `gc_per_peer` use
1812 // independent single-flight flags, so the two sequential sweeps here never
1813 // block a concurrent lazy prune from `check`.
1814 //
1815 // Panic contract: a panic inside `retain_recent` (governor arithmetic bug,
1816 // allocator OOM during the walk) unwinds out of the sweep and kills this
1817 // task. `PruneGuard`'s Drop still releases the single-flight flag, so the
1818 // lazy-prune path in `check` keeps working — but the periodic sweep is dead
1819 // until restart, surfacing only via `JoinSet::join_next` at shutdown.
1820 // Operators wanting earlier notice should alert on the
1821 // `decdn_dht_rate_limit_tracked_{per_ip,per_peer}` gauges failing to drop on
1822 // a quiet node (there the periodic sweep is their only writer). A map already
1823 // empty when the sweep died sits at `0` legitimately — indistinguishable
1824 // from a healthy sweep over an empty map.
1825 let dht_rate_limit_gc_stop_tx = {
1826 let limiter = Arc::clone(&ch.dht_rate_limiter);
1827 spawn_periodic(
1828 &mut tasks,
1829 "dht_rate_limit_gc",
1830 DHT_RATE_LIMIT_GC_INTERVAL,
1831 move || {
1832 log_keyspace_gc("dht", "per_ip", limiter.gc_per_ip());
1833 log_keyspace_gc("dht", "per_peer", limiter.gc_per_peer());
1834 },
1835 )
1836 };
1837
1838 // Probe rate-limiter keyspace GC (#982) — same DoS-shape rationale and panic
1839 // contract as the DHT GC above, for the `cdn/probe/v1` per-IP / per-peer
1840 // keyed maps.
1841 let probe_rate_limit_gc_stop_tx = {
1842 let limiter = Arc::clone(&ch.probe_rate_limiter);
1843 spawn_periodic(
1844 &mut tasks,
1845 "probe_rate_limit_gc",
1846 PROBE_RATE_LIMIT_GC_INTERVAL,
1847 move || {
1848 log_keyspace_gc("probe", "per_ip", limiter.gc_per_ip());
1849 log_keyspace_gc("probe", "per_peer", limiter.gc_per_peer());
1850 },
1851 )
1852 };
1853
1854 // DHT republish scheduler (ADR 022 §STORE Flow). The subscribe
1855 // handle is taken before the cold-start seed so an announcement racing
1856 // with seed-time lands in the channel backlog rather than the
1857 // gap between the snapshot and the spawn.
1858 let republish_scheduler = Arc::new(crate::dht::RepublishScheduler::new());
1859 // A token rather than a `oneshot`: the republisher's detached lag sweep
1860 // selects against the same signal, and shutdown must cancel it *before*
1861 // `cache.shutdown()` flushes the store. A signal the republisher forwards
1862 // only once it is next polled cannot give that ordering.
1863 let republish_stop = CancellationToken::new();
1864 let cache_inserts_rx = infra.cache.subscribe_inserts();
1865 // Walk the on-disk store (NOT `access_times_snapshot`, which is
1866 // eviction-recency state rather than the on-disk set) so every held (complete or
1867 // partial), non-evicted blob gets a `uniform(0, 40 min)` republish entry
1868 // per ADR 022 §Bootstrap AC 15. On a transient list-error we degrade: the
1869 // steady-state `subscribe_inserts` path catches only blobs newly fetched
1870 // or blocks newly completed post-boot — blobs already on disk that get
1871 // cache-HIT requests are NOT re-scheduled until the next lag sweep or
1872 // successful restart.
1873 // Populate the origin-held index before seeding announces (#1130) so cold
1874 // origin content (fs directory entries + present pins) is advertised from
1875 // the first republish, not only after a warm pulls it into the store.
1876 infra.cache.rescan_origins().await;
1877
1878 // Union stored blobs (complete or partial) with origin-held content — the
1879 // same snapshot the republisher's lag sweep re-seeds from, so the two
1880 // cannot drift apart.
1881 // The union coalesces a hash that is both stored and origin-held into one
1882 // jitter draw, and on a store list-error the origin-held half still seeds:
1883 // announce degrades only for the store half. See `holder_snapshot` for why
1884 // the store half is skipped under the origin-only policy.
1885 let snapshot =
1886 crate::dht::publish::holder_snapshot(&infra.cache, cfg.cache.relay_foreign_namespaces)
1887 .await;
1888 // Same counters and lines as the lag sweep's degradation: both are this one
1889 // derivation coming up short, and the boot case is the worse of the two —
1890 // it lasts the whole process lifetime rather than until the next lag
1891 // (ADR 022 §Bootstrap AC 15 degraded).
1892 snapshot.report_degradation(&infra.node_metrics, "cold start");
1893 let cold_start_count = republish_scheduler.seed_cold_start(
1894 snapshot
1895 .hashes
1896 .into_iter()
1897 .map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
1898 );
1899 tracing::info!(
1900 cold_start_count,
1901 "republish scheduler seeded from existing cache + origin-held index (ADR 022 §Bootstrap cold-start, #1130)"
1902 );
1903 tasks.spawn(crate::dht::publish::run_republish(
1904 infra.ep.clone(),
1905 infra.secret_key.public(),
1906 Arc::clone(&ch.dht_routing),
1907 Arc::clone(&republish_scheduler),
1908 infra.cache.clone(),
1909 cfg.cache.relay_foreign_namespaces,
1910 Arc::clone(&infra.node_metrics),
1911 cache_inserts_rx,
1912 republish_stop.clone(),
1913 ));
1914
1915 // Periodic origin rescan (#1130): re-walk the fs origin + re-check pins so a
1916 // file added after boot becomes discoverable within one interval, then push
1917 // newly-found hashes into the republish scheduler — origin content never
1918 // generates a store-insert event, so this is its only steady-state announce
1919 // trigger. `fs_rescan_interval_sec == 0` disables it (startup + reload still
1920 // rescan). The tick body offloads the async rescan to a detached task
1921 // (`spawn_periodic` bodies are synchronous); scheduling is idempotent, so an
1922 // already-announced hash coalesces rather than double-drawing jitter.
1923 let origin_rescan_stop_tx = if cfg.cache.fs_rescan_interval_sec > 0 {
1924 let cache = infra.cache.clone();
1925 let scheduler = Arc::clone(&republish_scheduler);
1926 Some(spawn_periodic(
1927 &mut tasks,
1928 "origin_rescan",
1929 Duration::from_secs(cfg.cache.fs_rescan_interval_sec),
1930 move || {
1931 let cache = cache.clone();
1932 let scheduler = Arc::clone(&scheduler);
1933 tokio::spawn(async move {
1934 cache.rescan_origins().await;
1935 let report = cache.origin_held_snapshot();
1936 // The steady-state announce trigger reports a short set the
1937 // same way the boot seed and the lag sweep do. It runs far
1938 // more often than either, so leaving it silent would make the
1939 // most common truncation the least visible one.
1940 if report.probe_faults > 0 || report.enumerate_failures > 0 {
1941 tracing::warn!(
1942 faults = report.probe_faults,
1943 enumerate_failures = report.enumerate_failures,
1944 "origin rescan could not resolve every candidate; the announce set omits anything it could not confirm and no earlier pass had indexed"
1945 );
1946 }
1947 scheduler.seed_cold_start(
1948 report
1949 .hashes
1950 .into_iter()
1951 .map(|h| decdn_protocol::ContentHash::from_bytes(*h.as_bytes())),
1952 );
1953 });
1954 },
1955 ))
1956 } else {
1957 None
1958 };
1959
1960 // Oversized pin set starves the eviction driver on EVERY node, because
1961 // pinned blobs are LRU-exempt however they arrived. Runs unconditionally at
1962 // startup (and again on reload).
1963 warn_if_pins_exceed_cache(&infra.cache, cfg.cache.cache_size_mb);
1964
1965 // DHT bucket-refresh (ADR 022 §Routing Table). Once per hour
1966 // picks the bucket with the oldest last-refresh timestamp and
1967 // runs `FindNode(random_id_in_bucket)` against the bucket's
1968 // freshest peer to repopulate it. Best-effort hygiene — a failed
1969 // refresh is silently retried on the next tick.
1970 let (bucket_refresh_stop_tx, bucket_refresh_stop_rx) = oneshot::channel::<()>();
1971 // Shared "last bucket-refresh completed at" clock (wall-clock µs, 0 =
1972 // never). The refresh task stamps it each pass; `admin_v1_status`
1973 // reads it for the routing-table health view (issue #741).
1974 let bucket_refresh_clock = Arc::new(std::sync::atomic::AtomicU64::new(0));
1975 tasks.spawn(crate::dht::bucket_refresh::run_bucket_refresh(
1976 infra.ep.clone(),
1977 infra.secret_key.public(),
1978 Arc::clone(&ch.dht_routing),
1979 bucket_refresh_stop_rx,
1980 crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
1981 Arc::clone(&bucket_refresh_clock),
1982 Arc::clone(&infra.node_metrics),
1983 ));
1984
1985 // RPC connectivity watchdog (issue #283). Updates `decdn_rpc_healthy`
1986 // each tick; a sustained transition fires an alert. `interval == 0`
1987 // disables the watchdog entirely (operators can opt out for offline
1988 // dev). Spawned outside the `JoinSet` because we drive it via its own
1989 // `oneshot` and an explicit `await` during drain, since `JoinSet::abort_all`
1990 // cancels eagerly and we'd rather let the watchdog observe its `shutdown`
1991 // arm.
1992 //
1993 // Seed the gauge to `1` here unconditionally: the startup
1994 // `check_rpc_reachability` above already established the endpoint is
1995 // reachable, and registered Prometheus gauges otherwise default to 0
1996 // — which alerts would (correctly, by their own logic) read as an
1997 // outage. Seeding before the watchdog-spawn branch covers the
1998 // `interval == 0` case too.
1999 infra.node_metrics.rpc_healthy(true);
2000 let rpc_watchdog = if cfg.blockchain.rpc_watchdog_interval_sec > 0 {
2001 let client = reqwest::Client::builder()
2002 .timeout(Duration::from_secs(5))
2003 .build()
2004 .context("failed to build HTTP client for RPC watchdog")?;
2005 let interval = Duration::from_secs(cfg.blockchain.rpc_watchdog_interval_sec);
2006 let (tx, rx) = oneshot::channel::<()>();
2007 let handle = spawn_rpc_watchdog(
2008 client,
2009 cfg.blockchain.rpc_url.clone(),
2010 interval,
2011 Arc::clone(&infra.node_metrics),
2012 rx,
2013 );
2014 Some((tx, handle))
2015 } else {
2016 tracing::info!("RPC watchdog disabled (blockchain.rpc_watchdog_interval_sec = 0)");
2017 None
2018 };
2019
2020 // Admin HTTP surface (`appendix-local-admin-http.md`). Bind here so startup fails fast on a port
2021 // collision rather than after side-effectful subscriptions have
2022 // registered. The `serve` task itself is spawned later, once the rest of
2023 // the runtime state it reads is wired.
2024 let admin_listener = if let Some(admin_port) = cfg.observability.admin_port {
2025 let admin_addr = std::net::SocketAddr::from(([127, 0, 0, 1], admin_port));
2026 Some(admin::bind(admin_addr).context("failed to bind admin listener")?)
2027 } else {
2028 tracing::info!("admin server disabled (observability.admin_port = 0)");
2029 None
2030 };
2031
2032 // Local per-peer EWMA reputation score store (ADR 008 §Local Score
2033 // Calculation). Reputation is local-only: a node ranks its peers solely from
2034 // its own delivery observations — there is no cross-node aggregation. The
2035 // store feeds node selection via the `NodeOrigin` pull path (provisioned
2036 // below).
2037 // Opt into ADR 008 §Score Clamping's ±0.05 per-report clamp (the library
2038 // default is a no-op cap) so one bad interaction cannot over-penalize an
2039 // otherwise good peer (#1176).
2040 let local_reputation = Arc::new(
2041 decdn_reputation::LocalReputation::new(
2042 decdn_reputation::LocalReputationConfig::default()
2043 .with_max_delta_per_update(decdn_reputation::LOCAL_SCORE_MAX_DELTA_PER_REPORT),
2044 )
2045 .context("local reputation config invalid")?,
2046 );
2047
2048 // Periodic local-reputation eviction (ADR 008 §Local Score Calculation).
2049 // The store is written by the node-origin pull path; without this sweep
2050 // it would retain an entry for every peer ever ranked. `evict` is
2051 // synchronous and cheap (a bounded walk of idle entries), so it rides
2052 // `spawn_periodic`'s sync `FnMut` like the other GC sweeps.
2053 let reputation_evict_stop_tx = {
2054 let rep = Arc::clone(&local_reputation);
2055 spawn_periodic(
2056 &mut tasks,
2057 "reputation_evict",
2058 REPUTATION_EVICT_INTERVAL,
2059 move || {
2060 rep.evict();
2061 },
2062 )
2063 };
2064
2065 // Buyer-side PaymentPool bootstrap + node-to-node pull-through
2066 // provisioning (#831), fully backgrounded off the startup critical path
2067 // (#1109). The USDC `approve` receipt that `bootstrap` awaits could hang for
2068 // many minutes on a stuck tx (capped by `APPROVE_RECEIPT_TIMEOUT`);
2069 // running it inline would gate the metrics/admin binds and the
2070 // "node runtime ready" banner below. The origin shares its `OnceLock` with
2071 // the clone already in the cache's origin chain, so a later `provision` from
2072 // this task arms pull-through; reads before it land as clean misses (no
2073 // spend, no panic). Bootstrap failure stays NON-fatal — buying is
2074 // opportunistic cost-recovery.
2075 //
2076 // Precompute every cfg/secret-derived value the task needs: the closure is
2077 // `'static` so it can't borrow `cfg`/`secret_key`, and those are used later.
2078 let (buyer_bootstrap_stop_tx, buyer_bootstrap_stop_rx) = oneshot::channel::<()>();
2079 let buyer_working_deposit = U256::from(cfg.blockchain.buyer_working_deposit_micro_usdc);
2080 let buyer_ensure_max_approval = cfg.blockchain.buyer_max_approve;
2081 let buyer_signer_address = infra.eth_signer.address();
2082 let pull_through_enabled = cfg.cache.node_to_node_pull_through_enabled;
2083 let node_origin_self_id = crate::dht::NodeId::from_bytes(*infra.secret_key.public().as_bytes());
2084 let node_origin_slash_domain = ch.slash_domain.clone();
2085 // #1117: the `CapacityBond` bind domain this node signs its node→node client
2086 // identity binding under — reuses the serving-side `bind_domain` constructed
2087 // once above so an upstream verifies against an identical domain.
2088 let node_origin_bind_domain = ch.bind_domain.clone();
2089 let node_origin_config = crate::node_origin::NodeOriginConfig {
2090 probe_fanout: cfg.cache.node_pull_probe_fanout,
2091 pull_timeout: std::time::Duration::from_secs(cfg.cache.node_pull_timeout_sec),
2092 stall_window: std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
2093 min_throughput_bps: cfg.cache.node_pull_min_throughput_bps,
2094 max_blob_size_bytes: cfg
2095 .cache
2096 .max_blob_size_mb
2097 .saturating_mul(decdn_protocol::MB_BYTES),
2098 max_rate_per_mb: cfg.cache.max_rate_per_mb,
2099 event_poll_interval: std::time::Duration::from_millis(
2100 cfg.blockchain.event_poll_interval_ms,
2101 ),
2102 lookup: crate::dht::LookupConfig::default(),
2103 // Own self-attested region for the ADR 030 latency-vs-claim penalty
2104 // (#1177); `None` disables it (nothing to compare a peer's claim against).
2105 own_region: cfg.identity.region.clone(),
2106 // ADR 041 buy-side profitability gate + its inputs: the policy object
2107 // itself, the live operator fee-share cell, the shared ADR 040
2108 // frequency estimator (when built), and this node's configured sell rate
2109 // `P_sell` (`payment.rate_per_mb`).
2110 serve_economics: ch.serve_economics.clone(),
2111 operator_shares: ch.operator_shares.clone(),
2112 frequency_estimator: infra.frequency_estimator.clone(),
2113 sell_rate_base: cfg.payment.rate_per_mb,
2114 warming: Arc::clone(&ch.warming),
2115 };
2116 // Arc/handle clones for the task — the originals are used later in `run()`.
2117 let ep_for_buyer = infra.ep.clone();
2118 let eth_signer_for_buyer = Arc::clone(&infra.eth_signer);
2119 let resolver_opt = ch.node_address_resolver.clone();
2120 let node_origin_opt = infra.node_origin.clone();
2121 let dht_routing_c = Arc::clone(&ch.dht_routing);
2122 let staker_set_c = Arc::clone(&ch.staker_set);
2123 let origin_directory_c = Arc::clone(&ch.origin_directory);
2124 let local_reputation_c = Arc::clone(&local_reputation);
2125 let registry_regions_c = Arc::clone(&ch.registry_regions);
2126 let node_metrics_for_buyer = Arc::clone(&infra.node_metrics);
2127 let node_metrics_for_origin = Arc::clone(&infra.node_metrics);
2128 let node_origin_engine = infra.cache.clone();
2129 let mut buyer_bootstrap_stop_rx = buyer_bootstrap_stop_rx;
2130 let buyer_deployment = infra.payment_pool_deployment;
2131 let payment_pool_addr_for_buyer = buyer_deployment.payment_pool;
2132 tasks.spawn(async move {
2133 let service = tokio::select! {
2134 biased;
2135 // A shutdown that races a slow bootstrap unwinds cleanly here.
2136 _ = &mut buyer_bootstrap_stop_rx => return,
2137 res = crate::buyer_channel::BuyerPoolService::bootstrap(
2138 buyer_wallet_provider,
2139 buyer_deployment,
2140 buyer_signer_address,
2141 buyer_channel_store,
2142 eth_signer_for_buyer,
2143 buyer_working_deposit,
2144 buyer_ensure_max_approval,
2145 node_metrics_for_buyer,
2146 ) => match res {
2147 Ok(service) => Arc::new(service),
2148 Err(err) => {
2149 tracing::warn!(
2150 error = %sanitize_err_chain(&err),
2151 payment_pool_addr = %payment_pool_addr_for_buyer,
2152 "buyer-side PaymentPool bootstrap failed; node→node paid cache-miss \
2153 pulls are DISABLED for this process (seller settlement is unaffected). \
2154 This condition is sticky — restart the node to retry. Check: (1) \
2155 blockchain.payment_pool_address is correct, (2) the RPC endpoint is \
2156 reachable, (3) the wallet holds gas for the one-time USDC approve."
2157 );
2158 if pull_through_enabled {
2159 tracing::warn!(
2160 "cache.node_to_node_pull_through_enabled is set, but the buyer service \
2161 failed to bootstrap; pull-through stays DISABLED this process \
2162 (restart to retry)"
2163 );
2164 }
2165 return;
2166 }
2167 }
2168 };
2169
2170 // Provision the node-to-node pull origin now that the buyer service is
2171 // up (#831). `node_origin_opt` is `Some` iff the feature is on; the
2172 // buyer is always present at this point, so the remaining gate is the
2173 // address resolver. The origin's `OnceLock` is shared with the cache
2174 // chain's clone, so this set is what actually arms pull-through.
2175 if let Some(origin) = node_origin_opt.as_ref() {
2176 if let Some(resolver) = resolver_opt.as_ref() {
2177 origin.provision(crate::node_origin::NodeOriginDeps {
2178 endpoint: ep_for_buyer.clone(),
2179 dial_runtime: tokio::runtime::Handle::current(),
2180 routing_table: dht_routing_c,
2181 staker_set: staker_set_c,
2182 // FIND_VALUE directory reads: the last-resort fallback when
2183 // the DHT returns no providers, and the supplement when a
2184 // ranged pull's probed holders do not span the blob (ADR 022
2185 // §FIND_VALUE Flow; #912, #2195). Shares the single origin
2186 // directory built above with the reactive pull-through gate,
2187 // so a configured `ChainOriginDirectory` backs both reads for
2188 // every node. Absent chain addresses it is empty, and both
2189 // reads find no origin.
2190 origin_directory: origin_directory_c,
2191 addr_resolver: Arc::clone(resolver),
2192 buyer: Arc::clone(&service) as Arc<dyn crate::buyer_channel::PoolOpener>,
2193 self_id: node_origin_self_id,
2194 slash_domain: node_origin_slash_domain,
2195 bind_domain: node_origin_bind_domain,
2196 local_rep: local_reputation_c,
2197 negative_cache: crate::dht::NegativeProbeCache::new(),
2198 probe_cache: crate::dht::PositiveProbeCache::new(),
2199 metrics: node_metrics_for_origin,
2200 registry_regions: registry_regions_c,
2201 config: node_origin_config,
2202 // One voucher ledger per provider channel, shared by every concurrent
2203 // pull on it (#1145 review). Built here, at the single place the pull
2204 // paths' deps are assembled, so both paths necessarily share it —
2205 // which is the point: a per-pull ledger makes concurrent pulls collide
2206 // on the same cumulative amount. See `buyer_ledgers::BuyerLedgers`.
2207 ledgers: Arc::new(crate::buyer_ledgers::BuyerLedgers::default()),
2208 // Providers suppressed after a lane-terminal voucher rejection, skipped in
2209 // ranking for a fixed window (#1145 review). In-memory, like the negative
2210 // cache beside it.
2211 wedged_providers: Arc::new(std::sync::Mutex::new(
2212 std::collections::HashMap::new(),
2213 )),
2214 engine: node_origin_engine.clone(),
2215 });
2216 tracing::info!(
2217 "node-to-node cache-miss pull-through provisioned and enabled (#831)"
2218 );
2219 } else {
2220 tracing::warn!(
2221 "cache.node_to_node_pull_through_enabled is set, but the node-address \
2222 resolver failed to bootstrap; pull-through stays DISABLED this process \
2223 (restart to retry)"
2224 );
2225 }
2226 }
2227
2228 // Hold the service (and thus its `AbortOnDropHandle` reclaim sweep)
2229 // alive until shutdown — reclaim must keep running even when
2230 // pull-through is off (`node_origin_opt` is `None`).
2231 let _service = service;
2232 let _ = buyer_bootstrap_stop_rx.await;
2233 });
2234
2235 // Drain trigger for `admin_v1_drain` (issue #244). Constructed
2236 // unconditionally so the admin handler always has a live target —
2237 // there is no "drain disabled" state analogous to "no region" or
2238 // "no config path". The `Arc` is shared with the admin state (via
2239 // `Arc::clone`) and the select loop arm below.
2240 let drain_trigger = Arc::new(admin::DrainTrigger::new());
2241
2242 // Spawn the admin serve task with the full state. Bind happened
2243 // earlier (see `admin_listener` above) so a port collision would
2244 // have failed startup before any side-effectful subscribes ran.
2245 let admin_stop_tx = if let Some(listener) = admin_listener {
2246 let (tx, rx) = oneshot::channel::<()>();
2247 // Build the reload hook only when a config file path was passed
2248 // (CLI-only invocation has nothing on disk to re-read). The
2249 // hook hands `admin_v1_reload` the same `RuntimeReloadState` and
2250 // file path the SIGHUP arm uses, so both paths converge on a
2251 // single mutex-serialised reload — see `admin::AdminRpcImpl::reload`
2252 // and the SIGHUP arm of the select loop below.
2253 let reload_hook = config_path.map(|path| admin::ReloadHook {
2254 reload_state: Arc::clone(reload_state),
2255 config_path: path.clone(),
2256 });
2257 let state = admin::AdminState::new(
2258 *infra.secret_key.public().as_bytes(),
2259 started_at,
2260 infra.cache.clone(),
2261 reload_hook,
2262 Arc::clone(&drain_trigger),
2263 Arc::clone(&infra.node_metrics),
2264 )
2265 // DHT introspection for `admin_v1_status` (issue #741). All handles
2266 // are clones of state the DHT tasks already share — read-only here.
2267 .with_dht(admin::DhtStatusHandles {
2268 routing: Arc::clone(&ch.dht_routing),
2269 staker_set: Arc::clone(&ch.staker_set),
2270 record_store: Arc::clone(&ch.record_store),
2271 republish: Arc::clone(&republish_scheduler),
2272 refresh_clock: Arc::clone(&bucket_refresh_clock),
2273 refresh_interval: crate::dht::bucket_refresh::BUCKET_REFRESH_TICK,
2274 })
2275 // Lane introspection for `admin_v1_lanes` (issue #749). Shares the
2276 // same persistent lane-state store the client handler and settlement
2277 // service use, plus a read handle over the handler's live lane
2278 // registry for the last-voucher clock (issue #1733) — read-only here.
2279 .with_lanes(admin::LaneStatusHandles {
2280 pool_store: Arc::clone(&infra.channel_state_store),
2281 lane_activity: ch.client_handler.lane_activity_clock(),
2282 redeem_threshold_micro_usdc: cfg.blockchain.redeem_threshold_micro_usdc,
2283 })
2284 // Buyer-pool introspection for `admin_v1_pools` (#2078). The SAME store
2285 // handle `BuyerPoolService` records into, so the admin surface reports
2286 // what the daemon believes — the only read path to `buyer.redb` while
2287 // this process holds redb's exclusive lock on it.
2288 .with_buyer_pools(Arc::clone(&buyer_channel_store_for_admin))
2289 // Slash-detection introspection for `admin_v1_slashes` (#1032). Shares
2290 // the in-memory store the watcher appends to — read-only here.
2291 .with_slash_detection(admin::SlashStatusHandles {
2292 store: Arc::clone(&ch.slash_store),
2293 })
2294 // Bring-up node-id binding check (#1034), already logged during
2295 // `build_chain_and_handlers`. Surfacing it here too is what lets an
2296 // operator (or the G-NODE-07 journey) detect the unslashable state
2297 // without scraping the daemon's log for a WARN line.
2298 .with_binding(ch.binding_report)
2299 // Operator eth address for `admin_v1_status` (#1906). The loaded eth
2300 // signer's address — the same value the binding check above derives —
2301 // so an operator can read the wallet the node runs under without
2302 // grepping the bring-up log.
2303 .with_operator_address(infra.eth_signer.address())
2304 // ADR 041: the SAME warming allowance the buy loop debits, the serve
2305 // path credits, and the eviction driver forgets from, so
2306 // `admin_v1_evict` can also drop a hash's provenance tag on removal.
2307 .with_warming(Arc::clone(&ch.warming))
2308 // Live ADR 019 §Phase 4 registry state (#1030). The same `Arc` the serve
2309 // gate reads, so `decdn node health` cannot report a node as servable
2310 // while `serve_stream` is refusing it — and an operator who has just run
2311 // `decdn setup` sees `registry_active` flip without a restart.
2312 .with_staker_set(Arc::clone(&ch.staker_set))
2313 // Live content denylist for `admin_v1_status`'s
2314 // `chain_denied_origins` (ADR 011). The SAME `Arc` the client
2315 // handler and blacklist watcher hold, so the reported on-chain
2316 // origin deny-set size is always current.
2317 .with_denylist(reload_state.content_denylist());
2318 tasks.spawn(async move {
2319 if let Err(err) = admin::serve(listener, state, rx).await {
2320 tracing::error!(error = %err, "admin server exited with error");
2321 }
2322 });
2323 Some(tx)
2324 } else {
2325 None
2326 };
2327
2328 // Startup banner (#274). One structured INFO event per restart lets
2329 // operators correlate log streams across a fleet and across restarts
2330 // without stitching multiple lines together. Field names are stable —
2331 // log aggregators key on them.
2332 tracing::info!(
2333 event = "startup_banner",
2334 node_id = %infra.secret_key.public(),
2335 version = env!("CARGO_PKG_VERSION"),
2336 region = cfg.identity.region.as_deref().unwrap_or(""),
2337 bind_port = cfg.network.bind_port,
2338 metrics_addr = %metrics_addr,
2339 admin_port = ?cfg.observability.admin_port,
2340 rate_per_mb = cfg.payment.rate_per_mb,
2341 cache_dir = %cfg.cache.cache_dir.display(),
2342 has_origin = !cfg.cache.origins.is_empty(),
2343 origin_count = cfg.cache.origins.len(),
2344 origin_kinds = %origin_kinds_label(&cfg.cache.origins),
2345 "node runtime ready"
2346 );
2347
2348 Ok(Background {
2349 metrics_stop_tx,
2350 dispatch_gc_stop_tx,
2351 egress_ewma_stop_tx,
2352 origin_rescan_stop_tx,
2353 record_store_gc_stop_tx,
2354 eviction_stop_tx,
2355 dht_rate_limit_gc_stop_tx,
2356 probe_rate_limit_gc_stop_tx,
2357 reputation_evict_stop_tx,
2358 republish_stop,
2359 bucket_refresh_stop_tx,
2360 buyer_bootstrap_stop_tx,
2361 rpc_watchdog,
2362 admin_stop_tx,
2363 drain_trigger,
2364 tasks,
2365 })
2366}
2367
2368/// Build the endpoint, register handlers on a `Router`, spawn the metrics
2369/// server and other background tasks, and run until a shutdown signal is
2370/// received.
2371///
2372/// SIGHUP triggers a hot-reload of mutable config fields via
2373/// [`RuntimeReloadState`] (see issue #236). Other signals
2374/// (SIGINT/SIGTERM) trigger graceful shutdown.
2375///
2376/// The signal streams (SIGHUP, SIGTERM) are registered **once** before
2377/// the select loop and reused on every iteration. Re-creating
2378/// `tokio::signal::unix::Signal` each iteration would race with signal
2379/// delivery: a SIGHUP that arrives while `reload_runtime_config(..)` is
2380/// running would have nowhere to land if the future holding the
2381/// `Signal` had already been dropped, and would be silently lost. The
2382/// persistent stream queues the signal until the next `recv()` call
2383/// (kernel-managed, with coalescing) so concurrent or rapidly-repeated
2384/// signals are observed deterministically.
2385///
2386/// `node_metrics` is the registry `/metrics` serves. The caller builds it
2387/// so that pre-runtime components (the OTLP exporter) count into the same
2388/// registry.
2389///
2390/// `secret_key` is the node's iroh identity. The caller loads it, so that the
2391/// daemon entry point can build its JSON log formatter, which writes
2392/// `node_id` on every event, before bring-up starts.
2393pub async fn run(
2394 cfg: ResolvedConfig,
2395 secret_key: SecretKey,
2396 config_path: Option<PathBuf>,
2397 reload_state: Arc<RuntimeReloadState>,
2398 node_metrics: Arc<metrics::Metrics>,
2399) -> anyhow::Result<()> {
2400 // Captured at the very top of `run()`, before any `await` or I/O,
2401 // so `admin_v1_health.uptime_s` covers the whole runtime — including
2402 // the RPC reachability preflight below (which can spend up to its 5s
2403 // timeout on flaky networks). Only `commands::run`'s config resolution,
2404 // node key load and tracing bring-up come before it. Operators reasoning
2405 // about "how long has this node been up?" want every second since
2406 // `decdn-node run` started, not just everything after the admin server
2407 // bound.
2408 let started_at = std::time::Instant::now();
2409
2410 let infra = build_infra(&cfg, secret_key, &reload_state, node_metrics).await?;
2411
2412 let ch = build_chain_and_handlers(&cfg, &reload_state, &infra).await?;
2413
2414 let bg = spawn_background_tasks(
2415 &cfg,
2416 config_path.as_ref(),
2417 &reload_state,
2418 &infra,
2419 &ch,
2420 started_at,
2421 )
2422 .await?;
2423
2424 let (router, signal) = serve_until_shutdown(
2425 &reload_state,
2426 config_path.as_deref(),
2427 &bg.drain_trigger,
2428 ServeInputs {
2429 ep: infra.ep,
2430 probe_handler: ch.probe_handler,
2431 client_handler: ch.client_handler,
2432 dht_handler: ch.dht_handler,
2433 blacklist_ready_rx: ch.blacklist_ready_rx,
2434 },
2435 )
2436 .await?;
2437
2438 let handles = ShutdownHandles {
2439 metrics_stop_tx: bg.metrics_stop_tx,
2440 dispatch_gc_stop_tx: bg.dispatch_gc_stop_tx,
2441 egress_ewma_stop_tx: bg.egress_ewma_stop_tx,
2442 buyer_bootstrap_stop_tx: bg.buyer_bootstrap_stop_tx,
2443 origin_rescan_stop_tx: bg.origin_rescan_stop_tx,
2444 record_store_gc_stop_tx: bg.record_store_gc_stop_tx,
2445 eviction_stop_tx: bg.eviction_stop_tx,
2446 dht_rate_limit_gc_stop_tx: bg.dht_rate_limit_gc_stop_tx,
2447 probe_rate_limit_gc_stop_tx: bg.probe_rate_limit_gc_stop_tx,
2448 reputation_evict_stop_tx: bg.reputation_evict_stop_tx,
2449 republish_stop: bg.republish_stop,
2450 bucket_refresh_stop_tx: bg.bucket_refresh_stop_tx,
2451 admin_stop_tx: bg.admin_stop_tx,
2452 rpc_watchdog: bg.rpc_watchdog,
2453 poller: ch.poller,
2454 payment_service: ch.payment_service,
2455 receipt_writer: infra.receipt_writer,
2456 warming_creditor: ch.warming_creditor,
2457 lane_flush_task: infra.lane_flush_task,
2458 channel_state_store: infra.channel_state_store,
2459 node_metrics: infra.node_metrics,
2460 tasks: bg.tasks,
2461 };
2462 shutdown(handles, router, signal, &bg.drain_trigger, infra.cache).await
2463}
2464
2465/// Owned teardown state handed from [`run`] to [`shutdown`]. Every field is a
2466/// stop signal, watcher handle, or task handle that the graceful-shutdown
2467/// sequence consumes exactly once; the exhaustive destructure at the top of
2468/// [`shutdown`] (no `..` rest) is a deliberate safety net — a field left
2469/// unconsumed raises an `unused_variables` warning, which CI and the pre-commit
2470/// hook make fatal, proving no stop signal was dropped on the floor.
2471struct ShutdownHandles<P: Provider + Clone + 'static> {
2472 metrics_stop_tx: oneshot::Sender<()>,
2473 dispatch_gc_stop_tx: oneshot::Sender<()>,
2474 egress_ewma_stop_tx: oneshot::Sender<()>,
2475 buyer_bootstrap_stop_tx: oneshot::Sender<()>,
2476 /// Origin-held-index periodic rescan (#1130). `None` when
2477 /// `cache.fs_rescan_interval_sec == 0` disables it.
2478 origin_rescan_stop_tx: Option<oneshot::Sender<()>>,
2479 record_store_gc_stop_tx: oneshot::Sender<()>,
2480 eviction_stop_tx: oneshot::Sender<()>,
2481 dht_rate_limit_gc_stop_tx: oneshot::Sender<()>,
2482 probe_rate_limit_gc_stop_tx: oneshot::Sender<()>,
2483 reputation_evict_stop_tx: oneshot::Sender<()>,
2484 republish_stop: CancellationToken,
2485 bucket_refresh_stop_tx: oneshot::Sender<()>,
2486 admin_stop_tx: Option<oneshot::Sender<()>>,
2487 rpc_watchdog: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>,
2488 /// The single multiplexed chain-event poller. Stopped once, late (after
2489 /// `router.shutdown`), because its capacity-bond route gates DHT admission
2490 /// through drain — see the cancel site in [`shutdown`].
2491 poller: crate::chain_events::resumable_watcher::WatcherHandle,
2492 payment_service: PoolSettlementService<P>,
2493 /// The background receipt writer, cancelled once the router has drained
2494 /// and awaited in the drain phase so the audit tail survives shutdown
2495 /// (#803).
2496 receipt_writer: StopHandle,
2497 /// The ADR 041 warming-credit aggregator, cancelled beside the receipt
2498 /// writer and awaited in the drain phase so the serve credits already
2499 /// queued land in the ledger before the runtime returns.
2500 warming_creditor: StopHandle,
2501 /// Periodic lane-store flush timer, aborted below after one final durable
2502 /// flush of `channel_state_store`.
2503 lane_flush_task: tokio::task::JoinHandle<()>,
2504 channel_state_store: Arc<dyn PoolStateStore>,
2505 node_metrics: Arc<metrics::Metrics>,
2506 tasks: JoinSet<()>,
2507}
2508
2509/// Shut `router` down, giving up after `deadline`. Returns whether the router
2510/// task ran to completion.
2511///
2512/// The router's shutdown cancels its handlers, then calls
2513/// [`iroh::Endpoint::close`], which waits for every open connection to reach
2514/// QUIC's draining state. A connection whose QUIC driver died with the runtime
2515/// that dialled it never does, and the close then waits forever. The teardown
2516/// after this call redeems lanes, flushes the lane store and the receipt tail,
2517/// and closes the cache, so a stuck close must not cost that settlement state.
2518///
2519/// Past `deadline` this drops the shutdown future, which aborts the router's
2520/// task, and logs an error: every upstream dial runs on the main runtime, so a
2521/// close that does not finish is a defect, not load. The handlers are already
2522/// cancelled by then, so no serve outlives the give-up. The endpoint is left
2523/// closing rather than closed, so nothing after this call may wait on
2524/// [`iroh::Endpoint::closed`]. A router task that panicked also counts as not
2525/// finished: its endpoint close may never have run.
2526async fn close_router(router: &Router, deadline: Duration) -> bool {
2527 match tokio::time::timeout(deadline, router.shutdown()).await {
2528 Ok(Ok(())) => true,
2529 Ok(Err(err)) => {
2530 tracing::error!(
2531 error = %err,
2532 "router shutdown task failed; the endpoint may not have closed"
2533 );
2534 false
2535 }
2536 Err(_) => {
2537 tracing::error!(
2538 ?deadline,
2539 "router shutdown did not finish within the deadline, most likely because the \
2540 endpoint close waits on a connection whose QUIC driver is gone; continuing \
2541 with redeem and flush"
2542 );
2543 false
2544 }
2545 }
2546}
2547
2548/// Graceful teardown of [`run`] (#1253). Consumes every field of
2549/// [`ShutdownHandles`] via an exhaustive destructure — see that type's docs for
2550/// why the `..`-free binding is load-bearing. The teardown ordering here is
2551/// itself load-bearing (metrics accept-loop stop first; the multiplexed `poller`
2552/// stops after `router.shutdown`) and must not be reordered.
2553#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
2554async fn shutdown<P: Provider + Clone + 'static>(
2555 handles: ShutdownHandles<P>,
2556 router: Router,
2557 signal: ShutdownSignal,
2558 drain_trigger: &Arc<admin::DrainTrigger>,
2559 cache: CacheEngine,
2560) -> anyhow::Result<()> {
2561 let ShutdownHandles {
2562 metrics_stop_tx,
2563 dispatch_gc_stop_tx,
2564 egress_ewma_stop_tx,
2565 buyer_bootstrap_stop_tx,
2566 origin_rescan_stop_tx,
2567 record_store_gc_stop_tx,
2568 eviction_stop_tx,
2569 dht_rate_limit_gc_stop_tx,
2570 probe_rate_limit_gc_stop_tx,
2571 reputation_evict_stop_tx,
2572 republish_stop,
2573 bucket_refresh_stop_tx,
2574 mut admin_stop_tx,
2575 rpc_watchdog,
2576 poller,
2577 payment_service,
2578 receipt_writer,
2579 warming_creditor,
2580 lane_flush_task,
2581 channel_state_store,
2582 node_metrics,
2583 mut tasks,
2584 } = handles;
2585
2586 // Signal the HTTP accept loops to stop *before* closing the router.
2587 // The router close can take up to `SHUTDOWN_DEADLINE`, and while it runs
2588 // the metrics/admin servers would otherwise keep accepting fresh loopback
2589 // connections — wasting that budget and emitting misleading
2590 // "still serving" signals. The accept loops are cheap to unwind, so
2591 // stopping them first is strictly cleaner.
2592 if metrics_stop_tx.send(()).is_err() {
2593 // Receiver dropped → the metrics server task already exited on
2594 // its own. The spawn closure logs its own error on abnormal exit
2595 // (see `metrics server exited with error` above), so this branch
2596 // is purely informational: under a healthy shutdown we'd have
2597 // been the ones signaling it.
2598 tracing::warn!("metrics server exited before shutdown signal was sent");
2599 }
2600 // Best-effort: silently ignore the dispatch GC stop send failure.
2601 // The task only exits early on a panic, and its panic surfaces
2602 // through `JoinSet::join_next` during the drain phase below — no
2603 // operator-actionable signal to log at this seam.
2604 let _ = dispatch_gc_stop_tx.send(());
2605 // Best-effort, same rationale as the dispatch-GC stop above: the egress
2606 // EWMA tick only exits early on a panic, which surfaces through
2607 // `JoinSet::join_next` during the drain phase below.
2608 let _ = egress_ewma_stop_tx.send(());
2609 // Cancel a still-running buyer bootstrap and release the task's
2610 // process-lifetime hold on the service (#1109). Best-effort: on the
2611 // bootstrap-failed path the task already returned and dropped the receiver,
2612 // so this send errors harmlessly.
2613 let _ = buyer_bootstrap_stop_tx.send(());
2614 // origin-held-index periodic rescan (#1130)
2615 if let Some(tx) = origin_rescan_stop_tx {
2616 let _ = tx.send(());
2617 }
2618 let _ = record_store_gc_stop_tx.send(());
2619 // Eviction driver (#1173): best-effort stop, same shape as the GC sweeps.
2620 // The task also drains via `JoinSet::join_next` below.
2621 let _ = eviction_stop_tx.send(());
2622 let _ = dht_rate_limit_gc_stop_tx.send(());
2623 let _ = probe_rate_limit_gc_stop_tx.send(());
2624 // Best-effort, same rationale as the GC sweeps above: the reputation
2625 // evict tick exits on this stop signal (or a panic, which surfaces
2626 // through `JoinSet::join_next` during the drain phase below).
2627 let _ = reputation_evict_stop_tx.send(());
2628 // Cancels the republisher AND any lag sweep it left detached. The cancel is
2629 // ordered strictly before the `cache.shutdown()` flush below, which is what
2630 // the sweep needs: its `select!` is `biased` on this token, so a worker
2631 // polled any time after the flush takes the cancel arm instead of observing
2632 // the store it was walking disappear and reporting that as a store-walk
2633 // failure. The sweeps are detached and not joined, so one may outlive the
2634 // flush by a poll — it just cannot report anything once cancelled.
2635 republish_stop.cancel();
2636 let _ = bucket_refresh_stop_tx.send(());
2637 // The chain watchers are one multiplexed poller with one shutdown token, so
2638 // they stop together in a SINGLE `poller.shutdown()` at the LATE point below
2639 // (after `router.shutdown`). For the blacklist and rate-bounds routes the
2640 // late stop is harmless — neither carries a durable cursor to flush, and
2641 // neither is consulted for a drain-time decision (unlike the capacity-bond
2642 // route's staker set), so they only keep applying compliance/rate updates a
2643 // little longer.
2644 // Admin server shutdown is ordered per `admin_stop_order`:
2645 //
2646 // - `Early` (the default, including SIGTERM/SIGINT and plain
2647 // `admin_v1_drain`): stop admin *before* `router.shutdown` so
2648 // admin doesn't keep accepting fresh loopback connections
2649 // during drain. This is the original ordering (see
2650 // `appendix-local-admin-http`).
2651 // - `AfterRouter` (only when an `admin_v1_drain` call carried
2652 // `wait_admin: true`, i.e. `decdn node drain --wait`): keep
2653 // admin alive *through* `router.shutdown` so a polling client
2654 // can observe `admin_v1_health.in_flight_streams` reach 0. The
2655 // admin stop signal fires below, after `router.shutdown`
2656 // returns.
2657 //
2658 // `wait_admin` is sampled once into a local; the two `if` blocks
2659 // below are mutually exclusive, so the single `Option<Sender>` is
2660 // consumed by exactly one branch. The signal-gating on
2661 // `AdminDrain` means a sticky `wait_admin=true` from an in-flight
2662 // RPC that was racing a SIGTERM does *not* flip the runtime onto
2663 // the AfterRouter path when SIGTERM actually won the select (the
2664 // operator's stated intent wins; the RPC's request becomes a
2665 // no-op since drain is already in progress).
2666 let stop_order = admin_stop_order(signal, drain_trigger);
2667 if matches!(stop_order, AdminStopOrder::Early)
2668 && let Some(tx) = admin_stop_tx.take()
2669 && tx.send(()).is_err()
2670 {
2671 tracing::warn!("admin server exited before shutdown signal was sent");
2672 }
2673 let rpc_watchdog_handle = if let Some((tx, handle)) = rpc_watchdog {
2674 if tx.send(()).is_err() {
2675 tracing::warn!("RPC watchdog exited before shutdown signal was sent");
2676 }
2677 Some(handle)
2678 } else {
2679 None
2680 };
2681
2682 // Router::shutdown waits for ProtocolHandler::shutdown on each handler,
2683 // then closes the endpoint. Bounded, so every step below still runs.
2684 close_router(&router, SHUTDOWN_DEADLINE).await;
2685 // The ONE multiplexed poller driving all registered watcher routes stops here,
2686 // once. It exits cooperatively — cancelling its loop at the next await
2687 // boundary and flushing every persisting route's checkpoint (settlement's
2688 // `PoolOpened`) before returning — with its `WatcherHandle`'s `AbortOnDropHandle`
2689 // as the backstop.
2690 //
2691 // *After* `router.shutdown` deliberately, and the capacity-bond route is why:
2692 // its projection is the cached active-staker set, which gates DHT `Store`
2693 // admission and decides which probes the stake-lane reservation sheds.
2694 // Cancelling it before the drain would freeze that set while the router is
2695 // still serving, so a membership change landing mid-drain would be missed by
2696 // exactly the requests still in flight. This binding constraint forces the
2697 // single stop late; the other four routes (slash, settlement, blacklist,
2698 // rate-bounds) are not drain-consulted and could stop earlier, but one shared
2699 // token means they stop here too. `shutdown` only cancels the token — it is
2700 // not awaited — so settlement's checkpoint flush races the redeem sweep just
2701 // below rather than completing before it; that is fine because the flush is
2702 // best-effort and the checkpoint is independent of, and idempotent with
2703 // respect to, the redeem sweep.
2704 poller.shutdown();
2705 // The router has drained, so no further vouchers — and therefore no further
2706 // receipts — will be produced. Signal the receipt writer to flush whatever
2707 // is already enqueued and exit; it is awaited in the drain phase below so
2708 // the audit tail survives shutdown (#803). The early cancel overlaps the
2709 // tail flush with the settlement and store teardown between here and the
2710 // drain; the `shutdown().await` there cancels again as a no-op.
2711 receipt_writer.cancel();
2712 // Same reasoning for the ADR 041 warming-credit aggregator: no further
2713 // serves complete, so no further credits are produced. Let it apply what is
2714 // already queued and exit; it is awaited in the drain phase below so a
2715 // completed serve's credit is not lost to shutdown timing.
2716 warming_creditor.cancel();
2717
2718 // Redeem on shutdown (#327): now that the router has drained, no further
2719 // vouchers arrive and the persisted lane state is final. A pool is
2720 // owner-closed only, so there is nothing to close here. The poller cancel
2721 // above triggers settlement's tail stop and checkpoint flush asynchronously
2722 // (best-effort, not awaited before this point), so this quiesces the redeemer
2723 // and runs one final best-effort redeem sweep so an above-threshold lane is
2724 // not left un-redeemed. The redeem sweep does not depend on the checkpoint
2725 // flush having completed — the scan checkpoint is independent lane state and
2726 // idempotent to re-scan. Bounded by the deadline
2727 // so a slow RPC cannot hang shutdown.
2728 payment_service.shutdown(SHUTDOWN_DEADLINE).await;
2729
2730 // Final durable flush before stop, so the last interval of frontier lands.
2731 // The router has drained and the redeem sweep above already ran, so the
2732 // in-memory lane state is final; this is the last write before the
2733 // background flush task is aborted below.
2734 let flush_result = match tokio::task::spawn_blocking(move || channel_state_store.flush()).await
2735 {
2736 Ok(result) => result,
2737 Err(join_err) => Err(decdn_incentive::StoreError::Backend(format!(
2738 "flush join: {join_err}"
2739 ))),
2740 };
2741 if let Err(err) = flush_result {
2742 node_metrics.lane_flush_failure();
2743 tracing::warn!(error = %err, "final lane store flush on shutdown failed");
2744 }
2745 lane_flush_task.abort();
2746
2747 // Late admin stop (issue #604 `AfterRouter` path). The polling
2748 // client (`decdn node drain --wait`) needed admin to stay open
2749 // while `router.shutdown` awaited the last in-flight client
2750 // streams; now that it's returned, tear admin down so the polling
2751 // client either sees `in_flight_streams == 0` on its next tick or
2752 // observes ECONNREFUSED — both of which it treats as "drain
2753 // complete".
2754 //
2755 // If the receiver has already dropped (i.e. the admin task
2756 // crashed/panicked between the Early skip and this point), that's
2757 // a violated invariant — `decdn node drain --wait` clients may
2758 // have misread the ECONNREFUSED as drain completion while
2759 // in-flight streams were still draining. Log at `error!`
2760 // accordingly so post-mortems surface it.
2761 //
2762 // A router close that gave up at its deadline still reaches here: its
2763 // handlers were cancelled first, so no stream is in flight, and
2764 // `close_router` has already logged the stuck endpoint close.
2765 if matches!(stop_order, AdminStopOrder::AfterRouter)
2766 && let Some(tx) = admin_stop_tx.take()
2767 && tx.send(()).is_err()
2768 {
2769 tracing::error!(
2770 "admin server exited while runtime was awaiting router.shutdown; \
2771 `decdn node drain --wait` clients may have observed ECONNREFUSED \
2772 before in-flight streams completed (false drain-complete)"
2773 );
2774 }
2775
2776 // Flush the cache store before the drain deadline so in-flight writes
2777 // hit disk. Intentionally *not* gated by `SHUTDOWN_DEADLINE`: a slow
2778 // flush is preferable to a lost write, and the watchdog at the outer
2779 // process level catches a truly stuck shutdown. On failure we still
2780 // finish the task drain cleanly (so metrics/dispatch don't leak) and
2781 // then bubble the error out of `run()` — supervisors need a non-zero
2782 // exit to know the store may be inconsistent.
2783 let cache_shutdown_err = cache.shutdown().await.err();
2784 if let Some(err) = cache_shutdown_err.as_ref() {
2785 tracing::error!(error = %err.display_chain(), "cache shutdown failed; store state may be inconsistent");
2786 }
2787
2788 // Last-resort abort handle for the RPC watchdog, which lives *outside* the
2789 // `JoinSet`. On the normal path the watchdog oneshot drains it cleanly;
2790 // this is fired only if `drain` overruns `SHUTDOWN_DEADLINE`, because
2791 // dropping the timed-out `drain` future would otherwise merely *detach*
2792 // a wedged task (a dropped `JoinHandle` keeps running), not stop it.
2793 // `tasks.abort_all()` covers the `JoinSet` (reaped + logged per task
2794 // below); this covers the watchdog as a fire-and-forget abort — we do NOT
2795 // await it past the deadline, since `abort()` only lands at a poll point
2796 // and a truly non-yielding loop would re-hang the shutdown the timeout
2797 // escaped.
2798 let watchdog_abort = rpc_watchdog_handle
2799 .as_ref()
2800 .map(tokio::task::JoinHandle::abort_handle);
2801 // The receipt writer also lives outside `tasks`; same fire-and-forget abort
2802 // backstop if the drain overruns the deadline (#803).
2803 let receipt_writer_abort = receipt_writer.abort_handle();
2804 // The warming-credit aggregator is in the same position: outside `tasks`,
2805 // cancelled above, awaited in the drain, aborted if the drain overruns.
2806 let warming_creditor_abort = warming_creditor.abort_handle();
2807
2808 let drain = async {
2809 while let Some(result) = tasks.join_next().await {
2810 log_join_result(result, "shutdown");
2811 }
2812 // Stop-and-await the receipt writer so the runtime doesn't return while
2813 // it's still flushing its tail. `StopHandle::shutdown` cancels before
2814 // it joins (a no-op after the early cancel above), so the join cannot
2815 // hang on the sinks the client handler holds; a panic surfaces at
2816 // `warn` and a cancellation at `debug`, matching how every other
2817 // drained task is logged.
2818 log_join_result(receipt_writer.shutdown().await, "receipt-writer-shutdown");
2819 // Stop-and-await the aggregator for the same reason, and so that a task
2820 // that died earlier in the run surfaces here as a join error rather
2821 // than as credits that silently stopped landing.
2822 log_join_result(
2823 warming_creditor.shutdown().await,
2824 "warming-creditor-shutdown",
2825 );
2826 // Await the RPC watchdog. We signalled it via oneshot above, so
2827 // a healthy run resolves cleanly here. A panic surfaces as a
2828 // warning; cancellation is silent (same as the receipt writer).
2829 if let Some(handle) = rpc_watchdog_handle
2830 && let Err(err) = handle.await
2831 && !err.is_cancelled()
2832 {
2833 tracing::warn!(error = %err, "RPC watchdog task panicked during shutdown");
2834 }
2835 };
2836 if tokio::time::timeout(SHUTDOWN_DEADLINE, drain).await.is_ok() {
2837 tracing::info!("graceful shutdown complete");
2838 } else {
2839 tracing::warn!(
2840 deadline = ?SHUTDOWN_DEADLINE,
2841 out_of_joinset_aborts = usize::from(watchdog_abort.is_some()) + 2,
2842 "graceful shutdown timed out; aborting remaining tasks",
2843 );
2844 tasks.abort_all();
2845 // The RPC watchdog lives outside `tasks`; dropping the timed-out
2846 // `drain` only detached it, so abort explicitly. Unlike the
2847 // `JoinSet`, it is not reaped/awaited afterwards (see the rationale
2848 // where the abort handle is collected) — the count above is its
2849 // only per-shutdown record.
2850 if let Some(abort) = &watchdog_abort {
2851 abort.abort();
2852 }
2853 // Aborting the writer mid-tail-drain discards any still-enqueued audit
2854 // receipts. This only happens once shutdown has already blown its
2855 // deadline (an abnormal, already-warned event), but name it specifically
2856 // so a post-mortem can correlate a gap in `download_receipts.jsonl` with
2857 // the overrun — the drop counter does not cover this path (#803).
2858 tracing::warn!(
2859 event = "receipt_writer_aborted",
2860 "receipt writer aborted at shutdown deadline; enqueued audit receipts may be lost"
2861 );
2862 receipt_writer_abort.abort();
2863 // Aborting the aggregator mid-drain discards the still-queued warming
2864 // credits. Conservative in the same direction as a `Full` drop — the
2865 // affected sources stay more negative than reality until the time refill
2866 // catches up — and the allowance is in-memory bookkeeping a restart
2867 // rebuilds anyway.
2868 tracing::warn!(
2869 event = "warming_creditor_aborted",
2870 "warming-credit aggregator aborted at shutdown deadline; \
2871 queued serve credits are lost"
2872 );
2873 warming_creditor_abort.abort();
2874 while let Some(result) = tasks.join_next().await {
2875 log_join_result(result, "abort");
2876 }
2877 }
2878
2879 tracing::info!("node stopped");
2880 match cache_shutdown_err {
2881 None => Ok(()),
2882 Some(err) => Err(anyhow::Error::from(err).context("cache shutdown failed")),
2883 }
2884}
2885
2886/// Build an iroh `Endpoint` bound to `bind_port` on both address families:
2887/// `0.0.0.0:bind_port` and `[::]:bind_port`. ALPNs are set by the `Router`
2888/// when it spawns. The bind addresses are included in the error message so a
2889/// port collision surfaces as a config-level diagnostic (matching
2890/// `metrics::bind` and `admin::bind`), rather than an opaque "endpoint bind
2891/// failed".
2892///
2893/// The IPv6 bind replaces iroh's default `[::]:0` socket, so a dual-stack node
2894/// listens on one stable port per family and an operator can register an
2895/// `/ip6/<addr>/udp/<bind_port>/quic-v1` multiaddr. The two sockets do not
2896/// collide: netwatch sets `IPV6_V6ONLY` on every IPv6 socket. The IPv4 bind is
2897/// required. The IPv6 bind is not: a host without IPv6 (disabled by sysctl, or
2898/// a container without a v6 stack) or with `[::]:bind_port` taken still starts
2899/// on IPv4 only, and [`warn_if_no_ipv6_socket`] logs the degraded state.
2900///
2901/// No transient pre-bind probe: it would introduce a TOCTOU window
2902/// between the probe and the real bind, and iroh's
2903/// `Endpoint::builder().bind_addr(...).bind()` owns its UDP socket
2904/// internally — there is nothing to hand off. Single-bind matches the
2905/// existing TCP listeners.
2906async fn build_endpoint(
2907 secret_key: &SecretKey,
2908 bind_port: u16,
2909 relay_urls: &[String],
2910 discovery: &ResolvedDiscovery,
2911 transport_config: QuicTransportConfig,
2912) -> anyhow::Result<Endpoint> {
2913 let bind_v4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, bind_port);
2914 let bind_v6 = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, bind_port, 0, 0);
2915
2916 // Discovery selection is the discovery-provider seam (#818, see
2917 // adr/appendix-poc-production-seams.md). Both branches start from
2918 // `presets::Minimal` (crypto provider only) and compose the lookup legs
2919 // explicitly, so the node's own pkarr publisher carries
2920 // `node_publish_filter` (relay URL plus public IPs) rather than iroh's
2921 // relay-only default (ADR 001 § Node Discovery). With no `network.discovery`
2922 // keys the legs are the n0-hosted pkarr publisher and DNS lookup that
2923 // `presets::N0` wires; with operator discovery configured they are exactly
2924 // the configured legs. Discovery and relay are independent legs: `Minimal`
2925 // sets NO relay mode, so the n0 relay default is restored here, and a custom
2926 // `network.relay_urls` map (if any) replaces it below.
2927 let mut builder = if discovery.is_empty() {
2928 Endpoint::builder(presets::Minimal)
2929 .address_lookup(PkarrPublisher::n0_dns().addr_filter(node_publish_filter()))
2930 .address_lookup(DnsAddressLookup::n0_dns())
2931 } else {
2932 add_discovery_lookups(Endpoint::builder(presets::Minimal), discovery)?
2933 };
2934 builder = builder.relay_mode(RelayMode::Default);
2935
2936 builder = builder
2937 .secret_key(secret_key.clone())
2938 .transport_config(transport_config);
2939
2940 // A configured `network.relay_urls` list swaps the n0 default relay map for
2941 // the operator's self-hosted relays (`RelayMode::Custom`). The relay leg is
2942 // independent of the discovery leg selected above: a custom relay map routes
2943 // connectivity through the operator's relays while NodeId→address discovery
2944 // uses whichever provider the base-preset branch wired (n0 pkarr/DNS, or the
2945 // operator's `network.discovery` providers). Multiple entries give
2946 // redundancy/failover.
2947 if !relay_urls.is_empty() {
2948 let relays = parse_relay_urls(relay_urls)?;
2949 builder = builder.relay_mode(RelayMode::Custom(RelayMap::from_iter(relays)));
2950 }
2951
2952 let ep = builder
2953 .bind_addr(bind_v4)
2954 .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v4}: {e}"))?
2955 .bind_addr_with_opts(bind_v6, BindOpts::default().set_is_required(false))
2956 .map_err(|e| anyhow::anyhow!("invalid bind addr {bind_v6}: {e}"))?
2957 .bind()
2958 .await
2959 .map_err(|e| anyhow::anyhow!("endpoint bind {bind_v4} + {bind_v6} failed: {e}"))?;
2960 warn_if_no_ipv6_socket(&ep.bound_sockets(), bind_v6);
2961 // A relay sits on the NAT-traversal critical path, so bring-up reports
2962 // whether one connected. The check is advisory and runs in the background:
2963 // a transient relay outage during a rolling restart must not delay or
2964 // wedge startup, and iroh keeps retrying the relay on its own.
2965 tokio::spawn(report_home_relay(ep.home_relay_status(), HOME_RELAY_GRACE));
2966 Ok(ep)
2967}
2968
2969/// How long after bind the node waits for a home relay connection before
2970/// [`report_home_relay`] warns.
2971const HOME_RELAY_GRACE: Duration = Duration::from_secs(15);
2972
2973/// Wait up to `grace` for any home relay to connect, and warn with each
2974/// relay's last connection error when none does. Returns whether a relay
2975/// connected in time.
2976///
2977/// The status comes from iroh's relay client, so it reflects relay-protocol
2978/// health end to end: a fronting load balancer that accepts TCP while the
2979/// relay behind it is down reports as not connected. A relay that denies this
2980/// node's authentication is called out separately, since iroh's retries
2981/// present the same credentials and never recover from it. Relay URLs and
2982/// error chains are logged without credentials.
2983async fn report_home_relay(
2984 mut status: impl iroh::Watcher<Value = Vec<iroh::endpoint::RelayStatus>>,
2985 grace: Duration,
2986) -> bool {
2987 let deadline = tokio::time::sleep(grace);
2988 tokio::pin!(deadline);
2989 let mut relays = status.get();
2990 loop {
2991 if let Some(up) = relays.iter().find(|relay| relay.is_connected()) {
2992 tracing::info!(
2993 relay = %redact_userinfo(up.url().as_str()),
2994 "home relay connected"
2995 );
2996 return true;
2997 }
2998 tokio::select! {
2999 () = &mut deadline => break,
3000 next = status.updated() => match next {
3001 Ok(value) => relays = value,
3002 // The last endpoint handle is gone; nothing left to report on.
3003 Err(_) => return false,
3004 },
3005 }
3006 }
3007 warn_no_home_relay(&relays, grace);
3008 false
3009}
3010
3011/// The warning half of [`report_home_relay`]: one line per home relay with
3012/// its last error, or one line when iroh has not selected a relay at all.
3013fn warn_no_home_relay(relays: &[iroh::endpoint::RelayStatus], grace: Duration) {
3014 if relays.is_empty() {
3015 tracing::warn!(
3016 ?grace,
3017 "no home relay selected after bring-up; relay-assisted connectivity is down \
3018 while iroh keeps retrying. Check network.relay_urls and outbound HTTPS"
3019 );
3020 }
3021 for relay in relays {
3022 let url = redact_userinfo(relay.url().as_str());
3023 if let Some(reason) = relay.auth_denied_reason() {
3024 tracing::warn!(
3025 relay = %url,
3026 reason = %sanitize_rpc_display(reason),
3027 "home relay denied this node's authentication; retries will not recover \
3028 until the relay's access policy changes"
3029 );
3030 } else {
3031 tracing::warn!(
3032 relay = %url,
3033 ?grace,
3034 error = relay.last_error().map(|e| sanitize_error_sources(e)),
3035 "home relay not connected after bring-up; iroh keeps retrying in the \
3036 background. Check the relay URL and that the relay is up"
3037 );
3038 }
3039 }
3040}
3041
3042/// Log a warning when the endpoint holds no IPv6 socket. `build_endpoint`
3043/// binds `[::]:bind_port` as optional, so iroh drops a failed IPv6 bind with
3044/// only an info-level note. This warning tells the operator that the node is
3045/// IPv4-only and that an `/ip6/...` multiaddr does not reach it. Returns
3046/// whether an IPv6 socket is bound.
3047fn warn_if_no_ipv6_socket(bound: &[SocketAddr], bind_v6: SocketAddrV6) -> bool {
3048 let has_v6 = bound.iter().any(SocketAddr::is_ipv6);
3049 if !has_v6 {
3050 tracing::warn!(
3051 %bind_v6,
3052 "IPv6 bind failed; the node listens on IPv4 only — an /ip6/ multiaddr does not \
3053 reach it. Enable IPv6 on the host or free the port, or register /ip4/ \
3054 multiaddrs only"
3055 );
3056 }
3057 has_v6
3058}
3059
3060/// Compose the operator-configured `network.discovery` address-lookup legs onto
3061/// `builder` (#818 scope 1) — the `node`-side half of the discovery-provider
3062/// seam: config resolution holds plain shape-validated Strings, and this is
3063/// where they become iroh providers.
3064///
3065/// - `pkarr_url` + `dns_origin` wire a `PkarrPublisher` (this node publishes its
3066/// signed address record, filtered by `node_publish_filter`) and a
3067/// `DnsAddressLookup` (it resolves peers via DNS TXT). The endpoint builder pulls this node's secret key and TLS config into
3068/// the publisher automatically, so we do not pass the key here.
3069/// - `peers` seed a `MemoryLookup` static address book.
3070///
3071/// The `pkarr_url` (parsed as `url::Url`) and peer `node_id` (parsed as
3072/// `iroh::PublicKey`) are re-parsed here with the exact parsers config
3073/// resolution used, so a failure on those legs is an internal invariant break.
3074/// A peer `relay_url` is parsed as the stricter `iroh::RelayUrl` here while
3075/// resolution only checked it as a generic `url::Url` (mirroring the top-level
3076/// `network.relay_urls` path, where bring-up's `RelayUrl` parse is the
3077/// authoritative relay-shape gate) — so that leg can legitimately fail here on a
3078/// relay-specific shape issue. Every failure is surfaced as a bring-up error
3079/// (never a panic), and any echoed URL is `redact_userinfo`'d, upholding the
3080/// same no-credentials-in-errors invariant as the relay path.
3081fn add_discovery_lookups(
3082 mut builder: iroh::endpoint::Builder,
3083 discovery: &ResolvedDiscovery,
3084) -> anyhow::Result<iroh::endpoint::Builder> {
3085 if let Some(pkarr) = &discovery.pkarr_url {
3086 let url = pkarr.parse::<url::Url>().map_err(|e| {
3087 anyhow::anyhow!(
3088 "invalid network.discovery.pkarr_url {:?}: {e}",
3089 redact_userinfo(pkarr)
3090 )
3091 })?;
3092 builder =
3093 builder.address_lookup(PkarrPublisher::builder(url).addr_filter(node_publish_filter()));
3094 }
3095 if let Some(origin) = &discovery.dns_origin {
3096 builder = builder.address_lookup(DnsAddressLookup::builder(origin.clone()));
3097 }
3098 if !discovery.peers.is_empty() {
3099 let mut infos = Vec::with_capacity(discovery.peers.len());
3100 for peer in &discovery.peers {
3101 let id = peer.node_id.parse::<PublicKey>().map_err(|e| {
3102 anyhow::anyhow!("invalid network.discovery peer id {}: {e}", peer.node_id)
3103 })?;
3104 let mut addr = EndpointAddr::new(id);
3105 if let Some(relay) = &peer.relay_url {
3106 let relay_url = relay.parse::<RelayUrl>().map_err(|e| {
3107 anyhow::anyhow!(
3108 "invalid relay_url {:?} for network.discovery peer {}: {e}",
3109 redact_userinfo(relay),
3110 peer.node_id
3111 )
3112 })?;
3113 addr = addr.with_relay_url(relay_url);
3114 }
3115 for a in &peer.addrs {
3116 let sock = a.parse::<std::net::SocketAddr>().map_err(|e| {
3117 anyhow::anyhow!(
3118 "invalid addr {a:?} for network.discovery peer {}: {e}",
3119 peer.node_id
3120 )
3121 })?;
3122 addr = addr.with_ip_addr(sock);
3123 }
3124 infos.push(addr);
3125 }
3126 builder = builder.address_lookup(MemoryLookup::from_endpoint_info(infos));
3127 }
3128 Ok(builder)
3129}
3130
3131/// Parse the configured relay URL strings into `RelayUrl`s, surfacing the
3132/// offending entry on a parse failure. The entry is `redact_userinfo`'d first:
3133/// a malformed URL can still carry `user:pass@` credentials, and this node
3134/// never lets relay userinfo reach the logs (same invariant
3135/// [`report_home_relay`] upholds).
3136fn parse_relay_urls(urls: &[String]) -> anyhow::Result<Vec<RelayUrl>> {
3137 urls.iter()
3138 .map(|u| {
3139 u.parse::<RelayUrl>().map_err(|e| {
3140 anyhow::anyhow!(
3141 "invalid network.relay_urls entry {:?}: {e}",
3142 redact_userinfo(u)
3143 )
3144 })
3145 })
3146 .collect()
3147}
3148
3149/// Resolve the keystore password, then decrypt the keystore JSON via alloy's
3150/// KDF on a blocking thread.
3151///
3152/// Sources in precedence order: the `DECDN_KEYSTORE_PASSWORD` env var, then
3153/// `blockchain.keystore_password_file`, then an interactive prompt on a TTY.
3154/// Presence decides at each step (see [`eth_identity::read_password`]): a
3155/// variable that is set and a file that exists each supply their value, the
3156/// empty string included, so a headless host reaches one of the first two or
3157/// the startup fails. The list comes from
3158/// [`eth_identity::standard_sources`], the single builder every binary shares.
3159///
3160/// The Arbitrum Sepolia
3161/// chain id is bound on the signer so EIP-712 signers and any
3162/// `eth_sendTransaction` paths inherit a deterministic value. To target a
3163/// different chain, thread the value through `ResolvedBlockchain` next to
3164/// `rpc_url` (chain-id-keyed config is already a seam pattern; see
3165/// `appendix-poc-production-seams.md` §Seam 8).
3166async fn load_eth_signer(cfg: &ResolvedConfig) -> anyhow::Result<PrivateKeySigner> {
3167 use alloy::signers::Signer;
3168
3169 let sources = eth_identity::standard_sources(
3170 cfg.blockchain.keystore_password_file.clone(),
3171 eth_identity::PasswordUse::Unlock,
3172 );
3173 let resolved = eth_identity::read_password(&sources, "eth keystore password")?;
3174 // Surface a configured-but-unused password source (a typo'd or shadowed
3175 // password file). The daemon has a `tracing` subscriber, so unlike the CLI
3176 // it logs rather than printing.
3177 for warning in resolved.warnings() {
3178 tracing::warn!(%warning, "keystore password source ignored");
3179 }
3180 let password = resolved.into_secret();
3181
3182 let path = cfg.blockchain.eth_keystore.clone();
3183 // `spawn_blocking` because alloy's `decrypt_keystore` runs scrypt /
3184 // argon2 (synchronous, CPU-bound). Calling it on the runtime's worker
3185 // thread would stall every other task for the duration of the KDF.
3186 let signer = tokio::task::spawn_blocking(move || eth_identity::load_signer(&path, &password))
3187 .await
3188 .map_err(|e| anyhow::anyhow!("keystore decrypt task panicked: {e}"))??;
3189 // Bind the signer to the *configured* chain id, not a hardcoded
3190 // constant, so the signer's chain id and the `slash_sig` EIP-712 domain
3191 // chain id (also `cfg.blockchain.chain_id`, see the slash domain build)
3192 // can never silently diverge — e.g. when the chain target changes via
3193 // the seam in `appendix-poc-production-seams.md` §Seam 8. Note: raw
3194 // EIP-712 `sign_hash_sync` does not consult the signer's bound chain
3195 // id, so this binding only matters for any future `eth_sendTransaction`
3196 // path; keeping it single-sourced is defensive against that future code.
3197 Ok(signer.with_chain_id(Some(cfg.blockchain.chain_id)))
3198}
3199
3200/// Log a `JoinError` from a shutdown-drained task with a phase label.
3201fn log_join_result(result: Result<(), tokio::task::JoinError>, phase: &'static str) {
3202 if let Err(err) = result {
3203 if err.is_cancelled() {
3204 tracing::debug!(phase, "task cancelled during shutdown");
3205 } else {
3206 tracing::warn!(phase, error = %err, "task failed during shutdown");
3207 }
3208 }
3209}
3210
3211/// Admission/eviction policy selection (ADR 040) plus the ADR 041 estimator
3212/// decoupling. One shared frequency estimator feeds both the engine's
3213/// hit-signal sink and whichever policy objects need it; the engine itself
3214/// owns no policy knowledge beyond the estimator handle. The ADR 041 `margin`
3215/// serve-economics policy also consumes the estimator's heat signal (as its
3216/// buy-ceiling `n_hat` input), independent of which cache eviction/admission
3217/// policy is selected — so a node running plain `lru`/`always` cache policy
3218/// with `margin` serve economics still needs the estimator built and wired
3219/// into the cache. Returns the selected eviction policy and the estimator
3220/// itself (`None` when no consumer requested one) — callers thread the latter
3221/// on to `Infra::frequency_estimator` for the ADR 041 buy-side gate.
3222fn wire_cache_policies(
3223 cache_cfg: &decdn_common::config::resolved::ResolvedCache,
3224 cache: &CacheEngine,
3225) -> (
3226 Arc<dyn decdn_cache::EvictionPolicy>,
3227 Option<Arc<dyn decdn_cache::FrequencyEstimator>>,
3228) {
3229 let want_estimator = cache_cfg.eviction_policy == "tinylfu"
3230 || cache_cfg.admission_policy == "tinylfu"
3231 || cache_cfg.serve_economics.policy == "margin";
3232 let estimator: Option<Arc<dyn decdn_cache::FrequencyEstimator>> = want_estimator.then(|| {
3233 Arc::new(decdn_cache::policy::tinylfu::TinyLfuEstimator::new(
3234 cache_cfg.tinylfu.sketch_bytes,
3235 )) as Arc<dyn decdn_cache::FrequencyEstimator>
3236 });
3237 if let Some(est) = &estimator {
3238 cache.set_frequency_estimator(est.clone());
3239 }
3240 if cache_cfg.admission_policy == "tinylfu"
3241 && let Some(est) = &estimator
3242 {
3243 cache.set_admission_policy(Arc::new(decdn_cache::policy::tinylfu::ProbationAdmission {
3244 freq: est.clone(),
3245 promotion_threshold: cache_cfg.tinylfu.promotion_threshold,
3246 }));
3247 }
3248 // promotion_threshold + probation_target_pct live entirely on the policy
3249 // object, not on the engine or the eviction driver's `EvictionParams`.
3250 let eviction_policy: Arc<dyn decdn_cache::EvictionPolicy> =
3251 match cache_cfg.eviction_policy.as_str() {
3252 "tinylfu" => match &estimator {
3253 Some(est) => Arc::new(decdn_cache::policy::tinylfu::TinyLfuEviction::new(
3254 est.clone(),
3255 cache_cfg.tinylfu.promotion_threshold,
3256 cache_cfg.tinylfu.probation_target_pct,
3257 )),
3258 // Unreachable: `want_estimator` is true whenever eviction_policy
3259 // == "tinylfu", so `estimator` is always `Some` here.
3260 None => Arc::new(decdn_cache::policy::LruEviction),
3261 },
3262 _ => Arc::new(decdn_cache::policy::LruEviction),
3263 };
3264
3265 // `tinylfu` admission only does useful work paired with `tinylfu` eviction:
3266 // promotion out of probation and the probation cap both live in
3267 // `TinyLfuEviction::plan`. With `lru` eviction the probation labels are set
3268 // but never promoted or capped, and the estimator pays a per-serve cost for
3269 // no effect. Warn rather than silently no-op (the resolver already rejects
3270 // typos; this valid-but-inert combination deserves a heads-up).
3271 if cache_cfg.admission_policy == "tinylfu" && cache_cfg.eviction_policy != "tinylfu" {
3272 tracing::warn!(
3273 admission_policy = %cache_cfg.admission_policy,
3274 eviction_policy = %cache_cfg.eviction_policy,
3275 "cache.admission_policy = \"tinylfu\" is inert unless cache.eviction_policy is \
3276 also \"tinylfu\": probation admission relies on the tinylfu eviction policy to \
3277 promote and cap probation members; under lru eviction the labels do nothing and \
3278 the frequency estimator runs for no effect",
3279 );
3280 }
3281
3282 (eviction_policy, estimator)
3283}
3284
3285/// Construct the cache engine from resolved config. The
3286/// `[cache.origins]` array picks an ordered list of backends — HTTP,
3287/// filesystem, or S3 (#437, #284). The singular `[cache.origin]` TOML
3288/// form is collapsed by the resolver into a single-element vec, so
3289/// this function sees one canonical representation. An empty vec means
3290/// no pull-through is configured; the engine then serves only
3291/// already-cached content and cache misses surface as
3292/// `CacheError::NoOrigin`.
3293///
3294/// Init failure is fail-fast: if origin #i can't be constructed (bad
3295/// path, S3 auth failure, etc.) the node refuses to start and the
3296/// context chain identifies which entry. Skip-and-warn would let a
3297/// misconfigured fallback rot silently until the primary fails — the
3298/// opposite of operator intent.
3299///
3300/// `node_metrics` provides the shared `Arc<CacheMetrics>` that the
3301/// engine bumps on origin fetches, retry exhaustions (#285), and
3302/// chain-walk fallback advances (#284).
3303async fn build_cache(
3304 cfg: &ResolvedConfig,
3305 node_metrics: Arc<metrics::Metrics>,
3306 node_origin: Option<Arc<dyn Origin>>,
3307) -> anyhow::Result<CacheEngine> {
3308 // HTTP and S3 origins capture the runtime they are built on and run every
3309 // request send there, so their pooled connections outlive the pull-leg
3310 // runtimes that call them. This runs on the node's main runtime.
3311 let mut origins: Vec<Arc<dyn Origin>> = Vec::with_capacity(cfg.cache.origins.len());
3312 for (idx, resolved) in cfg.cache.origins.iter().enumerate() {
3313 let backend: Arc<dyn Origin> = match resolved {
3314 ResolvedOrigin::Http { url, decompress } => Arc::new(
3315 HttpOrigin::new_with_user_agent(url.clone(), &cfg.cache.user_agent)
3316 .with_context(|| {
3317 format!("failed to build HTTP origin client for cache.origins[{idx}]")
3318 })?
3319 .with_decompress_mode(*decompress),
3320 ),
3321 ResolvedOrigin::Fs { path } => {
3322 Arc::new(FilesystemOrigin::new(path.clone()).await.with_context(|| {
3323 format!("failed to open filesystem origin for cache.origins[{idx}]")
3324 })?)
3325 }
3326 // S3-compatible origin (#437). Conversion from the resolved-
3327 // config form to the cache-crate's runtime form happens here
3328 // because `decdn-cache` deliberately doesn't depend on
3329 // `decdn-common` (#578); `node` is the crate that sees both.
3330 ResolvedOrigin::S3(s3_cfg) => Arc::new(
3331 S3Origin::new(&s3_origin_config_from_resolved(s3_cfg))
3332 .await
3333 .with_context(|| {
3334 format!("failed to construct S3 origin client for cache.origins[{idx}]")
3335 })?
3336 .with_decompress_mode(s3_cfg.decompress),
3337 ),
3338 };
3339 origins.push(backend);
3340 }
3341 // Append the node-to-node pull origin LAST so configured HTTP/FS/S3 origins
3342 // are tried first and the paid network pull is the final fallback (#831).
3343 if let Some(node_origin) = node_origin {
3344 origins.push(node_origin);
3345 }
3346 let engine = CacheEngine::open_full(
3347 &cfg.cache.cache_dir,
3348 origins,
3349 cfg.cache.max_blob_size_mb,
3350 cfg.cache.pinned_hashes.clone(),
3351 cfg.cache.origin_retry,
3352 cfg.cache.circuit_breaker,
3353 Some(node_metrics.cache_metrics()),
3354 std::time::Duration::from_secs(cfg.cache.gc_interval_sec),
3355 )
3356 .await
3357 .context("failed to open cache engine")?;
3358 // `cache.*` is restart-required (not hot-reloaded), so applying the
3359 // probe-hold budget once here is sufficient (ADR 005 §Hold budget, #318).
3360 engine.set_max_probe_holds(cfg.cache.max_probe_holds);
3361 // One origin read on the range-pull path (an outboard fetch or one data
3362 // window) gets the streaming stage's throughput floor: the stall window as a
3363 // head start, plus the read size at the minimum throughput. The budget
3364 // scales with the read, so it never caps the blob size, and a stuck origin
3365 // ends the fill rather than parking it until the client gives up (ADR 037).
3366 engine.set_origin_read_budget(
3367 std::time::Duration::from_secs(cfg.cache.node_pull_stall_window_sec),
3368 cfg.cache.node_pull_min_throughput_bps,
3369 );
3370 // Live-origin probe memo (#1130 pt3) — likewise restart-configured once.
3371 engine.set_origin_probe_config(decdn_cache::origin_probe::OriginProbePolicy {
3372 positive_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_ttl_sec),
3373 negative_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_negative_ttl_sec),
3374 fault_ttl: std::time::Duration::from_secs(cfg.cache.origin_probe_fault_ttl_sec),
3375 timeout: std::time::Duration::from_millis(cfg.cache.origin_probe_timeout_ms),
3376 capacity: usize::try_from(cfg.cache.origin_probe_memo_capacity).unwrap_or(usize::MAX),
3377 });
3378 Ok(engine)
3379}
3380
3381/// Translate the `decdn-common` resolved-config S3 form into the
3382/// `decdn-cache` runtime form. The two types carry the same data —
3383/// they exist as separate types only because `decdn-cache` deliberately
3384/// doesn't pull `decdn-common` (the dep direction is `common -> cache`).
3385///
3386/// Credential redaction (`SecretString::expose`) happens here, at the
3387/// crate boundary: the SDK call site gets plain `String`s, and the
3388/// `Debug`-redacted wrapper stays inside the config layer where it
3389/// guards against incidental log leaks.
3390fn s3_origin_config_from_resolved(cfg: &decdn_common::config::ResolvedS3Config) -> S3OriginConfig {
3391 let credentials = cfg.credentials.as_ref().map(|c| match c {
3392 ResolvedS3Credentials::Static {
3393 access_key_id,
3394 secret_access_key,
3395 session_token,
3396 } => S3Credentials::Static {
3397 access_key_id: access_key_id.expose().to_string(),
3398 secret_access_key: secret_access_key.expose().to_string(),
3399 session_token: session_token.as_ref().map(|t| t.expose().to_string()),
3400 },
3401 ResolvedS3Credentials::DefaultChain { profile } => S3Credentials::DefaultChain {
3402 profile: profile.clone(),
3403 },
3404 });
3405 S3OriginConfig {
3406 bucket: cfg.bucket.clone(),
3407 region: cfg.region.clone(),
3408 endpoint_url: cfg.endpoint_url.clone(),
3409 path_style: cfg.path_style,
3410 prefix: cfg.prefix.clone(),
3411 credentials,
3412 }
3413}
3414
3415/// Stable comma-separated label for the resolved-origin chain, used
3416/// in startup logs so operators can grep for `origin_kinds=http,s3`
3417/// without parsing the structured fields back out. Returns `"none"`
3418/// when the chain is empty (rather than emitting an empty string) so
3419/// the field is always present and machine-parseable.
3420fn origin_kinds_label(origins: &[ResolvedOrigin]) -> String {
3421 if origins.is_empty() {
3422 return "none".to_string();
3423 }
3424 origins
3425 .iter()
3426 .map(|o| match o {
3427 ResolvedOrigin::Http { .. } => "http",
3428 ResolvedOrigin::Fs { .. } => "fs",
3429 ResolvedOrigin::S3(_) => "s3",
3430 })
3431 .collect::<Vec<_>>()
3432 .join(",")
3433}
3434
3435/// Which OS signal (or admin RPC call) triggered shutdown. Returned by
3436/// [`ShutdownStreams::recv`] or synthesised by the `drain_trigger` arm so
3437/// the "shutdown signal received" log line records the cause (SIGINT,
3438/// SIGTERM, or admin drain) — operators grep that field for post-incident
3439/// analysis. `Sigterm` is unreachable on non-unix targets.
3440#[derive(Debug, Clone, Copy)]
3441enum ShutdownSignal {
3442 Sigint,
3443 #[cfg(unix)]
3444 Sigterm,
3445 /// Graceful shutdown requested via `admin_v1_drain` (issue #244).
3446 AdminDrain,
3447}
3448
3449impl std::fmt::Display for ShutdownSignal {
3450 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3451 match self {
3452 Self::Sigint => f.write_str("SIGINT"),
3453 #[cfg(unix)]
3454 Self::Sigterm => f.write_str("SIGTERM"),
3455 Self::AdminDrain => f.write_str("admin-drain"),
3456 }
3457 }
3458}
3459
3460/// Whether the admin server's stop signal fires before or after
3461/// `router.shutdown()` for this drain (issue #604). `Early` is the
3462/// SIGTERM-equivalent default; `AfterRouter` opts in to keeping the
3463/// admin port alive so `decdn node drain --wait` can poll
3464/// `admin_v1_health.in_flight_streams` until it reaches 0.
3465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3466enum AdminStopOrder {
3467 Early,
3468 AfterRouter,
3469}
3470
3471/// Decide the admin shutdown ordering for a drain.
3472///
3473/// `AfterRouter` is selected **only** when the drain originated from
3474/// the admin RPC (`AdminDrain`) *and* the in-flight RPC asked for
3475/// `wait_admin: true`. Signal-driven shutdowns (SIGINT/SIGTERM)
3476/// always pick `Early` — even if an `admin_v1_drain { wait_admin:
3477/// true }` call was in-flight when the signal landed, the operator's
3478/// stated intent wins. This prevents a "sticky `wait_admin`" race
3479/// where an RPC stored the flag, SIGTERM won the select, and the
3480/// runtime would otherwise take the `AfterRouter` path against the
3481/// operator's signal (issue #604 review).
3482fn admin_stop_order(signal: ShutdownSignal, drain_trigger: &admin::DrainTrigger) -> AdminStopOrder {
3483 if matches!(signal, ShutdownSignal::AdminDrain) && drain_trigger.wait_admin() {
3484 AdminStopOrder::AfterRouter
3485 } else {
3486 AdminStopOrder::Early
3487 }
3488}
3489
3490/// Persistent SIGHUP stream, installed once at startup. The
3491/// `Signal` instance lives on `self` for the lifetime of the runtime
3492/// so successive SIGHUPs delivered while a reload is in progress are
3493/// queued by tokio (kernel-coalesced) rather than dropped. Re-creating
3494/// the `Signal` per iteration of the select loop would race signal
3495/// delivery — that's the bug this struct exists to prevent.
3496///
3497/// On non-Unix targets `recv` returns a `pending` future since SIGHUP
3498/// doesn't exist there.
3499struct HupStream {
3500 #[cfg(unix)]
3501 hup: Option<tokio::signal::unix::Signal>,
3502}
3503
3504impl HupStream {
3505 fn install() -> Self {
3506 #[cfg(unix)]
3507 {
3508 use tokio::signal::unix::{SignalKind, signal};
3509 let hup = match signal(SignalKind::hangup()) {
3510 Ok(s) => Some(s),
3511 Err(err) => {
3512 tracing::warn!(error = %err, "failed to install SIGHUP handler; hot-reload disabled");
3513 None
3514 }
3515 };
3516 Self { hup }
3517 }
3518 #[cfg(not(unix))]
3519 {
3520 Self {}
3521 }
3522 }
3523
3524 /// Wait for the next SIGHUP on the persistent stream. If
3525 /// installation failed (`None`) or the platform isn't Unix, this
3526 /// future never resolves.
3527 ///
3528 /// `Signal::recv` resolves to `Option<()>`. `None` indicates the
3529 /// underlying stream has been closed (the driver dropped, signal
3530 /// handler torn down) — once that happens the future would resolve
3531 /// *immediately, forever*, and the runtime select-loop would
3532 /// hot-spin. Park on `pending` in that branch instead so hot-reload
3533 /// is silently disabled rather than turning into a spin-loop.
3534 async fn recv(&mut self) {
3535 #[cfg(unix)]
3536 {
3537 // Three cases:
3538 // 1. install succeeded + stream yielded a real signal →
3539 // return so the caller runs the reload arm.
3540 // 2. install succeeded but the stream is now closed →
3541 // `recv()` returns `None`, which would otherwise
3542 // resolve *immediately, forever* and hot-spin the
3543 // runtime's select. Take ownership, log once, and park
3544 // on `pending` for the rest of the process.
3545 // 3. install failed at startup (`self.hup` is `None`) →
3546 // already logged a warning at install time; just park
3547 // on `pending`.
3548 if let Some(stream) = self.hup.as_mut() {
3549 if stream.recv().await.is_some() {
3550 return;
3551 }
3552 self.hup = None;
3553 tracing::warn!("SIGHUP stream closed; hot-reload disabled for this process");
3554 }
3555 std::future::pending::<()>().await;
3556 }
3557 #[cfg(not(unix))]
3558 {
3559 std::future::pending::<()>().await
3560 }
3561 }
3562}
3563
3564/// Persistent SIGINT/SIGTERM streams. Installed once at startup so
3565/// rapid-fire shutdown signals (e.g. operator pressing Ctrl-C twice)
3566/// are coalesced by the kernel rather than potentially lost between
3567/// re-registrations of `Signal`.
3568struct ShutdownStreams {
3569 /// `tokio::signal::ctrl_c` is itself a one-shot future that
3570 /// re-registers internally — but `tokio::signal::unix::signal`
3571 /// (used for SIGTERM) is not. We park `ctrl_c()` inside `recv`
3572 /// directly and rely on tokio's own implementation for SIGINT.
3573 #[cfg(unix)]
3574 term: Option<tokio::signal::unix::Signal>,
3575}
3576
3577impl ShutdownStreams {
3578 fn install() -> Self {
3579 #[cfg(unix)]
3580 {
3581 use tokio::signal::unix::{SignalKind, signal};
3582 let term = match signal(SignalKind::terminate()) {
3583 Ok(s) => Some(s),
3584 Err(err) => {
3585 tracing::warn!(
3586 error = %err,
3587 "failed to install SIGTERM handler; falling back to SIGINT only",
3588 );
3589 None
3590 }
3591 };
3592 Self { term }
3593 }
3594 #[cfg(not(unix))]
3595 {
3596 Self {}
3597 }
3598 }
3599
3600 /// Wait for the next SIGINT or (on Unix) SIGTERM and report which
3601 /// one fired. The persistent SIGTERM stream lives on `self`, so
3602 /// successive calls observe successive signals — there's no
3603 /// race window between iterations of the runtime's select loop.
3604 ///
3605 /// `Signal::recv` resolves to `Option<()>`. A `None` from the
3606 /// SIGTERM arm would otherwise resolve immediately forever and
3607 /// short-circuit the select to a spurious shutdown; demote that
3608 /// branch to a `pending` future so we wait for a real SIGINT
3609 /// instead. (`tokio::signal::ctrl_c` re-registers internally and
3610 /// is safe to await directly.)
3611 async fn recv(&mut self) -> ShutdownSignal {
3612 #[cfg(unix)]
3613 {
3614 if let Some(term) = self.term.as_mut() {
3615 let race = tokio::select! {
3616 _ = tokio::signal::ctrl_c() => Some(ShutdownSignal::Sigint),
3617 res = term.recv() => res.map(|()| ShutdownSignal::Sigterm),
3618 };
3619 if let Some(sig) = race {
3620 return sig;
3621 }
3622 // SIGTERM arm closed (`None`). Drop the stream so we
3623 // don't keep racing a future that resolves immediately
3624 // forever, then wait for a real SIGINT.
3625 tracing::warn!("SIGTERM stream closed; falling back to SIGINT only");
3626 self.term = None;
3627 }
3628 let _ = tokio::signal::ctrl_c().await;
3629 ShutdownSignal::Sigint
3630 }
3631 #[cfg(not(unix))]
3632 {
3633 let _ = tokio::signal::ctrl_c().await;
3634 ShutdownSignal::Sigint
3635 }
3636 }
3637}
3638
3639/// Classification of an RPC preflight/watchdog probe (#1106/#1108). A
3640/// rate-limited (`429`) or server-side (`5xx`) response — and any
3641/// timeout/connection error — is [`Transient`](RpcProbe::Transient): the startup
3642/// preflight retries it with backoff rather than aborting the process, so a
3643/// commodity endpoint that throttles the startup burst does not crash-loop the
3644/// node. Any other non-2xx (a `4xx` such as a bad path/auth) is
3645/// [`Fatal`](RpcProbe::Fatal) — retrying will not help.
3646enum RpcProbe {
3647 Healthy,
3648 Transient(String),
3649 Fatal(String),
3650}
3651
3652/// Retry budget for [`check_deployment_preflight`]'s chain reads. Its own
3653/// [`BootRetry`] budget, independent of the shared boot deadline the chain
3654/// bootstraps below share: the RPC endpoint has just passed
3655/// [`check_rpc_reachability`], so a transient hiccup clears fast, and a real
3656/// outage should not hold the lane store hostage for long.
3657const DEPLOYMENT_PREFLIGHT_BUDGET: Duration = Duration::from_mins(1);
3658
3659/// Verify the configured `PaymentPool` deployment against the live chain:
3660/// the RPC's `eth_chainId` must equal `blockchain.chain_id`, the configured
3661/// address must have code, and that code must answer
3662/// `PaymentPool.getRateBounds()` — a code-presence check alone would pass a
3663/// sibling contract pasted from the same deploy manifest (`CapacityBond`,
3664/// `FeeRouter`, `BuybackBurner`, `DecdnGovernor`, the USDC token), and that is
3665/// the likeliest wrong-address typo. `getRateBounds()` is the probe because
3666/// only `PaymentPool` declares it: `usdc()` is also a view on `FeeRouter` and
3667/// `BuybackBurner`, and `feeRouter()` is also a view on `DecdnGovernor`. The
3668/// probe holds only while that stays true: no other contract in the deployment
3669/// may declare a function with the `getRateBounds()` selector, and
3670/// `contracts/src/PaymentPool.sol` carries the same warning on the view. The
3671/// `deployment_preflight_guard` e2e boots against every contract in the deploy
3672/// manifest to catch a collision, and the `payment_pool_probe_selector` e2e
3673/// fails if any contract in `contracts/src` other than `PaymentPool` exposes
3674/// the selector, deployed or not. Runs BEFORE the lane store opens, because
3675/// the store's deployment binding
3676/// ([`crate::channel_store::Deployment`]) drops the seller lane state when
3677/// the stamp differs — a typo in either field must abort bring-up while the
3678/// store is untouched, not destroy unredeemed lane state on a WARN. The
3679/// reads retry transient RPC errors on `retry`; the mismatch verdicts are
3680/// deterministic and fail at once, and a non-`PaymentPool` target surfaces
3681/// from `getRateBounds()` as a permanent contract error, not a retry spin.
3682async fn check_deployment_preflight<P: Provider + Clone>(
3683 provider: P,
3684 deployment: crate::channel_store::Deployment,
3685 retry: &BootRetry,
3686) -> anyhow::Result<()> {
3687 let (rpc_chain_id, code_len) = retry
3688 .run(
3689 "deployment preflight (eth_chainId + PaymentPool code)",
3690 || {
3691 let provider = provider.clone();
3692 async move {
3693 let chain_id = provider.get_chain_id().await.context("eth_chainId")?;
3694 let code = provider
3695 .get_code_at(deployment.payment_pool)
3696 .await
3697 .context("eth_getCode(payment_pool)")?;
3698 Ok((chain_id, code.len()))
3699 }
3700 },
3701 )
3702 .await?;
3703 anyhow::ensure!(
3704 rpc_chain_id == deployment.chain_id,
3705 "blockchain.chain_id is {} but the RPC endpoint serves chain {rpc_chain_id}; a \
3706 mismatched chain id would rebind the lane store and drop its seller state — fix \
3707 blockchain.chain_id or blockchain.rpc_url",
3708 deployment.chain_id,
3709 );
3710 anyhow::ensure!(
3711 code_len > 0,
3712 "blockchain.payment_pool_address {} has no code on chain {rpc_chain_id}; a wrong \
3713 address would rebind the lane store and drop its seller state — fix \
3714 blockchain.payment_pool_address",
3715 deployment.payment_pool,
3716 );
3717 // Contract-identity probe: `getRateBounds()` is a cheap view every
3718 // `PaymentPool` answers and no sibling contract declares. A contract
3719 // without it reverts (or, with no code path at all, returns no data), and
3720 // either answer is a permanent contract error that aborts at once.
3721 let contract =
3722 decdn_incentive::payment_pool::PaymentPool::new(deployment.payment_pool, provider);
3723 retry
3724 .run(
3725 "deployment preflight (PaymentPool.getRateBounds())",
3726 || async {
3727 contract.getRateBounds().call().await.with_context(|| {
3728 format!(
3729 "blockchain.payment_pool_address {} does not answer \
3730 PaymentPool.getRateBounds() on chain {rpc_chain_id}; the address hosts \
3731 some other contract, and a wrong address would rebind the lane store and \
3732 drop its seller state — fix blockchain.payment_pool_address",
3733 deployment.payment_pool,
3734 )
3735 })
3736 },
3737 )
3738 .await?;
3739 Ok(())
3740}
3741
3742/// Number of preflight attempts before a persistent transient failure is treated
3743/// as fatal (a real outage still aborts bring-up). Backoff caps at 8s, so the
3744/// worst-case wait is bounded (~5×5s request timeouts + 1+2+4+8s backoff).
3745const RPC_PREFLIGHT_MAX_ATTEMPTS: u32 = 5;
3746
3747/// Verify that the JSON-RPC endpoint is reachable by sending a lightweight
3748/// `net_version` request with a short timeout, retrying a transient (429/5xx/
3749/// timeout) response with bounded backoff (#1108) — a rate-limited endpoint must
3750/// not crash-loop the node at startup. A fatal (other 4xx) response, or an
3751/// exhausted retry budget, aborts bring-up so operators still catch typos and
3752/// dead endpoints before the node binds ports and starts serving.
3753async fn check_rpc_reachability(rpc_url: &str) -> anyhow::Result<()> {
3754 let client = reqwest::Client::builder()
3755 .timeout(Duration::from_secs(5))
3756 .build()
3757 .context("failed to build HTTP client for RPC check")?;
3758 preflight_retry(&client, rpc_url, Duration::from_secs(1)).await
3759}
3760
3761/// Retry loop behind [`check_rpc_reachability`], split out so the retry/classify
3762/// policy is testable with a small `initial_backoff` (the production caller
3763/// passes 1s). Retries a [`RpcProbe::Transient`] with doubling backoff (capped at
3764/// 8s) up to [`RPC_PREFLIGHT_MAX_ATTEMPTS`]; a [`RpcProbe::Fatal`] bails at once.
3765async fn preflight_retry(
3766 client: &reqwest::Client,
3767 rpc_url: &str,
3768 initial_backoff: Duration,
3769) -> anyhow::Result<()> {
3770 let mut backoff = initial_backoff;
3771 for attempt in 1..=RPC_PREFLIGHT_MAX_ATTEMPTS {
3772 match probe_rpc_classified(client, rpc_url).await {
3773 RpcProbe::Healthy => {
3774 tracing::info!("RPC endpoint reachable");
3775 return Ok(());
3776 }
3777 RpcProbe::Fatal(msg) => anyhow::bail!("blockchain.rpc_url {msg}"),
3778 RpcProbe::Transient(msg) if attempt == RPC_PREFLIGHT_MAX_ATTEMPTS => {
3779 anyhow::bail!(
3780 "blockchain.rpc_url {msg}; still failing after {RPC_PREFLIGHT_MAX_ATTEMPTS} attempts"
3781 );
3782 }
3783 RpcProbe::Transient(msg) => {
3784 tracing::warn!(
3785 attempt,
3786 backoff_secs = backoff.as_secs(),
3787 "RPC preflight transient failure ({msg}); retrying after backoff"
3788 );
3789 tokio::time::sleep(backoff).await;
3790 backoff = (backoff * 2).min(Duration::from_secs(8));
3791 }
3792 }
3793 }
3794 // The loop returns or bails on every path; this is unreachable but keeps the
3795 // signature total without an `unwrap`/`unreachable!` (anti-panic policy).
3796 anyhow::bail!("blockchain.rpc_url preflight exhausted retries")
3797}
3798
3799/// Issue a single `net_version` JSON-RPC probe and classify the outcome (see
3800/// [`RpcProbe`]). Shared by the startup preflight and the watchdog so their
3801/// health verdict can never diverge.
3802async fn probe_rpc_classified(client: &reqwest::Client, rpc_url: &str) -> RpcProbe {
3803 let body = serde_json::json!({
3804 "jsonrpc": "2.0",
3805 "method": "net_version",
3806 "params": [],
3807 "id": 1
3808 });
3809
3810 let resp = match client.post(rpc_url).json(&body).send().await {
3811 Ok(resp) => resp,
3812 // A timeout or connection error is transient — the endpoint may be
3813 // momentarily overloaded (the #1108 startup-burst 429 arrives here as a
3814 // reset on some gateways) rather than misconfigured.
3815 Err(err) => {
3816 return RpcProbe::Transient(format!(
3817 "is not reachable (timeout or connection error): {}",
3818 sanitize_error_sources(&err)
3819 ));
3820 }
3821 };
3822
3823 let status = resp.status();
3824 if status.is_success() {
3825 RpcProbe::Healthy
3826 } else if status.as_u16() == 429 || status.is_server_error() {
3827 RpcProbe::Transient(format!(
3828 "returned status {status} (rate-limited or server error)"
3829 ))
3830 } else {
3831 RpcProbe::Fatal(format!(
3832 "returned unexpected status {status}; verify the endpoint is a valid JSON-RPC server"
3833 ))
3834 }
3835}
3836
3837/// Issue a single `net_version` JSON-RPC probe against `rpc_url`. Returns
3838/// `Ok(())` on a healthy (2xx) response, an error otherwise — a thin adapter over
3839/// [`probe_rpc_classified`] preserving the watchdog's original pass/fail
3840/// semantics (it treats any non-2xx as unhealthy for the gauge, transient or not).
3841pub(crate) async fn probe_rpc(client: &reqwest::Client, rpc_url: &str) -> anyhow::Result<()> {
3842 match probe_rpc_classified(client, rpc_url).await {
3843 RpcProbe::Healthy => Ok(()),
3844 RpcProbe::Transient(msg) | RpcProbe::Fatal(msg) => {
3845 anyhow::bail!("blockchain.rpc_url {msg}")
3846 }
3847 }
3848}
3849
3850/// Spawn the RPC connectivity watchdog. On each tick of `interval`, calls
3851/// [`probe_rpc`] and updates `metrics.rpc_healthy(...)`. Logs a `warn`
3852/// when health transitions down and an `info` when it transitions back
3853/// up; steady-state ticks are silent. Stops when `shutdown` resolves.
3854///
3855/// The caller is responsible for seeding the gauge before this is
3856/// spawned — `run()` does so right after the successful
3857/// `check_rpc_reachability` startup probe. The watchdog only writes the
3858/// gauge on tick boundaries. The previous-state tracking starts
3859/// assuming the endpoint is healthy for the same reason, which prevents
3860/// a spurious "recovered" log on the first tick.
3861fn spawn_rpc_watchdog(
3862 client: reqwest::Client,
3863 rpc_url: String,
3864 interval: Duration,
3865 metrics: Arc<metrics::Metrics>,
3866 mut shutdown: oneshot::Receiver<()>,
3867) -> tokio::task::JoinHandle<()> {
3868 tokio::spawn(async move {
3869 // Caller seeded the gauge from the startup probe; see fn docs.
3870 let mut prev_healthy = true;
3871 loop {
3872 tokio::select! {
3873 biased;
3874 _ = &mut shutdown => {
3875 tracing::debug!("RPC watchdog shutdown signal received");
3876 return;
3877 }
3878 () = tokio::time::sleep(interval) => {}
3879 }
3880
3881 let now_healthy = match probe_rpc(&client, &rpc_url).await {
3882 Ok(()) => {
3883 if !prev_healthy {
3884 // Deliberately omit the URL: the node configures one
3885 // RPC endpoint, and it may carry basic-auth credentials
3886 // in the userinfo component.
3887 tracing::info!("RPC endpoint healthy");
3888 }
3889 true
3890 }
3891 Err(err) => {
3892 if prev_healthy {
3893 // `probe_rpc_classified` already strips the URL from the
3894 // transport error before `probe_rpc` wraps it. Strip again
3895 // here as a backstop: an `rpc_url` can hold an API key in
3896 // its path or query, not only in userinfo.
3897 tracing::warn!(error = %sanitize_err_chain(&err), "RPC endpoint unhealthy");
3898 }
3899 false
3900 }
3901 };
3902 metrics.rpc_healthy(now_healthy);
3903 prev_healthy = now_healthy;
3904 }
3905 })
3906}
3907
3908#[cfg(test)]
3909#[allow(
3910 clippy::unwrap_used,
3911 clippy::expect_used,
3912 clippy::panic,
3913 clippy::indexing_slicing
3914)]
3915mod tests;