Uh oh!
There was an error while loading. Please reload this page.
fix(read): never self-dial on the fetch path; self-exclude the download locator (#836, #92) - #96
Conversation
…ad locator (#836, #92) The #1590 port fix let the pool locator offer the connected holder at :9444, but the resource FETCH still dialed the reader's OWN address instead of the holder, so no fetchRange ever reached the holder and DATA 404'd (run e2e-836-arb-20260725-084501). Root cause (one root, two symptoms): self-exclusion was enforced only on the DISCOVERY leg (#1584 — the gossip pool + the raw dig-dht locator), but the DOWNLOAD locator is a separate set that UNIONs the discovery locator with a PoolProviderLocator over the connected pool, and that pool branch was NOT self-excluded. A relay-introduced self-connection surfaces this node in its own gossip pool (peer_id == local); the un-excluded pool locator then offered SELF as a fetch candidate, so the confirm round dialed self (Direct -> own IP -> connection refused; Relayed -> refused self-dial), never completed against the real holder, and dig-download reported "no providers located" for the resource -> read fell through to the 400 upstream -> 404. Both the self-dial and the "no providers located" share this one root. Fix (two defenses): - on_pool_event drops a self PeerAdded at the pool feed, so self never enters the connected pool OR the selector registry. - the whole download locator is wrapped in SelfExcludingLocator, so no source (DHT or pool) can offer self on the fetch/dial path — mirroring #1584 on the fetch leg. Regression test uses a target-RECORDING transport (records every dial target for availability + fetch) so a self-dial is caught even though a target-blind mock would pass: with self in the connected pool, the fetch reaches the holder and NEVER dials self. Also a pool-feed test that a self PeerAdded is dropped. Both fail on the pre-fix code for the right reason. verify-then-decrypt fail-closed unchanged. Resolves dig-node#92 (the reflexive self-candidate leaking into the dial set). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaxTyBhLj2Ry1eVQBLLotN
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Independent correctness review (fresh context) — PASS.
Verified the two defenses and the tests against the branch tip:
- Defense 1 (
on_pool_event, download.rs:660-665): a selfPeerAdded(self.self_peer_id == peer_id.to_hex()) returns EARLY, before bothself.selector.on_pool_eventand the connected-pool insertion. So self never enters the selector registry OR the download-side connected pool thePoolProviderLocatorreads.PeerRemovedis unaffected (removing self is a harmless no-op). Onlypeer_id == localis dropped; a distinct peer (holder [1]) is preserved — asserted byon_pool_event_drops_a_self_peer_added. - Defense 2 (
NodeContent::new, download.rs:549-555): the WHOLEdownload_locatorunion (discoverylocator∪PoolProviderLocator) is wrapped inSelfExcludingLocator(.., self_peer_id), which drops any record whoseprovider_peer_id == me(hex). Mirrors the #1584 discovery-leg exclusion on the fetch path; idempotent over the already-wrapped discoverylocator.Noneidentity = transparent pass-through, so no legitimate peer is excluded. - Tests non-vacuous + target-asserting:
TargetRecordingTransportrecords every dial target (availability + fetch), closing the target-blind-mock gap that hid the bug — a self-dial is caught even though the inner mock serves bytes regardless of target.mock_peer_hex(9) == PeerId::from_bytes([9;32]).to_hex(), so the test'sself_idmatches the injected selfPeerAdded;mock_peer_hex(1)is the holder. Both tests are RED on pre-fix code (self enters pool + is dialed / registry non-empty) and GREEN after. - verify-then-decrypt untouched; patch bump correct (0.58.1→0.58.2, core 0.18.0→0.18.1, Cargo.lock consistent); SPEC + DEVELOPMENT_LOG updated in-unit; readable-code holds.
Non-gating observation (resolved, no action required): fetch_never_dials_self_and_reaches_the_connected_holder exercises Defense 1 directly, but because Defense 1 removes self before it reaches the pool the PoolProviderLocator reads, Defense 2's wrap is not hit at the composition level in that test. Defense 2's SelfExcludingLocator is independently unit-tested in self_excluding_locator.rs, so coverage of the wrap itself is adequate — belt-and-suspenders is acceptable.
Correctness verdict: PASS. (Merge still gated by the orchestrator on Test+coverage / build checks currently pending.)
Uh oh!
There was an error while loading. Please reload this page.
…(#836) (#100) * fix(read): bypass getAvailability confirm for connected-pool holders (#836) The #836 read-leg DATA miss survives the #97 address fix at the next layer: dig-download's `locate_and_confirm` keeps ONLY providers whose `dig.getAvailability` answer is `available`, dropping every other provider BEFORE any `dig.fetchRange`. On a relayed/isolated net a holder the reader is already CONNECTED to (offered by `PoolProviderLocator`) can answer availability=not-available as a FALSE NEGATIVE (cache-inventory lag, a resource-vs-capsule granularity quirk, or a transient probe failure), so the confirm drops it -> `providers.is_empty()` -> `DownloadError::NotFound` ("no providers located for ContentId::Resource {...}") -> ZERO fetchRange -> §21 upstream backfill -> 404. This is the exact e2e symptom: the availability probe dials :9444, then no fetchRange. Ground truth (ends the "key mismatch" misdiagnosis): the logged `ea12da62` IS the RESOURCE key (`ContentId::to_key()` hashes store+root+rk), and `befbbaf9` is that resource's raw retrieval_key -- same resource, no key mismatch. The locate chain (CapsuleFallback bridge + pool union + self-exclusion) resolves the holder in-process; the drop is downstream in the CONFIRM. Fix: `PoolConfirmTransport` wraps the range transport and short-circuits `query_availability` to available=true for any provider whose peer_id is in the connected pool -- a live, connection-verified holder is confirmed by the connection itself, and the whole-resource merkle verify (not the self-reported availability flag) is the real integrity gate. A DHT-only provider still goes through the real confirm; a connected non-holder fails its ranges and is dropped there (bounded, safe). Preserves #96/#97/#1584/#1580 + verify-then-decrypt fail-closed; the discovery/redirect leg is untouched. Faithful regression test `connected_pool_holder_is_fetched_even_when_it_answers_availability_false`: a connected, reachable holder that answers availability=false but WOULD serve bytes -- RED pre-fix (no fetchRange, "no providers located"), GREEN post-fix. Every prior read-leg mock answered availability=true, so none could reproduce the confirm-gate drop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SaxTyBhLj2Ry1eVQBLLotN * style(read): cargo fmt PoolConfirmTransport construction (#836) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Root cause
The #1590 port fix (v0.58.1, 8a6be4d) let the pool locator correctly offer the connected holder at
:9444, but the resource FETCH still dialed the reader's OWN address instead of the holder — so nofetchRangeever reached the holder and DATA 404'd (rune2e-836-arb-20260725-084501, reader 172.31.66.104 / holder 172.31.69.169).One root, two symptoms. Self-exclusion was enforced only on the DISCOVERY leg (#1584 — the gossip pool + the raw dig-dht locator). The DOWNLOAD locator is a SEPARATE candidate set:
NodeContent::newUNIONs the discovery locator with aPoolProviderLocatorover the connected pool, and that pool branch was never self-excluded. A relay-introduced self-connection surfaces this node in its own gossip pool (peer_id == local); the pool feed mirrors it into the connected-pool map; the un-excludedPoolProviderLocatorthen offers SELF as a fetch candidate.Result: the confirm round dials self (
Direct -> own IP -> Connection refused;Relayed -> refusing relayed self-dial (target == local peer_id)), never completes against the reachable holder, and dig-download reportsno providers locatedfor theContentId::Resource-> read falls through to the §21 upstream (400) -> 404. The self-dial (log L118/L120) and theno providers located(L129) are the SAME root — the self entry poisons the confirm.crates/dig-node-core/src/download.rs—NodeContent::newdownload_locator(thePoolProviderLocatorbranch was outsideSelfExcludingLocator).dig-node#92 (the reflexive self-candidate) is RESOLVED by this fix — the leaked self candidate is now dropped both at the feed and at the locator.
Fix (two defenses)
on_pool_eventdrops a selfPeerAddedat the pool feed — self never enters the connected pool OR the selector registry.SelfExcludingLocator, so no source (DHT or pool) can offer self on the fetch/dial path (mirrors #1584 on the fetch leg).verify-then-decrypt fail-closed unchanged.
Test (reproduce-first, target-asserting)
fetch_never_dials_self_and_reaches_the_connected_holderuses aTargetRecordingTransportthat records every dial target (availability + fetch) — closing the gap that hid the bug for nine iterations (a target-blind mock serves bytes regardless of target and falsely passes). With self in the connected pool, the fetch reaches the holder and NEVER dials self. Pluson_pool_event_drops_a_self_peer_added.Verified RED on pre-fix code for the right reason:
the reader must NEVER dial itself on the fetch path(download.rs:1522)a self entry must never enter the connected pool(download.rs:1550)GREEN after fix; full
download::suite 21 passed;cargo clippy -p dig-node-core --all-targets -D warningsclean;cargo fmtapplied.Blast radius
gitnexus/socraticode are disabled (§2.0 override) — radius via ripgrep + read. Edited symbols:
NodeContent::new(download_locator composition) andNodeContent::on_pool_event. Both are internal todig-node-core;on_pool_eventcallers (spawn_selector_registry_feed, tests) are behaviour-preserving for non-self peers (only a selfPeerAdded— which should never have been a source — is now dropped). No public API/wire/format change. Version: workspace0.58.1 -> 0.58.2(patch),dig-node-core 0.18.0 -> 0.18.1.Resume
Re-run
runlegs-read-arbiter.shon cached8a6be4db+ this fix -> expect afetchRangeto reach the holder:9444->DATA_MERKLE_VERIFIED.Closes-partial: #836 read-leg DATA blocker. Resolves#92. Does NOT close #836/#1586/#1572/#1062/#1590.
🤖 Generated with Claude Code