Skip to content

Gateway: make Timeout recoverable; bridge: drop the per-client RwLock - #89

Merged
ehsan6sha merged 5 commits into
mainfrom
fix/gateway-timeout-recovery-and-bridge-lock
Aug 23, 2026
Merged

Gateway: make Timeout recoverable; bridge: drop the per-client RwLock#89
ehsan6sha merged 5 commits into
mainfrom
fix/gateway-timeout-recovery-and-bridge-lock

Conversation

@ehsan6sha

Copy link
Copy Markdown
Member

Backend half of the FxFiles web-hang investigation. The client-side work is already shipped; this is what it was routing around.

Not for release yet — no version bump, no tag. Opening for CI (cargo test --workspace, wasm clippy, wasm-pack test --headless --firefox) and for review of the lock removal.

1. f4d6635 — gateway: let a block Timeout reach the gc-recovery path

The gateway already has a full gc-recovery system (recovery_fallback.rs: resolve key→CID from the cluster pinset mirror and serve the block). Production could not reach it.

Recovery is invoked only from the miss branches gated on NotFound/Unavailable. A read that fails with Timeout isn't in that set, so:

  • try_recover_block never runs — the cluster mirror that still holds the data is never consulted;
  • Timeout has no arm in ApiError::error_code(), so it hits the _ catch-all → InternalErrorHTTP 500;
  • a 5xx trips the client SDK's health gate, marking the entire backend down — exactly what the deliberate Unavailable → 410 mapping exists to prevent.

Why Timeout survives instead of becoming Unavailable: cluster_fallback::classify_exhaustion only rewrites it when the local daemon's offline probe returns a clean miss. Read-only inspection of the live host found load ~12.8, fula-gateway at 12.4 GB RSS, and kubo answering /id in 45 ms but unable to answer repo/stat within 30 s — so that probe errors out, responsive is false, and the Timeout passes through. A bucket whose data is still pinned in the cluster therefore 500s.

Also fixes a second gap: open_bucket_for_user was called with a bare ?. It reads the prolly index root — exactly the block class ipfs repo gc orphans — so a root-block miss bypassed the recovery gate one match below (Unavailable → 410 with no recovery and no client by-CID fallback, which fires only on 404; NotFound → 500). Both now route through the same gate, factored into recover_or_nosuchkey() so the two call sites can't drift.

Trade-off: a genuinely transient infra timeout now attempts recovery and, failing that, surfaces as 404 rather than 500. Better for clients, but a real outage reads as "not found" on this path. Errors that mean the daemon is actually unreachable (Connection, IpfsApi, …) are deliberately excluded and still propagate as 5xx, so the health gate still trips on a true outage.

This makes a damaged bucket fail gracefully. It does not repair one, and it does not address why the host is resource-saturated.

2. 984d6ed — bridge: drop the per-client RwLock

One slow bucket froze the entire web app. EncryptedClientHandle.inner was Arc<RwLock<EncryptedClient>> — a single lock, not per-bucket. load_forest took the exclusive guard and held it across the whole network fetch. Captured live on a phone:

loadForest: ENTER tag-metadata            <- ~30s, holds the write guard
getFlat:    ENTER website-metadata-v8/..  <- never completes
loadForest: ENTER tag-metadata-v8         <- never completes (healthy, normally 0.1s)
WebsiteDetail: loadWebsites ENTER         <- never completes

A Dart-side .timeout() can't rescue this: FRB exposes no cancel handle for load_forest, so the Rust future keeps running and keeps the guard after the caller gives up.

The lock is deleted, not made per-bucket, because:

  • EncryptedClient has zero &mut self methods — all ~180 take &self. The lock never protected mutability.
  • The SDK already owns per-bucket locking: forest_cache (DashMap), migration_locks, bucket_write_mutex — the last documented as "the OUTERMOST per-bucket lock … per-bucket, so different buckets continue to flush in parallel." A bridge-level per-bucket map would duplicate that one layer up, and wouldn't even cover calls carrying no bucket (get_public_key, export_secret_key).
  • The read/write split encoded no invariant: save_forest (read) and flush_forest (write) had byte-identical bodies; rewrap_object took write() for one object while rotate_bucket took read() for a whole bucket.
  • No bridge fn composed two SDK calls under one guard.
  • FulaClientHandle next door already uses a bare Arc — in-repo precedent.

57 acquisitions removed (forest 27, encrypted 18, sharing 5, rotation 4, chunked 3). Arc<EncryptedClient> derefs to &EncryptedClient, so every downstream guard.… call is untouched.

3. 5060ab2 — wasm timeout via reqwest's own mechanism

ClientBuilder::timeout is a no-op on wasm32, so Config::timeout was silently dropped in the browser — no request timeout at all; a stalled fetch could hang for the tab's lifetime holding SDK locks.

