Skip to main content

ant_core/data/client/
mod.rs

1//! Client operations for the Autonomi network.
2//!
3//! Provides high-level APIs for storing and retrieving data
4//! on the Autonomi decentralized network.
5
6pub mod adaptive;
7pub mod batch;
8pub mod cache;
9pub(crate) mod cached_merkle;
10pub(crate) mod cached_single;
11pub mod chunk;
12pub mod data;
13pub mod file;
14pub mod merkle;
15pub mod payment;
16pub(crate) mod peer_cache;
17pub mod quote;
18
19use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelStart, Outcome};
20use crate::data::client::cache::ChunkCache;
21use crate::data::error::{Error, Result};
22use crate::data::network::Network;
23use ant_protocol::evm::Wallet;
24use ant_protocol::transport::{MultiAddr, P2PNode, PeerId};
25use ant_protocol::{XorName, CLOSE_GROUP_SIZE};
26use std::path::PathBuf;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use tracing::debug;
30
31/// Classify a `data::error::Error` into a controller `Outcome`.
32///
33/// Capacity signals (Timeout / NetworkError) drive the controller
34/// down; application errors do not. The mapping is conservative:
35/// anything that COULD be transport-related is treated as a network
36/// signal, because under-classifying a real network failure as
37/// "application error" makes the controller blind to genuine stress.
38///
39/// Mapping policy:
40/// - `Timeout` -> `Timeout` (per-op deadline elapsed)
41/// - `Network`, `InsufficientPeers`, `Io` -> `NetworkError` (transport
42///   layer reported failure)
43/// - `Protocol`, `Storage` -> `NetworkError` (these wrap remote errors
44///   that frequently include peer disconnects mid-stream — under
45///   network stress these are how transport failures surface)
46/// - `PartialUpload` -> `NetworkError` (literal capacity signal: some
47///   chunks could not be stored)
48/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
49///   `Serialization`, `InvalidData`, `SignatureVerification`,
50///   `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`
51///   -> `ApplicationError` (would happen on a perfectly healthy link)
52pub(crate) fn classify_error(err: &Error) -> Outcome {
53    match err {
54        Error::Timeout(_) => Outcome::Timeout,
55        Error::Network(_)
56        | Error::InsufficientPeers(_)
57        | Error::Io(_)
58        | Error::Protocol(_)
59        | Error::Storage(_)
60        | Error::PartialUpload { .. } => Outcome::NetworkError,
61        Error::AlreadyStored
62        | Error::Encryption(_)
63        | Error::Crypto(_)
64        | Error::Payment(_)
65        | Error::Serialization(_)
66        | Error::InvalidData(_)
67        | Error::SignatureVerification(_)
68        | Error::Config(_)
69        | Error::InsufficientDiskSpace(_)
70        | Error::CostEstimationInconclusive(_)
71        | Error::BadQuoteBinding { .. } => Outcome::ApplicationError,
72    }
73}
74
75/// Default timeout for lightweight network operations (quotes, DHT lookups) in seconds.
76const DEFAULT_QUOTE_TIMEOUT_SECS: u64 = 10;
77
78/// Default timeout for the per-peer chunk GET response and any other
79/// caller that explicitly reads `store_timeout_secs`, in seconds.
80///
81/// Note despite the name: this knob does **not** govern the non-merkle
82/// chunk PUT response timeout — that path uses the
83/// `STORE_RESPONSE_TIMEOUT` constant in `chunk.rs` directly. Nor does
84/// it govern the merkle batch PUT timeout — see
85/// `DEFAULT_MERKLE_STORE_TIMEOUT_SECS`.
86///
87/// 10 s matches the pre-existing `main` default and intentionally
88/// excludes residential-upload tuning, which is Mick's PR #78
89/// territory (splitting GET into its own field).
90const DEFAULT_STORE_TIMEOUT_SECS: u64 = 10;
91
92/// Default timeout for **merkle batch** chunk store operations in seconds.
93///
94/// Separate from `DEFAULT_STORE_TIMEOUT_SECS` because merkle PUTs carry
95/// an extra storer-side cost: the payment verifier runs an iterative
96/// DHT lookup (`CLOSENESS_LOOKUP_TIMEOUT` in `ant-node`, **240 s**
97/// post-PR #89) before accepting the proof.
98///
99/// This timeout MUST be >= the storer-side `CLOSENESS_LOOKUP_TIMEOUT`
100/// plus padding for the store-response round-trip and storer-local
101/// I/O. Otherwise the client gives up while the storer is still
102/// happily verifying, the storer wastes CPU/bandwidth on a chunk the
103/// client has already discarded, and the client re-targets a
104/// different close-K member — potentially double-storing the same
105/// chunk and polluting routing.
106///
107/// 270 s = 240 s (storer lookup) + 30 s padding (network RTT + LMDB
108/// put + fsync + clock skew tolerance).
109///
110/// This invariant must be re-validated if either side's timeout
111/// changes. Empirically surfaced as "every cross-region merkle chunk
112/// times out at 10 s" on a 210-node 7-region testnet run on
113/// 2026-05-12; bumping to 270 s flipped that 0/22 -> 9/9 pass rate.
114const DEFAULT_MERKLE_STORE_TIMEOUT_SECS: u64 = 270;
115
116/// Default timeout for chunk GET response operations in seconds.
117const DEFAULT_CHUNK_GET_TIMEOUT_SECS: u64 = 10;
118
119/// Default quote concurrency: high because quoting is pure network I/O
120/// (DHT lookups + small request/response messages) with no CPU-bound work.
121const DEFAULT_QUOTE_CONCURRENCY: usize = 32;
122
123/// Default store concurrency: moderate because each chunk PUT sends ~4MB
124/// to 7 close-group peers. At 8 concurrent stores, ~225MB of outbound
125/// traffic can be in flight. Users on fast connections can increase this
126/// with --store-concurrency; users on slow connections can decrease it.
127const DEFAULT_STORE_CONCURRENCY: usize = 8;
128
129/// Configuration for the Autonomi client.
130#[derive(Debug, Clone)]
131pub struct ClientConfig {
132    /// Per-op timeout for lightweight network operations (quotes,
133    /// DHT lookups), in seconds. The adaptive controller does NOT
134    /// currently size timeouts; this remains a static knob.
135    pub quote_timeout_secs: u64,
136    /// Per-op timeout, in seconds, for the chunk GET response path
137    /// (`chunk_get_from_peer`) and any other caller that reads this
138    /// field directly.
139    ///
140    /// Note despite the historical name `store_timeout_secs`: this
141    /// knob does **not** govern the non-merkle chunk PUT response
142    /// timeout (that path uses the `STORE_RESPONSE_TIMEOUT` constant
143    /// in `chunk.rs`) and does **not** govern the merkle batch PUT
144    /// timeout (see `merkle_store_timeout_secs`). Rename pending in
145    /// Mick's PR #78 which adds a dedicated `chunk_get_timeout_secs`.
146    ///
147    /// The adaptive controller does NOT currently size timeouts;
148    /// this remains a static knob.
149    pub store_timeout_secs: u64,
150    /// Per-op timeout for **merkle batch** chunk store (PUT)
151    /// operations, in seconds. Separate from `store_timeout_secs`
152    /// because merkle PUTs incur the storer-side
153    /// `CLOSENESS_LOOKUP_TIMEOUT` (240 s post-PR #89) on top of the
154    /// usual store path; the client must wait at least that long
155    /// plus padding, or the storer wastes work on a chunk the client
156    /// has already given up on. Default 270 s.
157    pub merkle_store_timeout_secs: u64,
158    /// Per-peer response timeout for chunk GET operations, in seconds.
159    /// This is intentionally independent from `store_timeout_secs`: PUTs
160    /// and GETs have different payload direction and performance profiles.
161    pub chunk_get_timeout_secs: u64,
162    /// Number of closest peers to consider for routing.
163    pub close_group_size: usize,
164    /// **Deprecated.** Pre-adaptive ceiling for quote concurrency.
165    ///
166    /// The adaptive controller now sizes quote fan-out from observed
167    /// signals. This field, when non-zero and smaller than the
168    /// controller's per-channel default, clamps the **quote channel
169    /// only** (it does NOT bleed into store or fetch). Removed in a
170    /// future release.
171    pub quote_concurrency: usize,
172    /// **Deprecated.** Pre-adaptive ceiling for store concurrency.
173    ///
174    /// The adaptive controller now sizes store fan-out from observed
175    /// signals. This field, when non-zero and smaller than the
176    /// controller's per-channel default, clamps the **store channel
177    /// only** (it does NOT bleed into quote or fetch). Removed in a
178    /// future release.
179    pub store_concurrency: usize,
180    /// Adaptive controller configuration. Defaults are tuned to match
181    /// or exceed the prior static behavior — disabling adaptation
182    /// (`adaptive.enabled = false`) reverts to the controller's
183    /// `initial` values without re-evaluation.
184    pub adaptive: AdaptiveConfig,
185    /// Allow loopback (`127.0.0.1`) connections in the saorsa-transport
186    /// layer. Set to `true` only for devnet / local testing. Production
187    /// peers on the public Autonomi network reject the QUIC handshake
188    /// variant produced when this is `true`, so the default is `false`.
189    ///
190    /// This mirrors the `--allow-loopback` flag in `ant-cli`, which already
191    /// defaults to `false` and threads through to the same
192    /// `CoreNodeConfig::builder().local(...)` call.
193    pub allow_loopback: bool,
194    /// Bind a dual-stack IPv6 socket (`true`) or an IPv4-only socket
195    /// (`false`). Defaults to `true`, matching the CLI default.
196    ///
197    /// Set to `false` only when running on hosts without a working IPv6
198    /// stack, to avoid advertising unreachable v6 addresses to the DHT
199    /// (which causes slow connects and junk DHT address records). This
200    /// mirrors the `--ipv4-only` flag in `ant-cli`.
201    pub ipv6: bool,
202}
203
204impl Default for ClientConfig {
205    fn default() -> Self {
206        Self {
207            quote_timeout_secs: DEFAULT_QUOTE_TIMEOUT_SECS,
208            store_timeout_secs: DEFAULT_STORE_TIMEOUT_SECS,
209            merkle_store_timeout_secs: DEFAULT_MERKLE_STORE_TIMEOUT_SECS,
210            chunk_get_timeout_secs: DEFAULT_CHUNK_GET_TIMEOUT_SECS,
211            close_group_size: CLOSE_GROUP_SIZE,
212            quote_concurrency: DEFAULT_QUOTE_CONCURRENCY,
213            store_concurrency: DEFAULT_STORE_CONCURRENCY,
214            adaptive: AdaptiveConfig::default(),
215            allow_loopback: false,
216            ipv6: true,
217        }
218    }
219}
220
221/// Build the adaptive controller for a `Client`. Loads any persisted
222/// snapshot, clamps cold-start values into the deprecated-flag bounds
223/// **per channel** (so a pin on `--store-concurrency` does NOT bleed
224/// into the fetch / quote channels), and returns the persistence path
225/// so callers can save back at shutdown.
226fn build_controller(config: &ClientConfig) -> (AdaptiveController, Option<PathBuf>) {
227    let mut adaptive_cfg = config.adaptive.clone();
228
229    // Per-channel ceilings: each legacy field is interpreted as a cap
230    // for ONLY its matching channel. The fetch channel has no
231    // pre-existing legacy field; it always uses the controller's
232    // default ceiling.
233    //
234    // The legacy fields are non-zero by ClientConfig::default(), but
235    // we honor them as bounds only when they would actually CONSTRAIN
236    // the controller — i.e. when smaller than the per-channel default
237    // max. A default ClientConfig must not silently lower the
238    // controller's ceilings.
239    // A value equal to the historic legacy default is treated as
240    // "not pinned by the user" — without this, every default
241    // ClientConfig would silently lower the controller's per-channel
242    // ceilings to the prior static values (32/8) and the controller
243    // could never grow above them.
244    let user_quote_max = config.quote_concurrency;
245    let user_store_max = config.store_concurrency;
246    let quote_pinned = user_quote_max > 0 && user_quote_max != DEFAULT_QUOTE_CONCURRENCY;
247    let store_pinned = user_store_max > 0 && user_store_max != DEFAULT_STORE_CONCURRENCY;
248    if quote_pinned && user_quote_max < adaptive_cfg.max.quote {
249        adaptive_cfg.max.quote = user_quote_max;
250    }
251    if store_pinned && user_store_max < adaptive_cfg.max.store {
252        adaptive_cfg.max.store = user_store_max;
253    }
254
255    // Cold-start values: matched to the prior static defaults. If the
256    // legacy field caps the channel below the cold-start, lower the
257    // start to match — never start above the channel's max.
258    let mut start = ChannelStart::default();
259    start.quote = start.quote.min(adaptive_cfg.max.quote);
260    start.store = start.store.min(adaptive_cfg.max.store);
261    start.fetch = start.fetch.min(adaptive_cfg.max.fetch);
262
263    let adaptive_enabled = adaptive_cfg.enabled;
264    let controller = AdaptiveController::new(start, adaptive_cfg);
265    // Skip disk warm-start entirely when adaptation is disabled —
266    // fixed-concurrency mode means the user wants exactly the cold
267    // start, no surprises from prior runs. (warm_start is also a
268    // no-op when disabled, but skipping the load avoids file I/O
269    // and the path-resolution side effects.)
270    let persist_path = if adaptive_enabled {
271        let p = adaptive::default_persist_path();
272        if let Some(ref path) = p {
273            if let Some(snap) = adaptive::load_snapshot(path) {
274                debug!(path = %path.display(), "adaptive: warm-start from disk");
275                controller.warm_start(snap);
276            }
277        }
278        p
279    } else {
280        // Even with adaptation off, persist_path is computed so
281        // explicit save_adaptive_snapshot() calls still work — but
282        // the controller currently never moves, so saving the cold
283        // start is harmless.
284        adaptive::default_persist_path()
285    };
286
287    // File downloads choose a stream-decrypt batch size per download
288    // from the current fetch cap and usable RAM, then pass it into
289    // self_encryption's runtime batch-size API. The adaptive controller
290    // still drives fan-out inside each batch by re-reading
291    // `controller.fetch.current()` in the decrypt callback.
292
293    (controller, persist_path)
294}
295
296/// Client for the Autonomi decentralized network.
297///
298/// Provides high-level APIs for storing and retrieving chunks
299/// and files on the network.
300pub struct Client {
301    config: ClientConfig,
302    network: Network,
303    wallet: Option<Arc<Wallet>>,
304    evm_network: Option<ant_protocol::evm::Network>,
305    chunk_cache: ChunkCache,
306    next_request_id: AtomicU64,
307    /// Adaptive concurrency controller: replaces the static
308    /// quote/store concurrency knobs. See `adaptive` module.
309    controller: AdaptiveController,
310    /// Path the controller persists its snapshot to. `None` disables
311    /// persistence (useful for tests / non-disk environments).
312    persist_path: Option<PathBuf>,
313}
314
315impl Client {
316    /// Create a client connected to the given P2P node.
317    #[must_use]
318    pub fn from_node(node: Arc<P2PNode>, config: ClientConfig) -> Self {
319        let network = Network::from_node(node);
320        let (controller, persist_path) = build_controller(&config);
321        Self {
322            config,
323            network,
324            wallet: None,
325            evm_network: None,
326            chunk_cache: ChunkCache::default(),
327            next_request_id: AtomicU64::new(1),
328            controller,
329            persist_path,
330        }
331    }
332
333    /// Create a client connected to bootstrap peers.
334    ///
335    /// Threads `config.allow_loopback` and `config.ipv6` through to
336    /// `Network::new`, which controls the saorsa-transport `local` and
337    /// `ipv6` flags on the underlying `CoreNodeConfig`. See
338    /// `ClientConfig::allow_loopback` and `ClientConfig::ipv6` for details.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if the P2P node cannot be created or bootstrapping fails.
343    pub async fn connect(
344        bootstrap_peers: &[std::net::SocketAddr],
345        config: ClientConfig,
346    ) -> Result<Self> {
347        debug!(
348            "Connecting to Autonomi network with {} bootstrap peers (allow_loopback={}, ipv6={})",
349            bootstrap_peers.len(),
350            config.allow_loopback,
351            config.ipv6,
352        );
353        let network = Network::new(bootstrap_peers, config.allow_loopback, config.ipv6).await?;
354        let (controller, persist_path) = build_controller(&config);
355        Ok(Self {
356            config,
357            network,
358            wallet: None,
359            evm_network: None,
360            chunk_cache: ChunkCache::default(),
361            next_request_id: AtomicU64::new(1),
362            controller,
363            persist_path,
364        })
365    }
366
367    /// Set the wallet for payment operations.
368    ///
369    /// Also populates the EVM network from the wallet so that
370    /// token approvals work without a separate `with_evm_network` call.
371    #[must_use]
372    pub fn with_wallet(mut self, wallet: Wallet) -> Self {
373        self.evm_network = Some(wallet.network().clone());
374        self.wallet = Some(Arc::new(wallet));
375        self
376    }
377
378    /// Set the EVM network without requiring a wallet.
379    ///
380    /// This enables token approval and contract interactions
381    /// for external-signer flows where the private key lives outside Rust.
382    #[must_use]
383    pub fn with_evm_network(mut self, network: ant_protocol::evm::Network) -> Self {
384        self.evm_network = Some(network);
385        self
386    }
387
388    /// Get the EVM network, falling back to the wallet's network if available.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if neither `with_evm_network` nor `with_wallet` was called.
393    pub(crate) fn require_evm_network(&self) -> Result<&ant_protocol::evm::Network> {
394        if let Some(ref net) = self.evm_network {
395            return Ok(net);
396        }
397        if let Some(ref wallet) = self.wallet {
398            return Ok(wallet.network());
399        }
400        Err(Error::Payment(
401            "EVM network not configured — call with_evm_network() or with_wallet() first"
402                .to_string(),
403        ))
404    }
405
406    /// Get the client configuration.
407    #[must_use]
408    pub fn config(&self) -> &ClientConfig {
409        &self.config
410    }
411
412    /// Get a mutable reference to the client configuration.
413    pub fn config_mut(&mut self) -> &mut ClientConfig {
414        &mut self.config
415    }
416
417    /// Get a reference to the network layer.
418    #[must_use]
419    pub fn network(&self) -> &Network {
420        &self.network
421    }
422
423    /// Get the wallet, if configured.
424    #[must_use]
425    pub fn wallet(&self) -> Option<&Arc<Wallet>> {
426        self.wallet.as_ref()
427    }
428
429    /// Get a reference to the chunk cache.
430    #[must_use]
431    pub fn chunk_cache(&self) -> &ChunkCache {
432        &self.chunk_cache
433    }
434
435    /// Adaptive concurrency controller. Hot loops read
436    /// `controller().<channel>.current()` to size their fan-out and
437    /// call `.observe(...)` on each completion.
438    #[must_use]
439    pub fn controller(&self) -> &AdaptiveController {
440        &self.controller
441    }
442
443    /// Persist the current adaptive snapshot to disk so the next
444    /// `Client::connect` warm-starts at the learned values instead of
445    /// cold defaults. Best effort — failures log and are discarded.
446    /// Idempotent. Safe to call from a Drop impl or an explicit
447    /// shutdown hook.
448    pub fn save_adaptive_snapshot(&self) {
449        if let Some(ref path) = self.persist_path {
450            adaptive::save_snapshot(path, self.controller.snapshot());
451        }
452    }
453
454    /// Get the next request ID for protocol messages.
455    pub(crate) fn next_request_id(&self) -> u64 {
456        self.next_request_id.fetch_add(1, Ordering::Relaxed)
457    }
458
459    /// Return all peers in the close group for a target address.
460    ///
461    /// Queries the DHT for the closest peers by XOR distance.
462    /// Returns each peer paired with its known network addresses.
463    pub(crate) async fn close_group_peers(
464        &self,
465        target: &XorName,
466    ) -> Result<Vec<(PeerId, Vec<MultiAddr>)>> {
467        let peers = self
468            .network()
469            .find_closest_peers(target, self.config().close_group_size)
470            .await?;
471
472        if peers.is_empty() {
473            return Err(Error::InsufficientPeers(
474                "DHT returned no peers for target address".to_string(),
475            ));
476        }
477        Ok(peers)
478    }
479}
480
481/// Persist the adaptive snapshot when the `Client` is dropped, so any
482/// caller — CLI, daemon, library user, integration test — gets
483/// warm-start carry-over for free without remembering to call
484/// `save_adaptive_snapshot()` explicitly. Best effort, sync `std::fs`,
485/// no panic risk on a poisoned mutex (the inner helper handles it).
486///
487/// We deliberately write SYNCHRONOUSLY (not via `spawn_blocking`)
488/// because Drop runs during process shutdown / runtime teardown,
489/// when fire-and-forget background tasks can be dropped before they
490/// complete and the snapshot is silently lost. A small synchronous
491/// stall on a tokio worker (typically <1ms for a local-disk JSON
492/// write of ~50 bytes) is the right tradeoff for guaranteed
493/// persistence — BOUNDED by `DROP_SAVE_TIMEOUT` so a stalled
494/// network-mounted data dir cannot block process shutdown.
495const DROP_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
496
497impl Drop for Client {
498    fn drop(&mut self) {
499        let Some(path) = self.persist_path.clone() else {
500            return;
501        };
502        let snap = self.controller.snapshot();
503        adaptive::save_snapshot_with_timeout(path, snap, DROP_SAVE_TIMEOUT);
504    }
505}
506
507#[cfg(test)]
508#[allow(clippy::unwrap_used)]
509mod tests {
510    use super::*;
511
512    /// Cover EVERY variant of `data::error::Error`. Build an instance of
513    /// each, classify it, and assert the resulting `Outcome` matches the
514    /// only sensible mapping. If a future commit adds a new error variant
515    /// without updating `classify_error`, this test fails to ensure the
516    /// adaptive controller always sees correct capacity signals.
517    ///
518    /// Mapping policy (mirrors `classify_error` doc):
519    /// - `Timeout` -> `Outcome::Timeout`
520    /// - `Network`, `InsufficientPeers`, `Io`, `Protocol`, `Storage`,
521    ///   `PartialUpload` -> `Outcome::NetworkError` (transport-related
522    ///   or literal capacity failure)
523    /// - everything else -> `Outcome::ApplicationError` (would happen
524    ///   on a perfectly healthy network)
525    #[test]
526    fn classify_error_covers_all_variants() {
527        let cases: Vec<(Error, Outcome)> = vec![
528            (Error::Timeout("t".to_string()), Outcome::Timeout),
529            (Error::Network("n".to_string()), Outcome::NetworkError),
530            (
531                Error::InsufficientPeers("p".to_string()),
532                Outcome::NetworkError,
533            ),
534            (Error::Storage("s".to_string()), Outcome::NetworkError),
535            (Error::Payment("p".to_string()), Outcome::ApplicationError),
536            (Error::Protocol("p".to_string()), Outcome::NetworkError),
537            (
538                Error::InvalidData("d".to_string()),
539                Outcome::ApplicationError,
540            ),
541            (
542                Error::Serialization("s".to_string()),
543                Outcome::ApplicationError,
544            ),
545            (Error::Crypto("c".to_string()), Outcome::ApplicationError),
546            (
547                Error::Io(std::io::Error::other("io")),
548                Outcome::NetworkError,
549            ),
550            (Error::Config("c".to_string()), Outcome::ApplicationError),
551            (
552                Error::SignatureVerification("s".to_string()),
553                Outcome::ApplicationError,
554            ),
555            (
556                Error::Encryption("e".to_string()),
557                Outcome::ApplicationError,
558            ),
559            (Error::AlreadyStored, Outcome::ApplicationError),
560            (
561                Error::InsufficientDiskSpace("d".to_string()),
562                Outcome::ApplicationError,
563            ),
564            (
565                Error::CostEstimationInconclusive("c".to_string()),
566                Outcome::ApplicationError,
567            ),
568            (
569                Error::PartialUpload {
570                    stored: vec![],
571                    stored_count: 0,
572                    failed: vec![],
573                    failed_count: 0,
574                    total_chunks: 0,
575                    reason: "r".to_string(),
576                },
577                Outcome::NetworkError,
578            ),
579        ];
580        for (err, expected) in &cases {
581            let got = classify_error(err);
582            assert_eq!(
583                got, *expected,
584                "classify_error({err:?}) = {got:?}, expected {expected:?}",
585            );
586        }
587    }
588
589    /// C4 fix guard: pinning the legacy `quote_concurrency` /
590    /// `store_concurrency` ClientConfig fields must clamp ONLY the
591    /// matching channel's max in the resulting controller. The fetch
592    /// (download) channel must keep its full default ceiling.
593    #[test]
594    fn legacy_concurrency_pin_does_not_bleed_across_channels() {
595        let cfg = ClientConfig {
596            quote_concurrency: 4,
597            store_concurrency: 2,
598            ..ClientConfig::default()
599        };
600        let (controller, _) = build_controller(&cfg);
601        // The store/quote caps must be clamped to the user's pin.
602        assert_eq!(controller.config.max.quote, 4, "quote pin not respected");
603        assert_eq!(controller.config.max.store, 2, "store pin not respected");
604        // The fetch cap must NOT have been lowered — that's the
605        // regression C4 was about.
606        let default_fetch_max = adaptive::ChannelMax::default().fetch;
607        assert_eq!(
608            controller.config.max.fetch, default_fetch_max,
609            "fetch cap was lowered by store/quote pin (C4 regression)"
610        );
611        // Cold-start values must respect the lowered ceilings.
612        assert!(
613            controller.quote.current() <= 4,
614            "quote start exceeds its cap"
615        );
616        assert!(
617            controller.store.current() <= 2,
618            "store start exceeds its cap"
619        );
620    }
621
622    /// Default ClientConfig must NOT silently lower the controller's
623    /// per-channel ceilings — the adaptive defaults give every channel
624    /// real headroom to grow. This guards against future commits
625    /// re-introducing a global clamp.
626    #[test]
627    fn default_client_config_does_not_clamp_controller_max() {
628        let cfg = ClientConfig::default();
629        let (controller, _) = build_controller(&cfg);
630        let defaults = adaptive::ChannelMax::default();
631        // The legacy fields default to 32/8 (the prior static knobs),
632        // both of which are <= the per-channel adaptive defaults
633        // (128/64). build_controller must keep the larger, not clobber
634        // with the legacy values.
635        assert_eq!(controller.config.max.quote, defaults.quote);
636        assert_eq!(controller.config.max.store, defaults.store);
637        assert_eq!(controller.config.max.fetch, defaults.fetch);
638        // Compile-time-ish guard: if a new variant is added to Error,
639        // this match forces an update here.
640        let _ = |e: &Error| match e {
641            Error::Timeout(_)
642            | Error::Network(_)
643            | Error::InsufficientPeers(_)
644            | Error::Storage(_)
645            | Error::Payment(_)
646            | Error::Protocol(_)
647            | Error::InvalidData(_)
648            | Error::Serialization(_)
649            | Error::Crypto(_)
650            | Error::Io(_)
651            | Error::Config(_)
652            | Error::SignatureVerification(_)
653            | Error::Encryption(_)
654            | Error::AlreadyStored
655            | Error::InsufficientDiskSpace(_)
656            | Error::CostEstimationInconclusive(_)
657            | Error::PartialUpload { .. }
658            | Error::BadQuoteBinding { .. } => (),
659        };
660    }
661}