From b9f075a0d67ce03eafb843634849a38a23d0b282 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:26:13 +0200 Subject: [PATCH 1/9] fix(hub): strengthen retries for bucket metadata gateway errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub bucket tree/HEAD calls already retried 504s, but only twice with short backoff — not enough to ride out MongoDB config-server elections that stall metadata for tens of seconds. - Raise default max retries from 2 to 4 (5 total attempts) - Use longer 1s/2s/4s/8s backoff on 502/503/504 - Add --hub-max-retries and --hub-request-timeout-secs CLI flags - Reuse cached directory listings when list_tree fails transiently after poll invalidation (read-only mounts keep serving docs) Co-authored-by: Cursor --- src/error.rs | 9 ++ src/hub_api.rs | 200 +++++++++++++++++++++++++++++++++------- src/setup.rs | 20 ++++ src/virtual_fs/inode.rs | 8 ++ src/virtual_fs/mod.rs | 20 +++- 5 files changed, 225 insertions(+), 32 deletions(-) diff --git a/src/error.rs b/src/error.rs index ee6ce056..95847533 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,15 @@ impl Error { } } + /// True for transient Hub failures where retrying or serving stale metadata is reasonable. + pub fn is_retryable(&self) -> bool { + match self { + Self::Hub { status: Some(s), .. } => is_retryable_status(*s), + Self::Http(err) => err.is_timeout() || err.is_connect(), + _ => false, + } + } + /// Errno to surface to FUSE clients. Maps known Hub/CAS HTTP statuses to a /// meaningful errno so an importing app (e.g. Radarr/Sonarr) can tell a quota /// or storage reject apart from a generic I/O failure. Everything else stays EIO. diff --git a/src/hub_api.rs b/src/hub_api.rs index ee679202..71aacc95 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -203,11 +203,38 @@ pub struct CasTokenInfo { /// How often the token file is re-read from disk. const TOKEN_FILE_REFRESH: std::time::Duration = std::time::Duration::from_secs(30); +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_HEAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Default retries after the first failed attempt (5 total tries). +const DEFAULT_MAX_RETRIES: u32 = 4; + +/// Tunables for Hub HTTP resilience (retries, per-request timeouts). +#[derive(Clone, Copy, Debug)] +pub struct HubClientConfig { + /// Retry attempts after the first request fails. 4 ⇒ up to 5 tries total. + pub max_retries: u32, + /// GET/list timeout. 0 keeps the built-in default (60s). + pub request_timeout_secs: u64, + /// HEAD timeout. 0 keeps the built-in default (30s). + pub head_request_timeout_secs: u64, +} + +impl Default for HubClientConfig { + fn default() -> Self { + Self { + max_retries: DEFAULT_MAX_RETRIES, + request_timeout_secs: 0, + head_request_timeout_secs: 0, + } + } +} + pub struct HubApiClient { client: Client, /// Client that does NOT follow redirects — used for HEAD requests where we /// need response headers from the Hub (not from the CAS redirect target). head_client: Client, + max_retries: u32, endpoint: String, token: Option, /// Path to a file containing the API token. Re-read periodically so @@ -272,6 +299,21 @@ fn retry_delay(attempt: u32) -> std::time::Duration { std::time::Duration::from_millis(500 * 2u64.pow(attempt - 1)) } +/// Longer backoff for gateway/upstream timeouts (502/503/504). Hub bucket metadata +/// can stall for several seconds during MongoDB config-server elections; spacing +/// retries over ~30s gives the cluster time to recover. +fn retry_delay_gateway(attempt: u32) -> Duration { + debug_assert!(attempt > 0, "retry_delay_gateway called with attempt=0"); + Duration::from_millis(1000 * 2u64.pow(attempt.saturating_sub(1).min(3))) +} + +fn retry_delay_for_status(status: Option, attempt: u32) -> Duration { + match status { + Some(502 | 503 | 504) => retry_delay_gateway(attempt), + _ => retry_delay(attempt), + } +} + /// Parse the IETF `RateLimit` header for `t=` (time until window reset), capped at 30s. /// Format: `"resource_type";r=;t=` /// This is what moon-landing sends on 429 responses. @@ -335,15 +377,15 @@ async fn probe_repo( } /// Send an HTTP request with automatic retry on transient errors (408, 429, 5xx, timeouts). -/// Uses the IETF RateLimit header's t= parameter when present, falls back to exponential backoff (2 retries max). +/// Uses the IETF RateLimit header's t= parameter when present, falls back to exponential backoff. /// Set `accept_redirects` to treat 3xx as success (needed for HEAD on /resolve/ endpoints /// where the redirect response itself carries metadata headers). async fn send_with_retry( build_request: impl Fn() -> reqwest::RequestBuilder, context: &str, accept_redirects: bool, + max_retries: u32, ) -> Result { - const MAX_RETRIES: u32 = 2; let mut attempt = 0; loop { attempt += 1; @@ -353,18 +395,19 @@ async fn send_with_retry( } Ok(resp) => { let status = resp.status().as_u16(); - if is_retryable_status(status) && attempt <= MAX_RETRIES { - let delay = parse_retry_delay(resp.headers()).unwrap_or_else(|| retry_delay(attempt)); - warn!("{context}: transient error ({status}), retry {attempt}/{MAX_RETRIES} in {delay:?}"); + if is_retryable_status(status) && attempt <= max_retries { + let delay = parse_retry_delay(resp.headers()) + .unwrap_or_else(|| retry_delay_for_status(Some(status), attempt)); + warn!("{context}: transient error ({status}), retry {attempt}/{max_retries} in {delay:?}"); tokio::time::sleep(delay).await; continue; } let body = resp.text().await.unwrap_or_default(); return Err(Error::hub_status(status, format!("{context}: {status} {body}"))); } - Err(err) if (err.is_timeout() || err.is_connect()) && attempt <= MAX_RETRIES => { + Err(err) if (err.is_timeout() || err.is_connect()) && attempt <= max_retries => { let delay = retry_delay(attempt); - warn!("{context}: transient error, retry {attempt}/{MAX_RETRIES} in {delay:?}: {err}"); + warn!("{context}: transient error, retry {attempt}/{max_retries} in {delay:?}: {err}"); tokio::time::sleep(delay).await; } Err(err) => return Err(Error::Http(err)), @@ -372,8 +415,18 @@ async fn send_with_retry( } } -fn make_clients(backend: &str) -> (Client, Client) { +fn make_clients(backend: &str, config: HubClientConfig) -> (Client, Client) { let user_agent = format!("hf-mount/{}; fs/{}", env!("CARGO_PKG_VERSION"), backend); + let request_timeout = if config.request_timeout_secs > 0 { + Duration::from_secs(config.request_timeout_secs) + } else { + DEFAULT_REQUEST_TIMEOUT + }; + let head_request_timeout = if config.head_request_timeout_secs > 0 { + Duration::from_secs(config.head_request_timeout_secs) + } else { + DEFAULT_HEAD_REQUEST_TIMEOUT + }; // Idle pool / keep-alive shared across both clients so a hung Hub doesn't // freeze the poll loop and TLS handshakes are amortized across rounds. let base = || { @@ -384,12 +437,12 @@ fn make_clients(backend: &str) -> (Client, Client) { .connect_timeout(Duration::from_secs(10)) }; let client = base() - .timeout(Duration::from_secs(60)) + .timeout(request_timeout) .build() .expect("failed to build client"); let head_client = base() .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) + .timeout(head_request_timeout) .build() .expect("failed to build head_client"); (client, head_client) @@ -406,8 +459,10 @@ impl HubApiClient { source: SourceKind, path_prefix: String, backend: &str, + config: HubClientConfig, ) -> Result> { - let (client, head_client) = make_clients(backend); + let max_retries = config.max_retries; + let (client, head_client) = make_clients(backend, config); let endpoint = endpoint.trim_end_matches('/').to_string(); let (source, last_modified) = match source { @@ -418,8 +473,13 @@ impl HubApiClient { } => { let url = format!("{}/api/{}/{}", endpoint, repo_type.api_prefix(), repo_id); let context = format!("resolve repo {repo_id}"); - let resp = - send_with_retry(|| init_auth_get(&client, &url, token, &token_file), &context, false).await?; + let resp = send_with_retry( + || init_auth_get(&client, &url, token, &token_file), + &context, + false, + max_retries, + ) + .await?; let body: serde_json::Value = resp.json().await?; let resolved_id = body["id"] .as_str() @@ -440,8 +500,13 @@ impl HubApiClient { SourceKind::Bucket { bucket_id } => { let url = format!("{}/api/buckets/{}", endpoint, bucket_id); let context = format!("resolve bucket {bucket_id}"); - let resp = match send_with_retry(|| init_auth_get(&client, &url, token, &token_file), &context, false) - .await + let resp = match send_with_retry( + || init_auth_get(&client, &url, token, &token_file), + &context, + false, + max_retries, + ) + .await { Ok(r) => r, Err(err) => { @@ -466,6 +531,7 @@ impl HubApiClient { Ok(Arc::new(Self { client, head_client, + max_retries, endpoint, token: token.map(|t| t.to_string()), token_file, @@ -478,10 +544,22 @@ impl HubApiClient { /// Create a client for a HuggingFace bucket. pub fn new(endpoint: &str, token: Option<&str>, bucket_id: &str, backend: &str) -> Arc { - let (client, head_client) = make_clients(backend); + Self::new_with_config(endpoint, token, bucket_id, backend, HubClientConfig::default()) + } + + pub fn new_with_config( + endpoint: &str, + token: Option<&str>, + bucket_id: &str, + backend: &str, + config: HubClientConfig, + ) -> Arc { + let max_retries = config.max_retries; + let (client, head_client) = make_clients(backend, config); Arc::new(Self { client, head_client, + max_retries, endpoint: endpoint.trim_end_matches('/').to_string(), token: token.map(|t| t.to_string()), token_file: None, @@ -605,7 +683,13 @@ impl HubApiClient { format!("{}/api/buckets/{}", self.endpoint, bucket_id) } }; - let resp = send_with_retry(|| self.auth(self.client.get(&url)), "revision probe", false).await?; + let resp = send_with_retry( + || self.auth(self.client.get(&url)), + "revision probe", + false, + self.max_retries, + ) + .await?; let probe: RevisionProbe = resp.json().await?; match &self.source { SourceKind::Repo { .. } => probe @@ -661,7 +745,13 @@ impl HubApiClient { }; loop { - let resp = send_with_retry(|| self.auth(self.client.get(&url)), "tree listing", false).await?; + let resp = send_with_retry( + || self.auth(self.client.get(&url)), + "tree listing", + false, + self.max_retries, + ) + .await?; let next_url = resp .headers() @@ -710,7 +800,13 @@ impl HubApiClient { }; loop { - let resp = send_with_retry(|| self.auth(self.client.get(&url)), "repo tree listing", false).await?; + let resp = send_with_retry( + || self.auth(self.client.get(&url)), + "repo tree listing", + false, + self.max_retries, + ) + .await?; let next_url = resp .headers() @@ -772,7 +868,13 @@ impl HubApiClient { ) } }; - let resp = send_with_retry(|| self.auth(self.head_client.head(&url)), "head_file", true).await; + let resp = send_with_retry( + || self.auth(self.head_client.head(&url)), + "head_file", + true, + self.max_retries, + ) + .await; let resp = match resp { Ok(r) => r, Err(Error::Hub { status: Some(404), .. }) => return Ok(None), @@ -831,7 +933,13 @@ impl HubApiClient { } }; - let resp = send_with_retry(|| self.auth(self.client.get(&url)), "CAS token request", false).await?; + let resp = send_with_retry( + || self.auth(self.client.get(&url)), + "CAS token request", + false, + self.max_retries, + ) + .await?; let info: CasTokenInfo = resp.json().await?; Ok(info) } @@ -846,7 +954,13 @@ impl HubApiClient { }; let url = format!("{}/api/buckets/{}/xet-write-token", self.endpoint, bucket_id); - let resp = send_with_retry(|| self.auth(self.client.get(&url)), "CAS write token request", false).await?; + let resp = send_with_retry( + || self.auth(self.client.get(&url)), + "CAS write token request", + false, + self.max_retries, + ) + .await?; let info: CasTokenInfo = resp.json().await?; Ok(info) } @@ -897,6 +1011,7 @@ impl HubApiClient { }, "batch operation", false, + self.max_retries, ) .await?; @@ -952,6 +1067,7 @@ impl HubApiClient { }, "HTTP download", false, + self.max_retries, ) .await; let resp = match resp { @@ -1292,10 +1408,12 @@ mod tests { // ── prefixed_path / strip_path_prefix tests ─────────────────────── fn make_test_client(prefix: &str, token_file: Option) -> HubApiClient { - let (client, head_client) = make_clients("test"); + let config = HubClientConfig::default(); + let (client, head_client) = make_clients("test", config); HubApiClient { client, head_client, + max_retries: config.max_retries, endpoint: "https://huggingface.co".to_string(), token: Some("static-token".to_string()), token_file, @@ -1430,6 +1548,26 @@ mod tests { assert_eq!(retry_delay(3), std::time::Duration::from_millis(2000)); } + #[test] + fn retry_delay_gateway_backoff() { + assert_eq!(retry_delay_gateway(1), std::time::Duration::from_millis(1000)); + assert_eq!(retry_delay_gateway(2), std::time::Duration::from_millis(2000)); + assert_eq!(retry_delay_gateway(3), std::time::Duration::from_millis(4000)); + assert_eq!(retry_delay_gateway(4), std::time::Duration::from_millis(8000)); + } + + #[test] + fn retry_delay_for_status_uses_gateway_backoff_on_504() { + assert_eq!( + retry_delay_for_status(Some(504), 2), + std::time::Duration::from_millis(2000) + ); + assert_eq!( + retry_delay_for_status(Some(429), 2), + std::time::Duration::from_millis(1000) + ); + } + #[test] fn is_retryable_status_covers_expected_codes() { use crate::error::is_retryable_status; @@ -1529,7 +1667,7 @@ mod tests { async fn send_with_retry_success_on_first_try() { let url = mock_server(vec![200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false).await.unwrap(); + let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -1537,7 +1675,7 @@ mod tests { async fn send_with_retry_retries_on_503_then_succeeds() { let url = mock_server(vec![503, 200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false).await.unwrap(); + let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -1545,7 +1683,7 @@ mod tests { async fn send_with_retry_retries_on_429_then_succeeds() { let url = mock_server(vec![429, 200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false).await.unwrap(); + let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); assert_eq!(resp.status(), 200); } @@ -1553,7 +1691,7 @@ mod tests { async fn send_with_retry_gives_up_after_max_retries() { let url = mock_server(vec![503, 503, 503]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false).await; + let result = send_with_retry(|| client.get(&url), "test", false, 2).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, Error::Hub { status: Some(503), .. })); @@ -1563,7 +1701,7 @@ mod tests { async fn send_with_retry_no_retry_on_404() { let url = mock_server(vec![404]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false).await; + let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(404), .. })); } @@ -1572,7 +1710,7 @@ mod tests { async fn send_with_retry_304_returned_as_error() { let url = mock_server(vec![304]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false).await; + let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(304), .. })); } @@ -1584,7 +1722,7 @@ mod tests { .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); - let resp = send_with_retry(|| client.get(&url), "test", true).await.unwrap(); + let resp = send_with_retry(|| client.get(&url), "test", true, DEFAULT_MAX_RETRIES).await.unwrap(); assert_eq!(resp.status(), 302); } @@ -1595,7 +1733,7 @@ mod tests { .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); - let result = send_with_retry(|| client.get(&url), "test", false).await; + let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(302), .. })); } diff --git a/src/setup.rs b/src/setup.rs index 72170757..d7570a2e 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -108,6 +108,20 @@ pub struct MountOptions { #[arg(long, default_value_t = 30)] pub poll_interval_secs: u64, + /// Max Hub HTTP retries after the first failed attempt (408/429/5xx/timeouts). + /// 4 ⇒ up to 5 tries with longer backoff on 502/503/504 gateway errors. + #[arg(long, default_value_t = 4)] + pub hub_max_retries: u32, + + /// Per-request Hub GET/list timeout in seconds. 0 keeps the built-in default (60s). + /// Raise above the Hub API's Mongo deadline when bucket metadata queries are slow. + #[arg(long, default_value_t = 0)] + pub hub_request_timeout_secs: u64, + + /// Per-request Hub HEAD timeout in seconds. 0 keeps the built-in default (30s). + #[arg(long, default_value_t = 0)] + pub hub_head_request_timeout_secs: u64, + /// Maximum number of concurrent tree-listing requests per poll round. /// Each loaded directory prefix issues one Hub API request; this cap /// prevents thundering-herd bursts on large mounts (e.g. transformers/docs) @@ -374,6 +388,11 @@ pub fn build_with_runtime( }; let backend = if is_nfs { "nfs" } else { "fuse" }; + let hub_config = crate::hub_api::HubClientConfig { + max_retries: options.hub_max_retries, + request_timeout_secs: options.hub_request_timeout_secs, + head_request_timeout_secs: options.hub_head_request_timeout_secs, + }; let hub_client = runtime.block_on(async { HubApiClient::from_source( &options.hub_endpoint, @@ -382,6 +401,7 @@ pub fn build_with_runtime( source_kind, path_prefix, backend, + hub_config, ) .await .unwrap_or_else(|e| panic!("Failed to initialize Hub client: {e}")) diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index e6238ff8..d783240c 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -732,6 +732,14 @@ impl InodeTable { self.inodes.get(&ino).is_some_and(|e| e.children_loaded()) } + /// True when a remote-backed directory still has child entries cached locally + /// (e.g. after poll invalidation cleared `children_loaded_at` but kept inodes). + pub fn has_cached_remote_children(&self, ino: u64) -> bool { + self.inodes.get(&ino).is_some_and(|e| { + e.kind == InodeKind::Directory && e.children_from_remote && !e.children.is_empty() + }) + } + /// True if the inode or any descendant is either dirty or has an open /// FUSE file handle — i.e. evicting the subtree would drop local state. pub fn has_dirty_or_open_descendants(&self, ino: u64) -> bool { diff --git a/src/virtual_fs/mod.rs b/src/virtual_fs/mod.rs index f9b00476..839e8531 100644 --- a/src/virtual_fs/mod.rs +++ b/src/virtual_fs/mod.rs @@ -806,8 +806,26 @@ impl VirtualFs { let entries = match self.hub_client.list_tree(&prefix).await { Ok(entries) => entries, + Err(e) if e.is_retryable() => { + let has_stale = { + let inodes = self.inode_table.read().expect("inodes poisoned"); + inodes.has_cached_remote_children(parent_ino) + }; + if has_stale { + warn!( + "list_tree({prefix}) failed ({e}); reusing cached directory listing from before invalidation" + ); + let mut inodes = self.inode_table.write().expect("inodes poisoned"); + if let Some(entry) = inodes.get_mut(parent_ino) { + entry.children_loaded_at = Some(Instant::now()); + } + return Ok(()); + } + error!("Failed to list tree for prefix '{prefix}': {e}"); + return Err(libc::EIO); + } Err(e) => { - error!("Failed to list tree for prefix '{}': {}", prefix, e); + error!("Failed to list tree for prefix '{prefix}': {e}"); return Err(libc::EIO); } }; From 4b857fb00e7c7d420e88ac4fa3f30c4f4d7a681b Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:33:34 +0200 Subject: [PATCH 2/9] Revise Hub retry strategy: deadline, jitter, stale cache fallback. Revert aggressive 5-try/15s gateway backoff in favor of fail-fast within a 5s operation budget, full jitter on retries, and a short-lived circuit breaker after consecutive 502/503/504. Extend stale metadata fallback to HEAD lookups and mark stale listings without faking fresh children_loaded_at. Co-authored-by: Cursor --- src/hub_api.rs | 378 +++++++++++++++++++++++++++++----------- src/setup.rs | 13 +- src/virtual_fs/inode.rs | 63 +++++++ src/virtual_fs/mod.rs | 33 +++- 4 files changed, 373 insertions(+), 114 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 71aacc95..62e24feb 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -205,18 +206,25 @@ const TOKEN_FILE_REFRESH: std::time::Duration = std::time::Duration::from_secs(3 const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_HEAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -/// Default retries after the first failed attempt (5 total tries). -const DEFAULT_MAX_RETRIES: u32 = 4; +/// Default retries after the first failed attempt (3 total tries). +const DEFAULT_MAX_RETRIES: u32 = 2; +/// Wall-clock budget for a single Hub HTTP operation (all attempts + backoff). +const DEFAULT_OPERATION_DEADLINE: Duration = Duration::from_secs(5); +/// Open the circuit after this many consecutive gateway failures (502/503/504). +const CIRCUIT_FAILURE_THRESHOLD: u32 = 3; +const CIRCUIT_OPEN_DURATION: Duration = Duration::from_secs(10); /// Tunables for Hub HTTP resilience (retries, per-request timeouts). #[derive(Clone, Copy, Debug)] pub struct HubClientConfig { - /// Retry attempts after the first request fails. 4 ⇒ up to 5 tries total. + /// Retry attempts after the first request fails. 2 ⇒ up to 3 tries total. pub max_retries: u32, /// GET/list timeout. 0 keeps the built-in default (60s). pub request_timeout_secs: u64, /// HEAD timeout. 0 keeps the built-in default (30s). pub head_request_timeout_secs: u64, + /// Total wall-clock budget for one Hub call including retries/backoff. 0 ⇒ 5s. + pub operation_deadline_ms: u64, } impl Default for HubClientConfig { @@ -225,6 +233,37 @@ impl Default for HubClientConfig { max_retries: DEFAULT_MAX_RETRIES, request_timeout_secs: 0, head_request_timeout_secs: 0, + operation_deadline_ms: DEFAULT_OPERATION_DEADLINE.as_millis() as u64, + } + } +} + +/// Tracks consecutive gateway failures and briefly skips retries when the Hub is degraded. +#[derive(Debug, Default)] +struct HubRetryState { + consecutive_gateway_failures: AtomicU32, + circuit_open_until: Mutex>, +} + +impl HubRetryState { + fn is_circuit_open(&self) -> bool { + let guard = self.circuit_open_until.lock().expect("circuit lock poisoned"); + guard.is_some_and(|until| Instant::now() < until) + } + + fn record_success(&self) { + self.consecutive_gateway_failures.store(0, Ordering::Relaxed); + *self.circuit_open_until.lock().expect("circuit lock poisoned") = None; + } + + fn record_gateway_failure(&self) { + let failures = self.consecutive_gateway_failures.fetch_add(1, Ordering::Relaxed) + 1; + if failures >= CIRCUIT_FAILURE_THRESHOLD { + *self.circuit_open_until.lock().expect("circuit lock poisoned") = + Some(Instant::now() + CIRCUIT_OPEN_DURATION); + warn!( + "Hub gateway circuit open for {CIRCUIT_OPEN_DURATION:?} after {failures} consecutive 502/503/504 responses" + ); } } } @@ -235,6 +274,8 @@ pub struct HubApiClient { /// need response headers from the Hub (not from the CAS redirect target). head_client: Client, max_retries: u32, + operation_deadline: Duration, + retry_state: Arc, endpoint: String, token: Option, /// Path to a file containing the API token. Re-read periodically so @@ -294,24 +335,28 @@ pub fn split_path_prefix(raw: &str) -> std::result::Result<(&str, &str), &'stati } } -fn retry_delay(attempt: u32) -> std::time::Duration { +fn retry_delay(attempt: u32) -> Duration { debug_assert!(attempt > 0, "retry_delay called with attempt=0"); - std::time::Duration::from_millis(500 * 2u64.pow(attempt - 1)) + Duration::from_millis(500 * 2u64.pow(attempt - 1)) } -/// Longer backoff for gateway/upstream timeouts (502/503/504). Hub bucket metadata -/// can stall for several seconds during MongoDB config-server elections; spacing -/// retries over ~30s gives the cluster time to recover. -fn retry_delay_gateway(attempt: u32) -> Duration { - debug_assert!(attempt > 0, "retry_delay_gateway called with attempt=0"); - Duration::from_millis(1000 * 2u64.pow(attempt.saturating_sub(1).min(3))) +/// Full jitter: sleep uniformly in [0, base]. Spreads retry waves when many +/// mounts hit the same transient Hub failure. +fn jittered_delay(base: Duration, salt: u32) -> Duration { + let base_ms = base.as_millis().min(u128::from(u64::MAX)) as u64; + if base_ms == 0 { + return base; + } + let jitter_seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + .wrapping_add(salt); + Duration::from_millis(jitter_seed as u64 % (base_ms + 1)) } -fn retry_delay_for_status(status: Option, attempt: u32) -> Duration { - match status { - Some(502 | 503 | 504) => retry_delay_gateway(attempt), - _ => retry_delay(attempt), - } +fn is_gateway_status(status: u16) -> bool { + matches!(status, 502 | 503 | 504) } /// Parse the IETF `RateLimit` header for `t=` (time until window reset), capped at 30s. @@ -377,7 +422,8 @@ async fn probe_repo( } /// Send an HTTP request with automatic retry on transient errors (408, 429, 5xx, timeouts). -/// Uses the IETF RateLimit header's t= parameter when present, falls back to exponential backoff. +/// Uses the IETF RateLimit header's t= parameter when present, falls back to exponential +/// backoff with full jitter. Stops when `operation_deadline` is exhausted even if retries remain. /// Set `accept_redirects` to treat 3xx as success (needed for HEAD on /resolve/ endpoints /// where the redirect response itself carries metadata headers). async fn send_with_retry( @@ -385,29 +431,67 @@ async fn send_with_retry( context: &str, accept_redirects: bool, max_retries: u32, + operation_deadline: Duration, + retry_state: Option<&HubRetryState>, ) -> Result { + let started = Instant::now(); + let effective_max_retries = if retry_state.is_some_and(HubRetryState::is_circuit_open) { + 0 + } else { + max_retries + }; let mut attempt = 0; loop { attempt += 1; match build_request().send().await { Ok(resp) if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => { + if let Some(state) = retry_state { + state.record_success(); + } return Ok(resp); } Ok(resp) => { let status = resp.status().as_u16(); - if is_retryable_status(status) && attempt <= max_retries { - let delay = parse_retry_delay(resp.headers()) - .unwrap_or_else(|| retry_delay_for_status(Some(status), attempt)); - warn!("{context}: transient error ({status}), retry {attempt}/{max_retries} in {delay:?}"); + if is_retryable_status(status) && attempt <= effective_max_retries { + let base_delay = parse_retry_delay(resp.headers()).unwrap_or_else(|| retry_delay(attempt)); + let delay = jittered_delay(base_delay, attempt); + if started.elapsed() + delay >= operation_deadline { + let body = resp.text().await.unwrap_or_default(); + if is_gateway_status(status) + && let Some(state) = retry_state + { + state.record_gateway_failure(); + } + warn!( + "{context}: operation deadline {operation_deadline:?} exceeded after attempt {attempt}" + ); + return Err(Error::hub_status(status, format!("{context}: {status} {body}"))); + } + warn!( + "{context}: transient error ({status}), retry {attempt}/{effective_max_retries} in {delay:?}" + ); tokio::time::sleep(delay).await; continue; } + if is_gateway_status(status) + && let Some(state) = retry_state + { + state.record_gateway_failure(); + } let body = resp.text().await.unwrap_or_default(); return Err(Error::hub_status(status, format!("{context}: {status} {body}"))); } - Err(err) if (err.is_timeout() || err.is_connect()) && attempt <= max_retries => { - let delay = retry_delay(attempt); - warn!("{context}: transient error, retry {attempt}/{max_retries} in {delay:?}: {err}"); + Err(err) if (err.is_timeout() || err.is_connect()) && attempt <= effective_max_retries => { + let delay = jittered_delay(retry_delay(attempt), attempt); + if started.elapsed() + delay >= operation_deadline { + warn!( + "{context}: operation deadline {operation_deadline:?} exceeded after attempt {attempt}: {err}" + ); + return Err(Error::Http(err)); + } + warn!( + "{context}: transient error, retry {attempt}/{effective_max_retries} in {delay:?}: {err}" + ); tokio::time::sleep(delay).await; } Err(err) => return Err(Error::Http(err)), @@ -462,6 +546,12 @@ impl HubApiClient { config: HubClientConfig, ) -> Result> { let max_retries = config.max_retries; + let operation_deadline = if config.operation_deadline_ms > 0 { + Duration::from_millis(config.operation_deadline_ms) + } else { + DEFAULT_OPERATION_DEADLINE + }; + let retry_state = Arc::new(HubRetryState::default()); let (client, head_client) = make_clients(backend, config); let endpoint = endpoint.trim_end_matches('/').to_string(); @@ -478,6 +568,8 @@ impl HubApiClient { &context, false, max_retries, + operation_deadline, + Some(&retry_state), ) .await?; let body: serde_json::Value = resp.json().await?; @@ -505,6 +597,8 @@ impl HubApiClient { &context, false, max_retries, + operation_deadline, + Some(&retry_state), ) .await { @@ -532,6 +626,8 @@ impl HubApiClient { client, head_client, max_retries, + operation_deadline, + retry_state, endpoint, token: token.map(|t| t.to_string()), token_file, @@ -555,11 +651,18 @@ impl HubApiClient { config: HubClientConfig, ) -> Arc { let max_retries = config.max_retries; + let operation_deadline = if config.operation_deadline_ms > 0 { + Duration::from_millis(config.operation_deadline_ms) + } else { + DEFAULT_OPERATION_DEADLINE + }; let (client, head_client) = make_clients(backend, config); Arc::new(Self { client, head_client, max_retries, + operation_deadline, + retry_state: Arc::new(HubRetryState::default()), endpoint: endpoint.trim_end_matches('/').to_string(), token: token.map(|t| t.to_string()), token_file: None, @@ -572,6 +675,23 @@ impl HubApiClient { }) } + async fn send_hub_request( + &self, + build_request: impl Fn() -> reqwest::RequestBuilder, + context: &str, + accept_redirects: bool, + ) -> Result { + send_with_retry( + build_request, + context, + accept_redirects, + self.max_retries, + self.operation_deadline, + Some(&self.retry_state), + ) + .await + } + /// Attach bearer auth to a request if a token is configured. fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if let Some(path) = &self.token_file { @@ -683,13 +803,9 @@ impl HubApiClient { format!("{}/api/buckets/{}", self.endpoint, bucket_id) } }; - let resp = send_with_retry( - || self.auth(self.client.get(&url)), - "revision probe", - false, - self.max_retries, - ) - .await?; + let resp = self + .send_hub_request(|| self.auth(self.client.get(&url)), "revision probe", false) + .await?; let probe: RevisionProbe = resp.json().await?; match &self.source { SourceKind::Repo { .. } => probe @@ -745,13 +861,9 @@ impl HubApiClient { }; loop { - let resp = send_with_retry( - || self.auth(self.client.get(&url)), - "tree listing", - false, - self.max_retries, - ) - .await?; + let resp = self + .send_hub_request(|| self.auth(self.client.get(&url)), "tree listing", false) + .await?; let next_url = resp .headers() @@ -800,13 +912,9 @@ impl HubApiClient { }; loop { - let resp = send_with_retry( - || self.auth(self.client.get(&url)), - "repo tree listing", - false, - self.max_retries, - ) - .await?; + let resp = self + .send_hub_request(|| self.auth(self.client.get(&url)), "repo tree listing", false) + .await?; let next_url = resp .headers() @@ -868,13 +976,9 @@ impl HubApiClient { ) } }; - let resp = send_with_retry( - || self.auth(self.head_client.head(&url)), - "head_file", - true, - self.max_retries, - ) - .await; + let resp = self + .send_hub_request(|| self.auth(self.head_client.head(&url)), "head_file", true) + .await; let resp = match resp { Ok(r) => r, Err(Error::Hub { status: Some(404), .. }) => return Ok(None), @@ -933,13 +1037,9 @@ impl HubApiClient { } }; - let resp = send_with_retry( - || self.auth(self.client.get(&url)), - "CAS token request", - false, - self.max_retries, - ) - .await?; + let resp = self + .send_hub_request(|| self.auth(self.client.get(&url)), "CAS token request", false) + .await?; let info: CasTokenInfo = resp.json().await?; Ok(info) } @@ -954,13 +1054,9 @@ impl HubApiClient { }; let url = format!("{}/api/buckets/{}/xet-write-token", self.endpoint, bucket_id); - let resp = send_with_retry( - || self.auth(self.client.get(&url)), - "CAS write token request", - false, - self.max_retries, - ) - .await?; + let resp = self + .send_hub_request(|| self.auth(self.client.get(&url)), "CAS write token request", false) + .await?; let info: CasTokenInfo = resp.json().await?; Ok(info) } @@ -1003,7 +1099,7 @@ impl HubApiClient { } let body = bytes::Bytes::from(body); - send_with_retry( + self.send_hub_request( || { self.auth(self.client.post(&url)) .header("content-type", "application/x-ndjson") @@ -1011,7 +1107,6 @@ impl HubApiClient { }, "batch operation", false, - self.max_retries, ) .await?; @@ -1057,19 +1152,19 @@ impl HubApiClient { }; info!("HTTP download: {} → {:?}", path, dest); - let resp = send_with_retry( - || { - let mut r = self.auth(self.client.get(&url)); - if let Some(ref etag) = cached_etag { - r = r.header("If-None-Match", format!("\"{}\"", etag.trim())); - } - r - }, - "HTTP download", - false, - self.max_retries, - ) - .await; + let resp = self + .send_hub_request( + || { + let mut r = self.auth(self.client.get(&url)); + if let Some(ref etag) = cached_etag { + r = r.header("If-None-Match", format!("\"{}\"", etag.trim())); + } + r + }, + "HTTP download", + false, + ) + .await; let resp = match resp { Ok(r) => r, Err(Error::Hub { status: Some(304), .. }) => { @@ -1414,6 +1509,8 @@ mod tests { client, head_client, max_retries: config.max_retries, + operation_deadline: DEFAULT_OPERATION_DEADLINE, + retry_state: Arc::new(HubRetryState::default()), endpoint: "https://huggingface.co".to_string(), token: Some("static-token".to_string()), token_file, @@ -1549,23 +1646,20 @@ mod tests { } #[test] - fn retry_delay_gateway_backoff() { - assert_eq!(retry_delay_gateway(1), std::time::Duration::from_millis(1000)); - assert_eq!(retry_delay_gateway(2), std::time::Duration::from_millis(2000)); - assert_eq!(retry_delay_gateway(3), std::time::Duration::from_millis(4000)); - assert_eq!(retry_delay_gateway(4), std::time::Duration::from_millis(8000)); + fn jittered_delay_within_bounds() { + let base = Duration::from_millis(1000); + for salt in 0..100 { + let delay = jittered_delay(base, salt); + assert!(delay <= base); + } } #[test] - fn retry_delay_for_status_uses_gateway_backoff_on_504() { - assert_eq!( - retry_delay_for_status(Some(504), 2), - std::time::Duration::from_millis(2000) - ); - assert_eq!( - retry_delay_for_status(Some(429), 2), - std::time::Duration::from_millis(1000) - ); + fn is_gateway_status_matches_gateway_codes() { + assert!(is_gateway_status(502)); + assert!(is_gateway_status(503)); + assert!(is_gateway_status(504)); + assert!(!is_gateway_status(429)); } #[test] @@ -1628,6 +1722,23 @@ mod tests { // ── send_with_retry integration tests ───────────────────────────── + async fn test_send_with_retry( + build_request: impl Fn() -> reqwest::RequestBuilder, + context: &str, + accept_redirects: bool, + max_retries: u32, + ) -> Result { + send_with_retry( + build_request, + context, + accept_redirects, + max_retries, + DEFAULT_OPERATION_DEADLINE, + None, + ) + .await + } + /// Minimal HTTP server that responds with a given sequence of status codes. async fn mock_server(responses: Vec) -> String { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1667,7 +1778,9 @@ mod tests { async fn send_with_retry_success_on_first_try() { let url = mock_server(vec![200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); + let resp = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES) + .await + .unwrap(); assert_eq!(resp.status(), 200); } @@ -1675,7 +1788,9 @@ mod tests { async fn send_with_retry_retries_on_503_then_succeeds() { let url = mock_server(vec![503, 200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); + let resp = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES) + .await + .unwrap(); assert_eq!(resp.status(), 200); } @@ -1683,7 +1798,9 @@ mod tests { async fn send_with_retry_retries_on_429_then_succeeds() { let url = mock_server(vec![429, 200]).await; let client = Client::new(); - let resp = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await.unwrap(); + let resp = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES) + .await + .unwrap(); assert_eq!(resp.status(), 200); } @@ -1691,7 +1808,7 @@ mod tests { async fn send_with_retry_gives_up_after_max_retries() { let url = mock_server(vec![503, 503, 503]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false, 2).await; + let result = test_send_with_retry(|| client.get(&url), "test", false, 2).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, Error::Hub { status: Some(503), .. })); @@ -1701,7 +1818,7 @@ mod tests { async fn send_with_retry_no_retry_on_404() { let url = mock_server(vec![404]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; + let result = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(404), .. })); } @@ -1710,7 +1827,7 @@ mod tests { async fn send_with_retry_304_returned_as_error() { let url = mock_server(vec![304]).await; let client = Client::new(); - let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; + let result = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(304), .. })); } @@ -1722,7 +1839,9 @@ mod tests { .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); - let resp = send_with_retry(|| client.get(&url), "test", true, DEFAULT_MAX_RETRIES).await.unwrap(); + let resp = test_send_with_retry(|| client.get(&url), "test", true, DEFAULT_MAX_RETRIES) + .await + .unwrap(); assert_eq!(resp.status(), 302); } @@ -1733,11 +1852,60 @@ mod tests { .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); - let result = send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; + let result = test_send_with_retry(|| client.get(&url), "test", false, DEFAULT_MAX_RETRIES).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(302), .. })); } + #[tokio::test] + async fn send_with_retry_respects_operation_deadline() { + let url = mock_server(vec![503, 503, 503, 503, 503]).await; + let client = Client::new(); + let started = Instant::now(); + let result = send_with_retry( + || client.get(&url), + "test", + false, + 10, + Duration::from_millis(250), + None, + ) + .await; + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[tokio::test] + async fn send_with_retry_circuit_skips_retries_when_open() { + let state = HubRetryState::default(); + let url = mock_server(vec![504; 9]).await; + let client = Client::new(); + for _ in 0..CIRCUIT_FAILURE_THRESHOLD { + let _ = send_with_retry( + || client.get(&url), + "test", + false, + DEFAULT_MAX_RETRIES, + DEFAULT_OPERATION_DEADLINE, + Some(&state), + ) + .await; + } + assert!(state.is_circuit_open()); + + let url2 = mock_server(vec![504, 200]).await; + let result = send_with_retry( + || client.get(&url2), + "test", + false, + DEFAULT_MAX_RETRIES, + DEFAULT_OPERATION_DEADLINE, + Some(&state), + ) + .await; + assert!(matches!(result, Err(Error::Hub { status: Some(504), .. }))); + } + // ── probe_repo tests ────────────────────────────────────────────── #[tokio::test] diff --git a/src/setup.rs b/src/setup.rs index d7570a2e..4c60b225 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -109,12 +109,13 @@ pub struct MountOptions { pub poll_interval_secs: u64, /// Max Hub HTTP retries after the first failed attempt (408/429/5xx/timeouts). - /// 4 ⇒ up to 5 tries with longer backoff on 502/503/504 gateway errors. - #[arg(long, default_value_t = 4)] + /// 2 ⇒ up to 3 tries with exponential backoff and jitter. + #[arg(long, default_value_t = 2)] pub hub_max_retries: u32, /// Per-request Hub GET/list timeout in seconds. 0 keeps the built-in default (60s). - /// Raise above the Hub API's Mongo deadline when bucket metadata queries are slow. + /// This is the reqwest client timeout for a single HTTP round-trip, not the Hub's + /// MongoDB query deadline (that lives in moon-landing RuntimeConfig). #[arg(long, default_value_t = 0)] pub hub_request_timeout_secs: u64, @@ -122,6 +123,11 @@ pub struct MountOptions { #[arg(long, default_value_t = 0)] pub hub_head_request_timeout_secs: u64, + /// Wall-clock budget in milliseconds for one Hub operation including retries/backoff. + /// After this deadline hf-mount fails fast and may serve stale cached metadata. + #[arg(long, default_value_t = 5000)] + pub hub_operation_deadline_ms: u64, + /// Maximum number of concurrent tree-listing requests per poll round. /// Each loaded directory prefix issues one Hub API request; this cap /// prevents thundering-herd bursts on large mounts (e.g. transformers/docs) @@ -392,6 +398,7 @@ pub fn build_with_runtime( max_retries: options.hub_max_retries, request_timeout_secs: options.hub_request_timeout_secs, head_request_timeout_secs: options.hub_head_request_timeout_secs, + operation_deadline_ms: options.hub_operation_deadline_ms, }; let hub_client = runtime.block_on(async { HubApiClient::from_source( diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index d783240c..84205654 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -133,6 +133,10 @@ pub struct InodeEntry { /// hot path for write-heavy workloads (tarball extract, xfstests) that /// create thousands of unique names under freshly-mkdir'd directories. pub children_from_remote: bool, + /// When set, the directory listing is served from stale cache after a transient + /// Hub failure. Lookups may use cached children via `listing_usable()` without + /// treating the listing as freshly validated. + pub stale_listing_since: Option, pub children: Vec, /// Name → ino lookup for `lookup_child`. Kept in sync with `children` /// via the `add_child` / `remove_child_*` helpers so a directory with @@ -190,6 +194,17 @@ impl InodeEntry { self.children_loaded_at.is_some() } + /// True when cached directory children can be used for lookup/readdir even if + /// the listing was not freshly fetched (stale fallback after Hub errors). + pub fn listing_usable(&self) -> bool { + self.children_loaded_at.is_some() || self.stale_listing_since.is_some() + } + + pub fn stale_listing_recent(&self, retry_interval: std::time::Duration) -> bool { + self.stale_listing_since + .is_some_and(|since| since.elapsed() < retry_interval) + } + /// Mark the inode as dirty, incrementing the generation counter. pub fn set_dirty(&mut self) { self.dirty_generation = self.dirty_generation.saturating_add(1); @@ -288,6 +303,7 @@ impl InodeTable { dirty_generation: 0, children_loaded_at: None, children_from_remote: false, + stale_listing_since: None, children: Vec::new(), child_index: HashMap::new(), pending_deletes: Vec::new(), @@ -630,6 +646,7 @@ impl InodeTable { // sites). Directories start unloaded until the first list. children_loaded_at: None, children_from_remote: false, + stale_listing_since: None, children: Vec::new(), child_index: HashMap::new(), pending_deletes: Vec::new(), @@ -724,6 +741,14 @@ impl InodeTable { pub fn invalidate_children(&mut self, ino: u64) { if let Some(entry) = self.inodes.get_mut(&ino) { entry.children_loaded_at = None; + entry.stale_listing_since = None; + } + } + + /// Mark a directory as serving a stale cached listing after a transient Hub failure. + pub fn mark_stale_listing(&mut self, ino: u64) { + if let Some(entry) = self.inodes.get_mut(&ino) { + entry.stale_listing_since = Some(Instant::now()); } } @@ -1495,6 +1520,44 @@ mod tests { table.invalidate_children(9999); } + #[test] + fn test_stale_listing_usable_without_fresh_load() { + let mut table = InodeTable::new(false); + let dir_ino = table.insert( + ROOT_INODE, + "dir".to_string(), + "dir".to_string(), + InodeKind::Directory, + 0, + UNIX_EPOCH, + None, + 0o755, + 0, + 0, + ); + table.get_mut(dir_ino).unwrap().children_from_remote = true; + let file_ino = table.insert( + dir_ino, + "file.txt".to_string(), + "dir/file.txt".to_string(), + InodeKind::File, + 1, + UNIX_EPOCH, + None, + 0o644, + 0, + 0, + ); + assert!(!table.get(dir_ino).unwrap().children_loaded()); + assert!(!table.get(dir_ino).unwrap().listing_usable()); + + table.mark_stale_listing(dir_ino); + let parent = table.get(dir_ino).unwrap(); + assert!(parent.listing_usable()); + assert!(!parent.children_loaded()); + assert!(table.lookup_child(dir_ino, "file.txt").is_some_and(|e| e.inode == file_ino)); + } + #[test] fn test_pending_deletes() { let mut table = InodeTable::new(false); diff --git a/src/virtual_fs/mod.rs b/src/virtual_fs/mod.rs index 839e8531..f6719143 100644 --- a/src/virtual_fs/mod.rs +++ b/src/virtual_fs/mod.rs @@ -36,6 +36,8 @@ const BLOCK_SIZE: u32 = 512; const NEG_CACHE_CAPACITY: usize = 1_000; /// How long a negative-cache entry stays valid before being re-checked. const NEG_CACHE_TTL: Duration = Duration::from_secs(30); +/// Minimum interval between Hub list_tree retries while serving a stale listing. +const STALE_LISTING_RETRY_INTERVAL: Duration = Duration::from_secs(5); /// `notify_inval_entry` is a blocking syscall that takes the parent dir's /// `i_rwsem` in the kernel and walks the dcache. Issuing thousands per sweep /// starves concurrent FUSE ops (lookup/readdir wait on the same lock) and @@ -784,6 +786,12 @@ impl VirtualFs { match inodes.get(parent_ino) { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), + Some(e) + if e.stale_listing_recent(STALE_LISTING_RETRY_INTERVAL) + && inodes.has_cached_remote_children(parent_ino) => + { + return Ok(()); + } None => return Err(libc::ENOENT), _ => {} } @@ -799,6 +807,12 @@ impl VirtualFs { match inodes.get(parent_ino) { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), + Some(e) + if e.stale_listing_recent(STALE_LISTING_RETRY_INTERVAL) + && inodes.has_cached_remote_children(parent_ino) => + { + return Ok(()); + } Some(e) => e.full_path.to_string(), None => return Err(libc::ENOENT), } @@ -816,9 +830,7 @@ impl VirtualFs { "list_tree({prefix}) failed ({e}); reusing cached directory listing from before invalidation" ); let mut inodes = self.inode_table.write().expect("inodes poisoned"); - if let Some(entry) = inodes.get_mut(parent_ino) { - entry.children_loaded_at = Some(Instant::now()); - } + inodes.mark_stale_listing(parent_ino); return Ok(()); } error!("Failed to list tree for prefix '{prefix}': {e}"); @@ -942,6 +954,7 @@ impl VirtualFs { // directory we'd otherwise keep ~50% slack forever. Trim now, // since regrowth on rare child mutations is cheap. parent.children.shrink_to_fit(); + parent.stale_listing_since = None; parent.children_loaded_at = Some(Instant::now()); parent.children_from_remote = true; } @@ -1303,7 +1316,7 @@ impl VirtualFs { }, // Either a dirty file (local writes win until flushed) or any // entry under a fully-listed parent we can trust as-is. - Some(entry) if entry.kind == InodeKind::File || parent_entry.children_loaded() => { + Some(entry) if entry.kind == InodeKind::File || parent_entry.listing_usable() => { FastResult::Hit(self.make_vfs_attr(entry)) } // Cached non-file under an unloaded parent: we can't HEAD-probe @@ -1312,7 +1325,7 @@ impl VirtualFs { Some(_) => FastResult::NotLoaded, // No cached entry but the parent listing is authoritative → // the name really doesn't exist; populate the negative cache. - None if parent_entry.children_loaded() => { + None if parent_entry.listing_usable() => { let parent_path = &parent_entry.full_path; let full_path = if parent_path.is_empty() { name.to_string() @@ -1439,7 +1452,15 @@ impl VirtualFs { // 404 may mean "doesn't exist" or "it's a directory" (the resolve // endpoint only handles files), so the listing has the final word. Ok(_) => {} - Err(e) => debug!("HEAD lookup {} failed, falling back to list: {}", full_path, e), + Err(e) if e.is_retryable() => { + let inodes = self.inode_table.read().expect("inodes poisoned"); + if let Some(entry) = inodes.lookup_child(parent, name) { + warn!("HEAD {full_path} failed ({e}), serving cached inode"); + return Ok(self.make_vfs_attr(entry)); + } + debug!("HEAD lookup {full_path} failed, falling back to list: {e}"); + } + Err(e) => debug!("HEAD lookup {full_path} failed, falling back to list: {e}"), } self.ensure_children_loaded(parent).await?; From c7ce37825232badad8aed8ae13de5943da02fadb Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:35:09 +0200 Subject: [PATCH 3/9] Apply rustfmt fixes for CI fmt --check. Co-authored-by: Cursor --- src/hub_api.rs | 10 +--------- src/virtual_fs/inode.rs | 12 ++++++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 62e24feb..5b73d899 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -1862,15 +1862,7 @@ mod tests { let url = mock_server(vec![503, 503, 503, 503, 503]).await; let client = Client::new(); let started = Instant::now(); - let result = send_with_retry( - || client.get(&url), - "test", - false, - 10, - Duration::from_millis(250), - None, - ) - .await; + let result = send_with_retry(|| client.get(&url), "test", false, 10, Duration::from_millis(250), None).await; assert!(result.is_err()); assert!(started.elapsed() < Duration::from_secs(2)); } diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index 84205654..02613b9f 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -760,9 +760,9 @@ impl InodeTable { /// True when a remote-backed directory still has child entries cached locally /// (e.g. after poll invalidation cleared `children_loaded_at` but kept inodes). pub fn has_cached_remote_children(&self, ino: u64) -> bool { - self.inodes.get(&ino).is_some_and(|e| { - e.kind == InodeKind::Directory && e.children_from_remote && !e.children.is_empty() - }) + self.inodes + .get(&ino) + .is_some_and(|e| e.kind == InodeKind::Directory && e.children_from_remote && !e.children.is_empty()) } /// True if the inode or any descendant is either dirty or has an open @@ -1555,7 +1555,11 @@ mod tests { let parent = table.get(dir_ino).unwrap(); assert!(parent.listing_usable()); assert!(!parent.children_loaded()); - assert!(table.lookup_child(dir_ino, "file.txt").is_some_and(|e| e.inode == file_ino)); + assert!( + table + .lookup_child(dir_ino, "file.txt") + .is_some_and(|e| e.inode == file_ino) + ); } #[test] From 17796d7dd35bf5ac9f9762fde6bc04113c271d13 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:38:46 +0200 Subject: [PATCH 4/9] Address review: negative cache, stale backoff, read-only HEAD fallback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only populate negative dentry cache from fresh listings (children_loaded), not stale snapshots. Escalate stale list_tree retry interval 5s→10s→30s. Default hub operation deadline 3s (fail fast vs server Mongo timeout). Gate HEAD lookup stale fallback on read-only mounts. Check HTTP deadline before each attempt, not only before backoff sleep. Co-authored-by: Cursor --- src/hub_api.rs | 9 ++++++- src/setup.rs | 5 ++-- src/virtual_fs/inode.rs | 59 ++++++++++++++++++++++++++++++++++++++--- src/virtual_fs/mod.rs | 15 +++++------ 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 5b73d899..f23ca189 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -209,7 +209,9 @@ const DEFAULT_HEAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Default retries after the first failed attempt (3 total tries). const DEFAULT_MAX_RETRIES: u32 = 2; /// Wall-clock budget for a single Hub HTTP operation (all attempts + backoff). -const DEFAULT_OPERATION_DEADLINE: Duration = Duration::from_secs(5); +/// Kept shorter than the Hub's Mongo tree-listing deadline so transient 504s +/// fail fast to stale cache instead of racing the server timeout. +const DEFAULT_OPERATION_DEADLINE: Duration = Duration::from_secs(3); /// Open the circuit after this many consecutive gateway failures (502/503/504). const CIRCUIT_FAILURE_THRESHOLD: u32 = 3; const CIRCUIT_OPEN_DURATION: Duration = Duration::from_secs(10); @@ -443,6 +445,11 @@ async fn send_with_retry( let mut attempt = 0; loop { attempt += 1; + if started.elapsed() >= operation_deadline { + return Err(Error::hub(format!( + "{context}: operation deadline {operation_deadline:?} exceeded before attempt {attempt}" + ))); + } match build_request().send().await { Ok(resp) if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => { if let Some(state) = retry_state { diff --git a/src/setup.rs b/src/setup.rs index 4c60b225..fa78f38e 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -124,8 +124,9 @@ pub struct MountOptions { pub hub_head_request_timeout_secs: u64, /// Wall-clock budget in milliseconds for one Hub operation including retries/backoff. - /// After this deadline hf-mount fails fast and may serve stale cached metadata. - #[arg(long, default_value_t = 5000)] + /// Default 3000ms — shorter than the Hub's Mongo tree-listing deadline so 504s + /// fail fast to stale cache instead of racing the server timeout. + #[arg(long, default_value_t = 3000)] pub hub_operation_deadline_ms: u64, /// Maximum number of concurrent tree-listing requests per poll round. diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index 02613b9f..6e22baed 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -137,6 +137,8 @@ pub struct InodeEntry { /// Hub failure. Lookups may use cached children via `listing_usable()` without /// treating the listing as freshly validated. pub stale_listing_since: Option, + /// Escalating backoff between stale listing refresh attempts (5s → 10s → 30s). + pub stale_listing_backoff_level: u32, pub children: Vec, /// Name → ino lookup for `lookup_child`. Kept in sync with `children` /// via the `add_child` / `remove_child_*` helpers so a directory with @@ -200,9 +202,17 @@ impl InodeEntry { self.children_loaded_at.is_some() || self.stale_listing_since.is_some() } - pub fn stale_listing_recent(&self, retry_interval: std::time::Duration) -> bool { + pub fn stale_listing_retry_interval(&self) -> std::time::Duration { + match self.stale_listing_backoff_level.min(2) { + 0 => std::time::Duration::from_secs(5), + 1 => std::time::Duration::from_secs(10), + _ => std::time::Duration::from_secs(30), + } + } + + pub fn stale_listing_recent(&self) -> bool { self.stale_listing_since - .is_some_and(|since| since.elapsed() < retry_interval) + .is_some_and(|since| since.elapsed() < self.stale_listing_retry_interval()) } /// Mark the inode as dirty, incrementing the generation counter. @@ -304,6 +314,7 @@ impl InodeTable { children_loaded_at: None, children_from_remote: false, stale_listing_since: None, + stale_listing_backoff_level: 0, children: Vec::new(), child_index: HashMap::new(), pending_deletes: Vec::new(), @@ -647,6 +658,7 @@ impl InodeTable { children_loaded_at: None, children_from_remote: false, stale_listing_since: None, + stale_listing_backoff_level: 0, children: Vec::new(), child_index: HashMap::new(), pending_deletes: Vec::new(), @@ -742,12 +754,16 @@ impl InodeTable { if let Some(entry) = self.inodes.get_mut(&ino) { entry.children_loaded_at = None; entry.stale_listing_since = None; + entry.stale_listing_backoff_level = 0; } } /// Mark a directory as serving a stale cached listing after a transient Hub failure. pub fn mark_stale_listing(&mut self, ino: u64) { if let Some(entry) = self.inodes.get_mut(&ino) { + if entry.stale_listing_since.is_some() { + entry.stale_listing_backoff_level = entry.stale_listing_backoff_level.saturating_add(1).min(2); + } entry.stale_listing_since = Some(Instant::now()); } } @@ -1563,7 +1579,44 @@ mod tests { } #[test] - fn test_pending_deletes() { + fn test_stale_listing_backoff_intervals() { + let mut entry = InodeEntry { + inode: 2, + parent: 1, + name: Arc::from("dir"), + full_path: Arc::from("dir"), + kind: InodeKind::Directory, + size: 0, + mtime: UNIX_EPOCH, + mode: 0o755, + uid: 0, + gid: 0, + atime: UNIX_EPOCH, + ctime: UNIX_EPOCH, + nlink: 2, + symlink_target: None, + xet_hash: None, + staging_is_current: false, + etag: None, + dirty_generation: 0, + children_loaded_at: None, + children_from_remote: true, + stale_listing_since: None, + stale_listing_backoff_level: 0, + children: Vec::new(), + child_index: HashMap::new(), + pending_deletes: Vec::new(), + last_revalidated: None, + eviction: EvictionState::default(), + }; + assert_eq!(entry.stale_listing_retry_interval(), std::time::Duration::from_secs(5)); + entry.stale_listing_backoff_level = 1; + assert_eq!(entry.stale_listing_retry_interval(), std::time::Duration::from_secs(10)); + entry.stale_listing_backoff_level = 2; + assert_eq!(entry.stale_listing_retry_interval(), std::time::Duration::from_secs(30)); + } + + #[test] let mut table = InodeTable::new(false); let ino = table.insert( diff --git a/src/virtual_fs/mod.rs b/src/virtual_fs/mod.rs index f6719143..06ad138f 100644 --- a/src/virtual_fs/mod.rs +++ b/src/virtual_fs/mod.rs @@ -36,8 +36,6 @@ const BLOCK_SIZE: u32 = 512; const NEG_CACHE_CAPACITY: usize = 1_000; /// How long a negative-cache entry stays valid before being re-checked. const NEG_CACHE_TTL: Duration = Duration::from_secs(30); -/// Minimum interval between Hub list_tree retries while serving a stale listing. -const STALE_LISTING_RETRY_INTERVAL: Duration = Duration::from_secs(5); /// `notify_inval_entry` is a blocking syscall that takes the parent dir's /// `i_rwsem` in the kernel and walks the dcache. Issuing thousands per sweep /// starves concurrent FUSE ops (lookup/readdir wait on the same lock) and @@ -787,8 +785,7 @@ impl VirtualFs { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), Some(e) - if e.stale_listing_recent(STALE_LISTING_RETRY_INTERVAL) - && inodes.has_cached_remote_children(parent_ino) => + if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => { return Ok(()); } @@ -808,8 +805,7 @@ impl VirtualFs { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), Some(e) - if e.stale_listing_recent(STALE_LISTING_RETRY_INTERVAL) - && inodes.has_cached_remote_children(parent_ino) => + if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => { return Ok(()); } @@ -955,6 +951,7 @@ impl VirtualFs { // since regrowth on rare child mutations is cheap. parent.children.shrink_to_fit(); parent.stale_listing_since = None; + parent.stale_listing_backoff_level = 0; parent.children_loaded_at = Some(Instant::now()); parent.children_from_remote = true; } @@ -1325,7 +1322,9 @@ impl VirtualFs { Some(_) => FastResult::NotLoaded, // No cached entry but the parent listing is authoritative → // the name really doesn't exist; populate the negative cache. - None if parent_entry.listing_usable() => { + // Only trust misses from a fresh Hub listing — stale snapshots + // must not invent 30s negative entries for names we never saw. + None if parent_entry.children_loaded() => { let parent_path = &parent_entry.full_path; let full_path = if parent_path.is_empty() { name.to_string() @@ -1452,7 +1451,7 @@ impl VirtualFs { // 404 may mean "doesn't exist" or "it's a directory" (the resolve // endpoint only handles files), so the listing has the final word. Ok(_) => {} - Err(e) if e.is_retryable() => { + Err(e) if e.is_retryable() && self.read_only => { let inodes = self.inode_table.read().expect("inodes poisoned"); if let Some(entry) = inodes.lookup_child(parent, name) { warn!("HEAD {full_path} failed ({e}), serving cached inode"); From 6cbca366b92a3094f523413ad91d6dc32513adc3 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:40:29 +0200 Subject: [PATCH 5/9] Fix broken test module: restore test_pending_deletes fn header. Co-authored-by: Cursor --- src/virtual_fs/inode.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index 6e22baed..8865cdfb 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -1617,6 +1617,7 @@ mod tests { } #[test] + fn test_pending_deletes() { let mut table = InodeTable::new(false); let ino = table.insert( From 56da96a2ecee490286fd1e4480e7dae214c42b7d Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:42:59 +0200 Subject: [PATCH 6/9] Cap in-flight Hub requests with operation deadline timeout. Wrap each send() in tokio::time::timeout(remaining) so the 3s operation budget applies to hung requests, not just between retries. Reset circuit failure counter when the cooldown expires (half-open). Allow stale fallback for empty remote-backed directories. Fix operation_deadline_ms doc comment. Co-authored-by: Cursor --- src/hub_api.rs | 119 ++++++++++++++++++++++++++++++++++------ src/virtual_fs/inode.rs | 26 ++++++++- 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index f23ca189..885e888a 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -225,7 +225,8 @@ pub struct HubClientConfig { pub request_timeout_secs: u64, /// HEAD timeout. 0 keeps the built-in default (30s). pub head_request_timeout_secs: u64, - /// Total wall-clock budget for one Hub call including retries/backoff. 0 ⇒ 5s. + /// Total wall-clock budget for one Hub call including in-flight requests, + /// retries, and backoff. 0 keeps the built-in default (3s). pub operation_deadline_ms: u64, } @@ -248,6 +249,20 @@ struct HubRetryState { } impl HubRetryState { + /// Returns allowed retry count; opens half-open after the cooldown expires. + fn effective_max_retries(&self, max_retries: u32) -> u32 { + let mut guard = self.circuit_open_until.lock().expect("circuit lock poisoned"); + match *guard { + Some(until) if Instant::now() < until => 0, + Some(_) => { + *guard = None; + self.consecutive_gateway_failures.store(0, Ordering::Relaxed); + max_retries + } + None => max_retries, + } + } + fn is_circuit_open(&self) -> bool { let guard = self.circuit_open_until.lock().expect("circuit lock poisoned"); guard.is_some_and(|until| Instant::now() < until) @@ -423,9 +438,17 @@ async fn probe_repo( None } +fn operation_deadline_exceeded(context: &str, operation_deadline: Duration) -> Error { + Error::hub_status( + 504, + format!("{context}: operation deadline {operation_deadline:?} exceeded"), + ) +} + /// Send an HTTP request with automatic retry on transient errors (408, 429, 5xx, timeouts). /// Uses the IETF RateLimit header's t= parameter when present, falls back to exponential -/// backoff with full jitter. Stops when `operation_deadline` is exhausted even if retries remain. +/// backoff with full jitter. Each in-flight request is capped by the remaining operation +/// deadline (`tokio::time::timeout`); reqwest per-request timeouts are a secondary safety net. /// Set `accept_redirects` to treat 3xx as success (needed for HEAD on /resolve/ endpoints /// where the redirect response itself carries metadata headers). async fn send_with_retry( @@ -437,27 +460,28 @@ async fn send_with_retry( retry_state: Option<&HubRetryState>, ) -> Result { let started = Instant::now(); - let effective_max_retries = if retry_state.is_some_and(HubRetryState::is_circuit_open) { - 0 - } else { - max_retries - }; + let effective_max_retries = retry_state + .map(|state| state.effective_max_retries(max_retries)) + .unwrap_or(max_retries); let mut attempt = 0; loop { attempt += 1; - if started.elapsed() >= operation_deadline { - return Err(Error::hub(format!( - "{context}: operation deadline {operation_deadline:?} exceeded before attempt {attempt}" - ))); + let remaining = operation_deadline.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(operation_deadline_exceeded(context, operation_deadline)); } - match build_request().send().await { - Ok(resp) if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => { + + let send_result = tokio::time::timeout(remaining, build_request().send()).await; + match send_result { + Ok(Ok(resp)) + if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => + { if let Some(state) = retry_state { state.record_success(); } return Ok(resp); } - Ok(resp) => { + Ok(Ok(resp)) => { let status = resp.status().as_u16(); if is_retryable_status(status) && attempt <= effective_max_retries { let base_delay = parse_retry_delay(resp.headers()).unwrap_or_else(|| retry_delay(attempt)); @@ -488,7 +512,7 @@ async fn send_with_retry( let body = resp.text().await.unwrap_or_default(); return Err(Error::hub_status(status, format!("{context}: {status} {body}"))); } - Err(err) if (err.is_timeout() || err.is_connect()) && attempt <= effective_max_retries => { + Ok(Err(err)) if (err.is_timeout() || err.is_connect()) && attempt <= effective_max_retries => { let delay = jittered_delay(retry_delay(attempt), attempt); if started.elapsed() + delay >= operation_deadline { warn!( @@ -501,7 +525,23 @@ async fn send_with_retry( ); tokio::time::sleep(delay).await; } - Err(err) => return Err(Error::Http(err)), + Ok(Err(err)) => return Err(Error::Http(err)), + Err(_elapsed) if attempt <= effective_max_retries => { + let delay = jittered_delay(retry_delay(attempt), attempt); + if started.elapsed() + delay >= operation_deadline { + warn!( + "{context}: operation deadline {operation_deadline:?} exceeded waiting for in-flight request" + ); + return Err(operation_deadline_exceeded(context, operation_deadline)); + } + warn!( + "{context}: in-flight request exceeded remaining deadline, retry {attempt}/{effective_max_retries} in {delay:?}" + ); + tokio::time::sleep(delay).await; + } + Err(_elapsed) => { + return Err(operation_deadline_exceeded(context, operation_deadline)); + } } } } @@ -1781,6 +1821,38 @@ mod tests { url } + /// HTTP server that waits before responding (tests operation-deadline caps on in-flight requests). + async fn mock_slow_server(responses: Vec<(Duration, u16)>) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}"); + + tokio::spawn(async move { + for (delay, status) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let _ = stream.read(&mut buf).await; + tokio::time::sleep(delay).await; + let reason = match status { + 200 => "OK", + 504 => "Gateway Timeout", + _ => "Unknown", + }; + let body = format!("status {status}"); + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.ok(); + stream.shutdown().await.ok(); + } + }); + + url + } + #[tokio::test] async fn send_with_retry_success_on_first_try() { let url = mock_server(vec![200]).await; @@ -1864,6 +1936,21 @@ mod tests { assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(302), .. })); } + #[tokio::test] + async fn send_with_retry_caps_in_flight_request_by_operation_deadline() { + let url = mock_slow_server(vec![(Duration::from_secs(5), 200)]).await; + let client = Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap(); + let started = Instant::now(); + let result = + send_with_retry(|| client.get(&url), "test", false, 0, Duration::from_millis(200), None).await; + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(504), .. })); + } + #[tokio::test] async fn send_with_retry_respects_operation_deadline() { let url = mock_server(vec![503, 503, 503, 503, 503]).await; diff --git a/src/virtual_fs/inode.rs b/src/virtual_fs/inode.rs index 8865cdfb..682f2799 100644 --- a/src/virtual_fs/inode.rs +++ b/src/virtual_fs/inode.rs @@ -773,12 +773,13 @@ impl InodeTable { self.inodes.get(&ino).is_some_and(|e| e.children_loaded()) } - /// True when a remote-backed directory still has child entries cached locally - /// (e.g. after poll invalidation cleared `children_loaded_at` but kept inodes). + /// True when a remote-backed directory still has a cached listing locally + /// (including legitimately empty dirs). Used after poll invalidation cleared + /// `children_loaded_at` but kept inode state. pub fn has_cached_remote_children(&self, ino: u64) -> bool { self.inodes .get(&ino) - .is_some_and(|e| e.kind == InodeKind::Directory && e.children_from_remote && !e.children.is_empty()) + .is_some_and(|e| e.kind == InodeKind::Directory && e.children_from_remote) } /// True if the inode or any descendant is either dirty or has an open @@ -1578,6 +1579,25 @@ mod tests { ); } + #[test] + fn test_has_cached_remote_children_includes_empty_dir() { + let mut table = InodeTable::new(false); + let dir_ino = table.insert( + ROOT_INODE, + "empty".to_string(), + "empty".to_string(), + InodeKind::Directory, + 0, + UNIX_EPOCH, + None, + 0o755, + 0, + 0, + ); + table.get_mut(dir_ino).unwrap().children_from_remote = true; + assert!(table.has_cached_remote_children(dir_ino)); + } + #[test] fn test_stale_listing_backoff_intervals() { let mut entry = InodeEntry { From d57a449e0d683231a31c91f66ff78d1e242ab6e9 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:45:15 +0200 Subject: [PATCH 7/9] Apply rustfmt fixes for CI fmt --check. Co-authored-by: Cursor --- src/hub_api.rs | 25 ++++++------------------- src/virtual_fs/mod.rs | 8 ++------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 885e888a..14c89ec1 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -473,9 +473,7 @@ async fn send_with_retry( let send_result = tokio::time::timeout(remaining, build_request().send()).await; match send_result { - Ok(Ok(resp)) - if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => - { + Ok(Ok(resp)) if resp.status().is_success() || (accept_redirects && resp.status().is_redirection()) => { if let Some(state) = retry_state { state.record_success(); } @@ -493,9 +491,7 @@ async fn send_with_retry( { state.record_gateway_failure(); } - warn!( - "{context}: operation deadline {operation_deadline:?} exceeded after attempt {attempt}" - ); + warn!("{context}: operation deadline {operation_deadline:?} exceeded after attempt {attempt}"); return Err(Error::hub_status(status, format!("{context}: {status} {body}"))); } warn!( @@ -520,9 +516,7 @@ async fn send_with_retry( ); return Err(Error::Http(err)); } - warn!( - "{context}: transient error, retry {attempt}/{effective_max_retries} in {delay:?}: {err}" - ); + warn!("{context}: transient error, retry {attempt}/{effective_max_retries} in {delay:?}: {err}"); tokio::time::sleep(delay).await; } Ok(Err(err)) => return Err(Error::Http(err)), @@ -567,10 +561,7 @@ fn make_clients(backend: &str, config: HubClientConfig) -> (Client, Client) { .tcp_keepalive(Some(Duration::from_secs(60))) .connect_timeout(Duration::from_secs(10)) }; - let client = base() - .timeout(request_timeout) - .build() - .expect("failed to build client"); + let client = base().timeout(request_timeout).build().expect("failed to build client"); let head_client = base() .redirect(reqwest::redirect::Policy::none()) .timeout(head_request_timeout) @@ -1939,13 +1930,9 @@ mod tests { #[tokio::test] async fn send_with_retry_caps_in_flight_request_by_operation_deadline() { let url = mock_slow_server(vec![(Duration::from_secs(5), 200)]).await; - let client = Client::builder() - .timeout(Duration::from_secs(60)) - .build() - .unwrap(); + let client = Client::builder().timeout(Duration::from_secs(60)).build().unwrap(); let started = Instant::now(); - let result = - send_with_retry(|| client.get(&url), "test", false, 0, Duration::from_millis(200), None).await; + let result = send_with_retry(|| client.get(&url), "test", false, 0, Duration::from_millis(200), None).await; assert!(result.is_err()); assert!(started.elapsed() < Duration::from_secs(1)); assert!(matches!(result.unwrap_err(), Error::Hub { status: Some(504), .. })); diff --git a/src/virtual_fs/mod.rs b/src/virtual_fs/mod.rs index 06ad138f..9203a648 100644 --- a/src/virtual_fs/mod.rs +++ b/src/virtual_fs/mod.rs @@ -784,9 +784,7 @@ impl VirtualFs { match inodes.get(parent_ino) { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), - Some(e) - if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => - { + Some(e) if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => { return Ok(()); } None => return Err(libc::ENOENT), @@ -804,9 +802,7 @@ impl VirtualFs { match inodes.get(parent_ino) { Some(e) if e.kind != InodeKind::Directory => return Err(libc::ENOTDIR), Some(e) if e.children_loaded() => return Ok(()), - Some(e) - if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => - { + Some(e) if e.stale_listing_recent() && inodes.has_cached_remote_children(parent_ino) => { return Ok(()); } Some(e) => e.full_path.to_string(), From e3cb1d287228d9d84e7140ab89c5c6f06ee8dd85 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:47:11 +0200 Subject: [PATCH 8/9] Fix clippy: remove unused is_circuit_open, use range pattern for 502-504. Co-authored-by: Cursor --- src/hub_api.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 14c89ec1..5eda3832 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -263,11 +263,6 @@ impl HubRetryState { } } - fn is_circuit_open(&self) -> bool { - let guard = self.circuit_open_until.lock().expect("circuit lock poisoned"); - guard.is_some_and(|until| Instant::now() < until) - } - fn record_success(&self) { self.consecutive_gateway_failures.store(0, Ordering::Relaxed); *self.circuit_open_until.lock().expect("circuit lock poisoned") = None; From 6906ac0603897af42ef20e8d495d30cc8dca3069 Mon Sep 17 00:00:00 2001 From: Arek Borucki Date: Sun, 9 Aug 2026 13:49:18 +0200 Subject: [PATCH 9/9] Fix clippy manual_range_patterns for is_gateway_status (502..=504). Co-authored-by: Cursor --- src/hub_api.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hub_api.rs b/src/hub_api.rs index 5eda3832..31b0023c 100644 --- a/src/hub_api.rs +++ b/src/hub_api.rs @@ -368,7 +368,7 @@ fn jittered_delay(base: Duration, salt: u32) -> Duration { } fn is_gateway_status(status: u16) -> bool { - matches!(status, 502 | 503 | 504) + matches!(status, 502..=504) } /// Parse the IETF `RateLimit` header for `t=` (time until window reset), capped at 30s. @@ -1959,7 +1959,6 @@ mod tests { ) .await; } - assert!(state.is_circuit_open()); let url2 = mock_server(vec![504, 200]).await; let result = send_with_retry(