I first hand-rolled a futures::select against a gloo-timers timer, then verified the claim that dropping the loser aborts the fetch. It does (AbortGuard::dropctrl.abort()), but the same file showed the racing was unnecessary — reqwest's wasm backend honours a per-request timeout:

reqwest-0.12.24/src/wasm/request.rs:264  pub fn timeout(mut self, ..) -> RequestBuilder
reqwest-0.12.24/src/wasm/mod.rs:64       AbortGuard::timeout -> ctrl.abort_with_reason(..)
reqwest-0.12.24/src/wasm/client.rs:229   AbortGuard::new() -> abort.timeout(*timeout) -> init.signal(..)

Replaced by one cfg-gated line. That removed the helper, the new ClientError::Timeout variant and its FulaError arm — so the public ClientError enum is unchanged (no downstream exhaustive-match break), and a browser timeout surfaces as an ordinary reqwest::Error with is_timeout(), identical in shape to native.

Review focus

The lock removal is the part that needs eyes. Known residual, stated rather than hidden: rewrap_object / rotate_bucket are the only write-guard sites whose SDK method takes no per-bucket lock, so key rotation can now overlap with an upload on the same bucket. I deliberately did not add a lock inside rewrap_object_dekrotate_bucket_inner calls it through buffer_unordered(MAX_CONCURRENT_REWRAPS), so that would serialise a deliberately-parallel operation. Rotation already mutates the forest concurrently within itself by design, and it has no call site in FxFiles today; this belongs in the SDK's own lock ordering rather than here.

Verified locally: cargo check clean on native and wasm32-unknown-unknown at each commit.

ehsan6sha and others added 5 commits August 22, 2026 19:11
The gateway already has a full gc-recovery system (recovery_fallback.rs:
resolve the key->CID from the cluster pinset mirror and serve the block).
Production could not reach it.

recovery is invoked only from the miss branches gated on NotFound /
Unavailable (handlers/object.rs). A read that fails with Timeout is not
in that set, so:

  * try_recover_block never runs -- the cluster mirror that still holds
    the data is never consulted;
  * Timeout has no arm in ApiError::error_code(), so it hits the `_`
    catch-all -> InternalError -> HTTP 500;
  * and a 5xx trips the client SDK's health gate, which marks the ENTIRE
    backend down. That is precisely the outcome the deliberate
    Unavailable -> 410 mapping exists to prevent.

Why Timeout survives instead of being rewritten to Unavailable:
cluster_fallback::classify_exhaustion only converts it when the local
daemon's offline probe returned a CLEAN miss. Read-only inspection of the
live host found load ~12.8, fula-gateway at 12.4 GB RSS, and kubo unable
to answer repo/stat within 30s while answering /id in 45ms -- so that
probe errors out, `responsive` is false, and the Timeout passes through.
A bucket whose data is still pinned in the cluster therefore 500s.

Two changes:

1. Timeout joins NotFound/Unavailable in the recoverable-miss set. Errors
   that indicate the daemon is genuinely unreachable (Connection,
   IpfsApi, ...) are deliberately NOT in the set and still propagate as
   5xx, so the health gate still trips on a true outage.

2. open_bucket_for_user is no longer called with a bare `?`. It reads the
   prolly INDEX ROOT -- exactly the block class an `ipfs repo gc` orphans
   -- so a root-block miss bypassed the recovery gate one match below:
   Unavailable became a 410 with no recovery attempted and no client
   by-CID fallback (which fires only on 404), and NotFound became a 500.
   Both now route through the same gate as an interior-node miss.

The miss handling is factored into recover_or_nosuchkey() so the two call
sites cannot drift.

Trade-off, stated plainly: a genuinely transient infra timeout now
attempts recovery and, failing that, surfaces as a 404 rather than a 500.
Better for the client, but it does mean a real outage reads as "not
found" on this path.

NOTE: this makes a damaged bucket fail gracefully. It does not repair
one, and it does not address why the host is resource-saturated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANUDVTMmqZGFDjbj7k9HuP
One slow bucket froze the entire web app. EncryptedClientHandle wrapped
the client in Arc<RwLock<..>>, load_forest took the EXCLUSIVE guard and
held it across the whole network fetch, and every other call -- on every
other bucket -- queued behind it. Captured live on a phone:

  loadForest: ENTER tag-metadata            <- ~30s, holds the write guard
  getFlat:    ENTER website-metadata-v8/..  <- never completes
  loadForest: ENTER tag-metadata-v8         <- never completes (healthy
                                               bucket, normally 0.1s)
  WebsiteDetail: loadWebsites ENTER         <- never completes

A Dart-side .timeout() cannot rescue that: the FRB binding exposes no
cancel handle for load_forest, so the Rust future keeps running and keeps
the guard after the caller has given up.

