Gateway: make Timeout recoverable; bridge: drop the per-client RwLock - #89
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 blockTimeoutreach the gc-recovery pathThe 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 withTimeoutisn't in that set, so:try_recover_blocknever runs — the cluster mirror that still holds the data is never consulted;Timeouthas no arm inApiError::error_code(), so it hits the_catch-all →InternalError→ HTTP 500;Unavailable→ 410 mapping exists to prevent.Why
Timeoutsurvives instead of becomingUnavailable:cluster_fallback::classify_exhaustiononly 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-gatewayat 12.4 GB RSS, and kubo answering/idin 45 ms but unable to answerrepo/statwithin 30 s — so that probe errors out,responsiveis false, and theTimeoutpasses through. A bucket whose data is still pinned in the cluster therefore 500s.Also fixes a second gap:
open_bucket_for_userwas called with a bare?. It reads the prolly index root — exactly the block classipfs repo gcorphans — 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 intorecover_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.2.
984d6ed— bridge: drop the per-clientRwLockOne slow bucket froze the entire web app.
EncryptedClientHandle.innerwasArc<RwLock<EncryptedClient>>— a single lock, not per-bucket.load_foresttook the exclusive guard and held it across the whole network fetch. Captured live on a phone:A Dart-side
.timeout()can't rescue this: FRB exposes no cancel handle forload_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:
EncryptedClienthas zero&mut selfmethods — all ~180 take&self. The lock never protected mutability.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).save_forest(read) andflush_forest(write) had byte-identical bodies;rewrap_objecttookwrite()for one object whilerotate_buckettookread()for a whole bucket.FulaClientHandlenext door already uses a bareArc— in-repo precedent.57 acquisitions removed (forest 27, encrypted 18, sharing 5, rotation 4, chunked 3).
Arc<EncryptedClient>derefs to&EncryptedClient, so every downstreamguard.…call is untouched.3.
5060ab2— wasm timeout via reqwest's own mechanismClientBuilder::timeoutis a no-op on wasm32, soConfig::timeoutwas 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::selectagainst agloo-timerstimer, then verified the claim that dropping the loser aborts the fetch. It does (AbortGuard::drop→ctrl.abort()), but the same file showed the racing was unnecessary — reqwest's wasm backend honours a per-request timeout:Replaced by one cfg-gated line. That removed the helper, the new
ClientError::Timeoutvariant and itsFulaErrorarm — so the publicClientErrorenum is unchanged (no downstream exhaustive-match break), and a browser timeout surfaces as an ordinaryreqwest::Errorwithis_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_bucketare 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 insiderewrap_object_dek—rotate_bucket_innercalls it throughbuffer_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 checkclean on native andwasm32-unknown-unknownat each commit.