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