The fix is to DELETE the lock, not to make it per-bucket:

  * EncryptedClient has no &mut self methods -- all ~180 public methods
    take &self. The lock never protected mutability.
  * The SDK already owns finer-grained locking: forest_cache (DashMap),
    per-bucket migration_locks, and bucket_write_mutex, documented there
    as "the OUTERMOST per-bucket lock ... per-bucket, so different
    buckets continue to flush in parallel". A per-bucket lock at the
    bridge would duplicate that one layer up -- and would not even cover
    the calls that carry no bucket (get_public_key, export_secret_key).
  * The read/write split encoded no invariant: save_forest (read) and
    flush_forest (write) had byte-identical bodies; rewrap_object took
    write() for ONE object while rotate_bucket took read() for a whole
    bucket.
  * No bridge function composed two SDK calls under one guard, so nothing
    depended on atomicity it appeared to provide.
  * FulaClientHandle next door already uses a bare Arc -- in-repo
    precedent.

57 guard acquisitions removed (forest 27, encrypted 18, sharing 5,
rotation 4, chunked 3); Arc<EncryptedClient> derefs to &EncryptedClient
so every downstream `guard.` call is untouched.

Also: wasm had NO request timeout at all. reqwest's
ClientBuilder::timeout is a no-op on wasm32, so FulaClient::new could not
arm it and Config::timeout was silently dropped in the browser -- a
stalled request could hang for the lifetime of the tab, holding the SDK's
per-bucket lock (and, before this change, the whole client). send_bounded
now races the send against a gloo-timers timer on wasm and stays a plain
send() on native, where reqwest already enforces the budget. Dropping the
abandoned future actually aborts the fetch, rather than orphaning it the
way a Dart-side timeout does.

New ClientError::Timeout maps to FulaError::Network, matching how
reqwest's own native timeout already surfaces, so both targets present a
transport failure identically to Dart.

Verified: cargo check clean on native AND wasm32-unknown-unknown.
NOT yet reviewed for concurrency regressions -- removing a global lock
increases parallelism and can surface latent SDK races.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANUDVTMmqZGFDjbj7k9HuP
…d race

Simplifies the wasm timeout added in the previous commit. I had claimed
that dropping the abandoned send() future aborts the browser fetch, and
went to verify it rather than leave it asserted. It is true --
AbortGuard::drop calls ctrl.abort() (reqwest-0.12.24/src/wasm/mod.rs:78-84)
-- but the same file showed the racing was unnecessary.

reqwest's wasm backend honours a per-REQUEST timeout. Only the
CLIENT-level ClientBuilder::timeout is a no-op there. See
reqwest-0.12.24/src/wasm:
  request.rs:264   pub fn timeout(mut self, timeout: Duration) -> RequestBuilder
  mod.rs:64        AbortGuard::timeout -> set_timeout(.. ctrl.abort_with_reason ..)
  client.rs:229    let mut abort = AbortGuard::new()?;
  client.rs:230        if let Some(timeout) = req.timeout() { abort.timeout(*timeout); }
  client.rs:233        init.signal(Some(&abort.signal()));

So the hand-rolled futures::select against a gloo-timers TimeoutFuture
reimplemented -- less well -- what reqwest already does with an
AbortController. Replaced by one cfg-gated line before send().

Removed as a result: the send_bounded helper, the new ClientError::Timeout
variant, and its FulaError arm. The public ClientError enum is therefore
UNCHANGED, so no downstream exhaustive match breaks, and a browser timeout
now surfaces as an ordinary reqwest::Error with is_timeout() -- identical
in shape to native instead of a wasm-only special case.

Verified: cargo check clean on native and wasm32-unknown-unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANUDVTMmqZGFDjbj7k9HuP
Removing the coarse bridge RwLock removed accidental serialization between key rotation and uploads. rewrap_object_dek is a GET-modify-PUT plus a forest mutation and takes no per-bucket lock, so an upload landing between its GET and PUT is overwritten by the re-encrypted old data.

Documented rather than papered over: taking bucket_write_mutex here would serialize rotate_bucket_inner's buffer_unordered concurrency, and a per-object lock would not exclude uploads (which hold only the per-bucket mutex). Correct fix is If-Match/ETag optimistic concurrency inside fula-client::rewrap_object_dek.

Acceptable to ship now: rotation has no call site in FxFiles, and the window needs a rotation and an upload of the SAME object to overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01ANUDVTMmqZGFDjbj7k9HuP
Bridge lock removal + wasm per-request timeout + gateway Timeout recovery. All 9 CI checks green on PR #89, including cargo test --workspace and wasm-pack headless-Firefox runtime tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01ANUDVTMmqZGFDjbj7k9HuP
@ehsan6sha
ehsan6sha merged commit a03a530 into main Aug 23, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant