You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Disclosure: this issue was drafted with the help of Claude Code based on a real bug we hit and fixed downstream — the panic, the trigger sequence, and the two code paths are from actual production debugging, but the writeup itself was AI-assisted. If anything here is inaccurate, mischaracterized, or otherwise not actionable, please feel free to close — no offense taken.
Summary
BaoFileStorage::bitfield() panicking on the Poisoned arm (src/store/fs/bao_file.rs:410) is a known symptom — PR #214 proposes to soften that arm. This issue is about the two upstream code paths that poison a handle when they should not, both of which are still present on main and in v0.100.0. Fixing them removes the panic at the source and is independent of (and complementary to) the symptom-level fix discussed in #214.
Both were reproduced from a downstream app (uniclipboard, a clipboard-sync tool that fetches blobs via iroh-blobs) and crash the iroh-blob-store worker task, leaving the store unusable until process restart.
Root cause 1 — HashContext::persist() poisons non-Partial handles
BaoFileStorage::take() is mem::replace(self, BaoFileStorage::Poisoned) — it always swaps. When the current variant is Complete (or anything other than Partial), the let-else returns early after the state has been replaced with Poisoned. The original Complete state is discarded and never restored.
A later observe(hash) → BaoFileStorageSubscriber::forward → BaoFileStorage::bitfield() then hits the Poisoned arm and panics.
Observed trigger sequence (UTC timestamps from a failing run):
13:40:13 — export with ExportMode::TryReference. Metadata DB transitions to Complete { data_location: External(...) }; in-memory state still Complete.
Some time later the entity manager evicts the handle (ShutdownCause::Idle) and calls persist(). State is Complete, so the let-else returns early — but take() has already swapped to Poisoned.
13:41:32 — user re-copies the same file; receiver calls store.blobs().observe(hash); subscriber dispatches bitfield() on the poisoned state.
thread 'iroh-blob-store-2' panicked at iroh-blobs-0.100.0/src/store/fs/bao_file.rs:410:17:
poisoned storage should not be used
BaoFileStorage::open calls std::fs::File::open(path) for DataLocation::External(...) (src/store/fs/bao_file.rs:596). When the external path is missing, the error is io::ErrorKind::NotFound — a fully recoverable condition (re-fetch from the network rewrites the metadata). Mapping it to Poisoned means the very next observe(hash) panics.
This conflates two distinct failure modes:
Stale metadata, target file gone — recoverable, should surface as "blob absent".
Real on-disk fault (permission denied, corrupted outboard, disk full) — should stay loud / Poisoned.
In our case the drift comes from ExportMode::TryReference caches being pruned by the application or older releases while the metadata DB still points at them. The application-side reconciliation is a separate concern; the panic on observe is the platform-level bug.
Proposed fix — route through a small helper that special-cases NotFound:
PR #214 changes bitfield() so the Poisoned arm returns Bitfield::empty() instead of panicking. That is a useful symptom-level guard for any future code path that ends up Poisoned, but:
it does not stop healthy Complete handles from being poisoned by persist(),
it does not stop missing-file recoverable cases from being poisoned by load(),
The two fixes here are orthogonal to that decision and can land independently.
Regression test
A deterministic code-level test for root cause 1 is straightforward:
Construct a BaoFileStorage::Complete(...) inside a watch::channel.
Run the buggy snippet — assert the state ends up Poisoned and bitfield() panics.
Run the patched snippet — assert the state is unchanged and bitfield() returns Bitfield::complete(...).
We have such a test (the buggy one marked #[should_panic(expected = \"poisoned storage should not be used\")], so it passes today and will start failing the day upstream merges an equivalent fix) and an end-to-end smoke test exercising add_bytes → observe → export_with_opts(TryReference) → sync_db → observe over many distinct hashes to churn the entity-manager actor pool. Happy to send these as a PR alongside the fixes if useful.
Honest caveat on a "fully deterministic" public-API repro
For root cause 1, we tried but did not get a deterministic public-API repro that panics on upstream and passes on the patch:
The buggy persist() is send_if_modified(|guard| ... false). tokio::sync::watch only signals changed() when the closure returns true, so a subscriber parked on changed() never wakes up after the buggy take() — a concurrent test that hopes to see the panic via the subscriber path stalls.
Through FsStore, the production race window is closed quickly by the entity-manager: after on_shutdown → persist poisons the state, the actor either reset()s the handle when the inbox is non-empty or recycle()s it back to the pool. The observable window is a few instructions inside the manager actor; staging a test there from outside the crate would need new test hooks.
The code-level test pins the broken semantics; the smoke test verifies the patched path is healthy under repeated use. Root cause 2 does have a clean public-API repro (the one @YuniqueUnic already posted in #214): create a >16 KiB blob → shutdown → delete the .data file → reopen → observe. With root cause 2 fixed (NotFound → NonExisting), observe would yield an empty bitfield instead of panicking, regardless of #214.
Environment
iroh-blobsv0.100.0 (also verified on main at the time of filing — both code paths unchanged)
Warning
Disclosure: this issue was drafted with the help of Claude Code based on a real bug we hit and fixed downstream — the panic, the trigger sequence, and the two code paths are from actual production debugging, but the writeup itself was AI-assisted. If anything here is inaccurate, mischaracterized, or otherwise not actionable, please feel free to close — no offense taken.
Summary
BaoFileStorage::bitfield()panicking on thePoisonedarm (src/store/fs/bao_file.rs:410) is a known symptom — PR #214 proposes to soften that arm. This issue is about the two upstream code paths that poison a handle when they should not, both of which are still present onmainand inv0.100.0. Fixing them removes the panic at the source and is independent of (and complementary to) the symptom-level fix discussed in #214.Both were reproduced from a downstream app (uniclipboard, a clipboard-sync tool that fetches blobs via
iroh-blobs) and crash theiroh-blob-storeworker task, leaving the store unusable until process restart.Root cause 1 —
HashContext::persist()poisons non-Partialhandlessrc/store/fs.rs:990-1009:BaoFileStorage::take()ismem::replace(self, BaoFileStorage::Poisoned)— it always swaps. When the current variant isComplete(or anything other thanPartial), thelet-elsereturns early after the state has been replaced withPoisoned. The originalCompletestate is discarded and never restored.A later
observe(hash)→BaoFileStorageSubscriber::forward→BaoFileStorage::bitfield()then hits thePoisonedarm and panics.Observed trigger sequence (UTC timestamps from a failing run):
13:40:13— fetch blob8878b5704b…; in-memory handle reachesComplete.13:40:13— export withExportMode::TryReference. Metadata DB transitions toComplete { data_location: External(...) }; in-memory state stillComplete.ShutdownCause::Idle) and callspersist(). State isComplete, so thelet-elsereturns early — buttake()has already swapped toPoisoned.13:41:32— user re-copies the same file; receiver callsstore.blobs().observe(hash); subscriber dispatchesbitfield()on the poisoned state.Proposed fix — check the variant before taking:
A more defensive variant would change
BaoFileStorage::take()itself to only swap when a predicate matches, but that has broader API impact.Root cause 2 —
HashContext::load()poisons on every IO error, includingNotFoundsrc/store/fs.rs:286-302:BaoFileStorage::opencallsstd::fs::File::open(path)forDataLocation::External(...)(src/store/fs/bao_file.rs:596). When the external path is missing, the error isio::ErrorKind::NotFound— a fully recoverable condition (re-fetch from the network rewrites the metadata). Mapping it toPoisonedmeans the very nextobserve(hash)panics.This conflates two distinct failure modes:
Poisoned.In our case the drift comes from
ExportMode::TryReferencecaches being pruned by the application or older releases while the metadata DB still points at them. The application-side reconciliation is a separate concern; the panic onobserveis the platform-level bug.Proposed fix — route through a small helper that special-cases
NotFound:Downstream then sees the blob as absent and goes through the normal
download → ImportBaopath, which rewrites the metadata entry from the fresh fetch.Relationship to PR #214
PR #214 changes
bitfield()so thePoisonedarm returnsBitfield::empty()instead of panicking. That is a useful symptom-level guard for any future code path that ends upPoisoned, but:Completehandles from being poisoned bypersist(),load(),Result<Bitfield, ...>vs. waiting onLoading) hasn't converged.The two fixes here are orthogonal to that decision and can land independently.
Regression test
A deterministic code-level test for root cause 1 is straightforward:
BaoFileStorage::Complete(...)inside awatch::channel.Poisonedandbitfield()panics.bitfield()returnsBitfield::complete(...).We have such a test (the buggy one marked
#[should_panic(expected = \"poisoned storage should not be used\")], so it passes today and will start failing the day upstream merges an equivalent fix) and an end-to-end smoke test exercisingadd_bytes → observe → export_with_opts(TryReference) → sync_db → observeover many distinct hashes to churn the entity-manager actor pool. Happy to send these as a PR alongside the fixes if useful.Honest caveat on a "fully deterministic" public-API repro
For root cause 1, we tried but did not get a deterministic public-API repro that panics on upstream and passes on the patch:
persist()issend_if_modified(|guard| ... false).tokio::sync::watchonly signalschanged()when the closure returnstrue, so a subscriber parked onchanged()never wakes up after the buggytake()— a concurrent test that hopes to see the panic via the subscriber path stalls.FsStore, the production race window is closed quickly by the entity-manager: afteron_shutdown → persistpoisons the state, the actor eitherreset()s the handle when the inbox is non-empty orrecycle()s it back to the pool. The observable window is a few instructions inside the manager actor; staging a test there from outside the crate would need new test hooks.The code-level test pins the broken semantics; the smoke test verifies the patched path is healthy under repeated use. Root cause 2 does have a clean public-API repro (the one @YuniqueUnic already posted in #214): create a >16 KiB blob → shutdown → delete the
.datafile → reopen →observe. With root cause 2 fixed (NotFound→NonExisting),observewould yield an empty bitfield instead of panicking, regardless of #214.Environment
iroh-blobsv0.100.0(also verified onmainat the time of filing — both code paths unchanged)ExportMode::TryReferencecache