diff --git a/CHANGELOG.md b/CHANGELOG.md index a876f85d..53d79558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it (`npm update -g @withautonomi/ant`). Self-replacing an npm-managed binary left npm's metadata describing a file that was no longer there, so the next `npm update` silently rolled the user back. +- A browser read asks a record's holders sooner. Its first hints are its bootstrap peers, which + rarely hold the record, and it used to ask them one at a time, each over a new connection, + while discovery had already reached a holder. Now a hint stops being read once lookup replies + list a close group of peers nearer the record. Only peers a reply proves, with an owner-signed + address record, and its authenticated responder count. A read that continues no other now + fetches the record after it once it has finished, so that fetch no longer competes with the + record the read waits for. In `ant-core`, a `BrowserNetwork` adapter reports listed peers + through the new `ReadProgress::listed`; `ReadProgress::may_hold` gives the rule. Native reads + are unchanged. (#242) ## [0.3.6] - 2026-09-01 diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 45da9813..e10cb608 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2050,6 +2050,21 @@ impl LookupQuery for BrowserNetworkLookupQuery { match result { Ok(nodes) => { if let Some(progress) = &read_progress { + // The responder's session authenticated it, and + // `find_node` verified every owner proof it + // returned. A peer listed without one is unproven. + progress.listed( + std::iter::once(responder) + .chain( + nodes + .iter() + .filter(|node| node.address_record.is_some()) + .filter_map(|node| { + parse_lookup_key(&node.peer_id, "peer ID").ok() + }), + ) + .map(PeerId::from_bytes), + ); let hints = std::iter::once(&candidate.wire) .chain(nodes.iter()) .filter(|node| { diff --git a/ant-core/src/browser/wasm_transport/read_ahead.rs b/ant-core/src/browser/wasm_transport/read_ahead.rs index 8a1192bb..994494dc 100644 --- a/ant-core/src/browser/wasm_transport/read_ahead.rs +++ b/ant-core/src/browser/wasm_transport/read_ahead.rs @@ -288,7 +288,10 @@ impl ReadAhead { /// sequential read also moves the read-ahead window to its start, so the /// records ahead are fetched alongside it. A read is sequential when it /// continues a recent read or the reader streams. Another read fetches only - /// the record after it, in case it starts a new stream. A streaming read from + /// the record after it, in case it starts a new stream, and only once the + /// read has finished: that record's discovery and transfer would otherwise + /// compete with the record the read waits for, such as the head of a video + /// a player is about to start. A streaming read from /// the start of the file also fetches the last record, where containers such /// as MP4 and WebM often keep the index a player reads next. A read that /// needs no records, such as one of zero length, changes nothing. @@ -302,6 +305,7 @@ impl ReadAhead { read: None, indices: HashMap::new(), addresses: Vec::new(), + next: None, }; } let read = self.next_id(); @@ -338,10 +342,11 @@ impl ReadAhead { break; } } + let mut next = None; if sequential { self.fill(); } else if needed.end < self.layout.infos().len() { - self.prefetch(needed.end); + next = Some(needed.end); } if self.streaming && start == 0 { if let Some(last) = self.layout.infos().len().checked_sub(1) { @@ -360,6 +365,7 @@ impl ReadAhead { read: Some(read), indices, addresses, + next, } } @@ -559,6 +565,8 @@ pub(super) struct ReadLease { /// Index of each record the read needs, by address. indices: HashMap<[u8; 32], usize>, addresses: Vec<[u8; 32]>, + /// The record after a read that continues no other, fetched once it ends. + next: Option, } impl ReadLease { @@ -591,5 +599,8 @@ impl Drop for ReadLease { self.read_ahead.pool.awaited.remove(&self.addresses); self.read_ahead.last_read.set(Instant::now()); } + if let Some(next) = self.next { + self.read_ahead.prefetch(next); + } } } diff --git a/ant-core/src/browser/wasm_transport/test_utils.rs b/ant-core/src/browser/wasm_transport/test_utils.rs index e4dda1a4..7b8eea24 100644 --- a/ant-core/src/browser/wasm_transport/test_utils.rs +++ b/ant-core/src/browser/wasm_transport/test_utils.rs @@ -8,6 +8,8 @@ use crate::data::client::adaptive::{AdaptiveConfig, AdaptiveController, ChannelS use crate::data::network::{BrowserNetwork, RequestDispatched}; use ant_protocol::chunk::{PointerGetResponse, PointerPutResponse}; use ant_protocol::pointer::Pointer; +use ant_protocol::transport::signed_address::SignedAddressRecord; +use ant_protocol::transport::{KnownReachability, NodeIdentity, TransportAddressRecord}; use self_encryption::{ChunkInfo, DataMap, XorName}; mod node_session; @@ -26,6 +28,10 @@ const EXCLUSIVE_GATHERS_FIRST: Duration = Duration::from_millis(10); const EXCLUSIVE_ABANDONED_AFTER: Duration = Duration::from_millis(50); /// Well past the abandonment, and far short of an ordinary admission timeout. const SPECULATIVE_ADMISSION_WAIT: Duration = Duration::from_millis(500); +/// Publication sequence of a mock node's owner proof; it never republishes. +const SIGNED_PEER_SEQUENCE: u64 = 1; +/// Reliability a lookup reply reports for a mock node. +const SIGNED_PEER_RELIABILITY: f64 = 1.0; /// Exercise bootstrap readiness after actual LRU pool eviction. #[wasm_bindgen] @@ -458,6 +464,32 @@ impl BrowserTestNode { pub fn endpoint(&self) -> String { self.endpoint.clone() } + /// This node as a lookup reply lists it, with an owner proof of its WebRTC + /// Direct endpoint signed with its own key. + pub fn signed_peer(&self) -> JsValue { + let identity = NodeIdentity::import( + &serde_json::from_value(serde_json::json!({ + "secret_key": self.secret.clone().into_bytes().to_vec(), + "public_key": self.public, + })) + .unwrap(), + ) + .unwrap(); + let address = self.endpoint.parse().unwrap(); + let record = TransportAddressRecord::from_multiaddr(&address, KnownReachability::Direct) + .unwrap() + .unwrap(); + let signed = + SignedAddressRecord::sign(&identity, SIGNED_PEER_SEQUENCE, vec![record]).unwrap(); + let node = shared::browser_record( + signed + .verify() + .unwrap() + .peer_record(SIGNED_PEER_RELIABILITY), + ) + .unwrap(); + serde_wasm_bindgen::to_value(&node).unwrap() + } pub fn last_method(&self) -> String { self.last_method.clone() } @@ -1106,6 +1138,12 @@ pub async fn test_shared_identity_and_timers() { ); } +/// The close group size the read's rules use, for tests that build one. +#[wasm_bindgen] +pub fn test_close_group_size() -> usize { + ant_protocol::CLOSE_GROUP_SIZE +} + /// Build a real, non-expiring owner proof for browser forwarding regressions. #[wasm_bindgen] pub fn test_signed_address_node() -> JsValue { diff --git a/ant-core/src/client_engine/read.rs b/ant-core/src/client_engine/read.rs index ed84becf..6833f0aa 100644 --- a/ant-core/src/client_engine/read.rs +++ b/ant-core/src/client_engine/read.rs @@ -157,11 +157,16 @@ fn settle( } /// Discovery converges on the target, so a hint with fewer than a close group of -/// attempted peers nearer the target may still hold the record. -fn may_hold(attempted: &HashSet<[u8; 32]>, candidate: &[u8; 32], target: &[u8; 32]) -> bool { +/// `known` peers nearer the target, such as peers already attempted, may still +/// hold the record. +pub(crate) fn may_hold<'a>( + known: impl IntoIterator, + candidate: &[u8; 32], + target: &[u8; 32], +) -> bool { let distance = ant_protocol::transport::xor_distance(candidate, target); - attempted - .iter() + known + .into_iter() .filter(|peer| ant_protocol::transport::xor_distance(peer, target) < distance) .count() < ant_protocol::CLOSE_GROUP_SIZE @@ -484,7 +489,7 @@ where .iter() .map(|(peer, _)| peer) .chain(refused_now.keys()) - .filter(|peer| !misses.contains(*peer) && may_hold(&misses, peer, &target)) + .filter(|peer| !misses.contains(*peer) && may_hold(misses.iter(), peer, &target)) .map(|peer| refused_now.get(peer).copied().unwrap_or_default()) .min(); (absent, refusal) diff --git a/ant-core/src/data/network.rs b/ant-core/src/data/network.rs index 8b58be7c..9698544d 100644 --- a/ant-core/src/data/network.rs +++ b/ant-core/src/data/network.rs @@ -7,7 +7,8 @@ pub use crate::client_engine::read::RequestDispatched; #[cfg(feature = "native")] use crate::data::error::Error; use crate::data::error::Result; -use ant_protocol::transport::{DHTNode, MultiAddr, PeerId, WitnessedCloseGroup}; +use ant_protocol::transport::{xor_distance, DHTNode, MultiAddr, PeerId, WitnessedCloseGroup}; +use ant_protocol::CLOSE_GROUP_SIZE; #[cfg(feature = "native")] use ant_protocol::{ transport::{CoreNodeConfig, IPDiversityConfig, NodeMode, P2PNode}, @@ -16,8 +17,7 @@ use ant_protocol::{ use serde::{Deserialize, Serialize}; #[cfg(feature = "native")] use std::net::SocketAddr; -#[cfg(feature = "native")] -use std::sync::Arc; +use std::sync::{Arc, Mutex}; /// Mirror of saorsa-core's private `AUTO_REBOOTSTRAP_THRESHOLD` /// (dht_network_manager.rs): the routing-table size below which the DHT @@ -97,11 +97,23 @@ pub type PeerAddresses = Vec<(PeerId, Vec)>; /// Bounded, latest-value hints for an immutable read in progress. Hints only /// start authenticated GETs; they never establish closeness, absence or payment /// authority. The receiver may cancel discovery after verifying content. +/// +/// Discovery also reports the peers its lookup replies list. A record is stored +/// by its close group, the `CLOSE_GROUP_SIZE` peers nearest it, so a peer with +/// that many listed peers nearer the record cannot hold it, and is dropped +/// from the hints: a browser's first hints are its bootstrap peers, which share +/// few or no leading bits with the record, and each GET to one costs a new +/// connection lane and a round trip before the read can ask a holder. +/// Transports list only peers whose identity they have verified, so a peer +/// cannot rule out holders with identities it invents. Listing only removes +/// hints: discovery, and the read after it, are unchanged. #[derive(Clone)] pub struct ReadProgress { target: [u8; 32], local: PeerId, sender: tokio::sync::watch::Sender, + /// The nearest listed peers, at most `CLOSE_GROUP_SIZE`, nearest first. + listed: Arc>>, } impl ReadProgress { @@ -114,11 +126,13 @@ impl ReadProgress { target, local, sender, + listed: Arc::default(), } } /// Offer authenticated discovery hints, retaining only the nearest bounded - /// set. Sending never waits for a slow read consumer. + /// set of those that may hold the record. Sending never waits for a slow + /// read consumer. pub fn offer(&self, peers: PeerAddresses) { if self.sender.is_closed() { return; @@ -134,12 +148,53 @@ impl ReadProgress { current.push(peer); } } - current.sort_by_key(|peer| { - ant_protocol::transport::xor_distance(peer.0.as_bytes(), &self.target) - }); + current.sort_by_key(|peer| xor_distance(peer.0.as_bytes(), &self.target)); current.truncate(crate::client_engine::read::MAX_GET_FALLBACK_PEERS); + self.retain_possible_holders(current); }); } + + /// Record peers a lookup reply listed, including its responder, and drop + /// the hints they show cannot hold the record. + pub fn listed(&self, peers: impl IntoIterator) { + { + let mut listed = self.listed.lock().unwrap_or_else(|e| e.into_inner()); + for peer in peers { + if peer != self.local && !listed.contains(&peer) { + listed.push(peer); + } + } + listed.sort_by_key(|peer| xor_distance(peer.as_bytes(), &self.target)); + listed.truncate(CLOSE_GROUP_SIZE); + } + if !self.sender.is_closed() { + self.sender + .send_if_modified(|current| self.retain_possible_holders(current)); + } + } + + /// Whether `peer` may hold the record: fewer than `CLOSE_GROUP_SIZE` peers + /// listed so far are nearer to it. + pub fn may_hold(&self, peer: &PeerId) -> bool { + let listed = self.listed.lock().unwrap_or_else(|e| e.into_inner()); + self.may_hold_given(&listed, peer) + } + + fn may_hold_given(&self, listed: &[PeerId], peer: &PeerId) -> bool { + crate::client_engine::read::may_hold( + listed.iter().map(PeerId::as_bytes), + peer.as_bytes(), + &self.target, + ) + } + + /// Drop the hints that cannot hold the record, and report whether any was. + fn retain_possible_holders(&self, hints: &mut PeerAddresses) -> bool { + let listed = self.listed.lock().unwrap_or_else(|e| e.into_inner()); + let before = hints.len(); + hints.retain(|peer| self.may_hold_given(&listed, &peer.0)); + hints.len() != before + } } /// Browser transport boundary for the shared client. Implementations perform @@ -704,6 +759,117 @@ pub(crate) async fn send_and_await_chunk_response PeerId { + let mut bytes = [0; 32]; + bytes[0] = first; + bytes[31] = tie; + PeerId::from_bytes(bytes) + } + + fn hint(peer: PeerId) -> (PeerId, Vec) { + let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, HINT_PORT)); + (peer, vec![MultiAddr::quic(address)]) + } + + /// A close group of listed peers, nearest first. + fn close_group() -> Vec { + (0..CLOSE_GROUP_SIZE as u8) + .map(|offset| peer(NEAREST_LISTED + offset, 0)) + .collect() + } + + fn read_progress() -> (ReadProgress, tokio::sync::watch::Receiver) { + let (sender, receiver) = tokio::sync::watch::channel(Vec::new()); + (ReadProgress::new(TARGET, peer(LOCAL, 0), sender), receiver) + } + + fn hinted(receiver: &tokio::sync::watch::Receiver) -> Vec { + receiver.borrow().iter().map(|(peer, _)| *peer).collect() + } + + #[test] + fn listed_peers_drop_hints_that_cannot_hold_the_record() { + let (progress, receiver) = read_progress(); + let within = peer(WITHIN_CLOSE_GROUP, 1); + let beyond = peer(BEYOND_CLOSE_GROUP, 0); + progress.offer(vec![hint(beyond), hint(within)]); + assert_eq!(hinted(&receiver), vec![within, beyond]); + + progress.listed(close_group()); + assert_eq!(hinted(&receiver), vec![within]); + + // Later offers are held to the same rule. + progress.offer(vec![hint(beyond)]); + assert_eq!(hinted(&receiver), vec![within]); + } + + #[test] + fn a_peer_may_hold_the_record_until_a_close_group_is_listed_nearer() { + let (progress, _receiver) = read_progress(); + let group = close_group(); + let beyond = peer(BEYOND_CLOSE_GROUP, 0); + assert!(progress.may_hold(&beyond)); + + progress.listed(group[..CLOSE_GROUP_SIZE - 1].iter().copied()); + assert!(progress.may_hold(&beyond)); + + progress.listed(group.iter().copied()); + assert!(!progress.may_hold(&beyond)); + assert!(progress.may_hold(&peer(WITHIN_CLOSE_GROUP, 1))); + // The farthest of the close group has only the rest of it nearer. + assert!(group.iter().all(|member| progress.may_hold(member))); + } + + #[test] + fn listing_ignores_the_local_peer_and_repeats() { + let (progress, _receiver) = read_progress(); + let farthest = close_group()[CLOSE_GROUP_SIZE - 1]; + let local = peer(LOCAL, 0); + progress.listed(std::iter::repeat_n(close_group()[0], CLOSE_GROUP_SIZE).chain([local])); + assert!(progress.may_hold(&farthest)); + } + + #[test] + fn listing_keeps_only_the_nearest_close_group() { + let (progress, _receiver) = read_progress(); + // The first peer past the close group has all of it listed nearer. + let past_group = peer(NEAREST_LISTED + CLOSE_GROUP_SIZE as u8, 0); + progress.listed(close_group()); + progress.listed((0..CLOSE_GROUP_SIZE as u8).map(|tie| peer(BEYOND_CLOSE_GROUP, tie))); + assert_eq!( + *progress.listed.lock().unwrap_or_else(|e| e.into_inner()), + close_group() + ); + assert!(!progress.may_hold(&past_group)); + } + + #[test] + fn a_listing_that_rules_out_no_hint_does_not_wake_the_read() { + let (progress, mut receiver) = read_progress(); + progress.offer(vec![hint(peer(WITHIN_CLOSE_GROUP, 1))]); + receiver.borrow_and_update(); + + progress.listed(close_group()); + assert!(!receiver.has_changed().unwrap()); + } + #[test] fn write_ready_false_with_no_peers() { let h = NetworkHealth::from_counts(0, 0); diff --git a/ant-core/wasm-tests/download.test.mjs b/ant-core/wasm-tests/download.test.mjs index 5290c880..c39a9ca2 100644 --- a/ant-core/wasm-tests/download.test.mjs +++ b/ant-core/wasm-tests/download.test.mjs @@ -1,7 +1,7 @@ import { BrowserNetworkClient } from "./client-fixture.mjs"; import assert from "node:assert/strict"; import test from "node:test"; -import { contentAddress, encryptPublicFile, parseWebRtcDirectMultiaddr, test_encode_public_data_map } from "./pkg/ant_core.js"; +import { BrowserTestNode, contentAddress, encryptPublicFile, parseWebRtcDirectMultiaddr, test_close_group_size, test_encode_public_data_map } from "./pkg/ant_core.js"; import { asChromium, mockWebRtc, paymentNetwork } from "./mock-webrtc.mjs"; const content = new TextEncoder().encode("Discoverable storage holder regression.".repeat(100)); @@ -162,6 +162,91 @@ test("cold closer hints cannot occupy the read slots before a connected holder", } finally { clearTimeout(deadline); reader?.close(); client.close(); } }); +// The close group the read's rule counts, and the bootstrap nodes a client +// connects to at most. +const closeGroupSize = test_close_group_size(); +const bootstrapNodes = 4; +// How long a far bootstrap node takes to answer a GET for a record it lacks, +// and how long the close group takes to connect. +const farMissMs = 100; +const groupConnectMs = 600; +// A lookup node this slow holds discovery past the deadline a read must beat. +const slowLookupMs = 3000; +const readDeadlineMs = 1500; +// An address for identities that no node behind them could answer at. +const inventedAddress = "/ip4/10.0.0.1/udp/9000/quic"; + +// The mock node at `index` as a lookup reply lists it, with its owner proof. +function signedPeer(index) { + const node = new BrowserTestNode(index + 1, false); + try { return node.signed_peer(); } finally { node.free(); } +} + +test("a read stops asking hints once a lookup lists a close group nearer the record", async () => { + // The read's first hints are its bootstrap nodes, the farthest from the + // record. Their signed lookup replies list the record's close group, which + // takes a while to connect, and whose farthest member holds the record. A + // hint with a whole close group listed nearer cannot hold the record, so the + // read asks no bootstrap node after those replies and waits for the group. + const nodes = Array.from({ length: 16 }, () => ({})); + const rtc = mockWebRtc(nodes); + const target = BigInt(`0x${datamap.address}`); + const ordered = rtc.endpoints.map((endpoint, index) => ({ index, + distance: BigInt(`0x${parseWebRtcDirectMultiaddr(endpoint).peerId}`) ^ target, + })).sort((a, b) => a.distance < b.distance ? -1 : 1).map(({ index }) => index); + const group = ordered.slice(0, closeGroupSize); + const far = ordered.slice(-bootstrapNodes); + const holder = group.at(-1); + nodes[holder].chunk = datamap.content; + for (const index of group) nodes[index].connectDelay = groupConnectMs; + for (const index of far) { + Object.assign(nodes[index], { addressV2: true, peers: group.map(signedPeer) }); + nodes[index].delay = method => method === "get_chunk" ? farMissMs : 0; + } + const client = new BrowserNetworkClient(far.map(index => rtc.endpoints[index])); + let reader; + try { + reader = await client.openPublicFile(datamap.address); + assert.equal(reader.size, content.length); + const gets = rtc.requests.filter(r => r.method === "get_chunk"); + assert.ok(gets.some(r => r.node === holder)); + const farGets = gets.filter(r => far.includes(r.node)).length; + assert.ok(farGets <= 1, `the read asked ${farGets} far hints`); + } finally { reader?.close(); client.close(); } +}); + +test("peers a lookup reply lists without an owner proof do not rule out a holder", async () => { + // A bootstrap node without signed addresses lists a close group of + // identities nearer the record than its holder. Unproven, they cannot rule + // the holder out, so the read asks it once the lookup reaches it, instead of + // waiting for a slow node to end the lookup. + const [bootstrap, holder, slow] = [0, 1, 2]; + const nodes = [{}, {}, {}]; + const rtc = mockWebRtc(nodes); + const listing = index => ({ + peer_id: parseWebRtcDirectMultiaddr(rtc.endpoints[index]).peerId, + native_addresses: [], reliability: 1, webrtc_direct: { multiaddr: rtc.endpoints[index] }, + }); + const target = BigInt(`0x${datamap.address}`); + const invented = Array.from({ length: closeGroupSize }, (_, offset) => ({ + peer_id: (target ^ BigInt(offset + 1)).toString(16).padStart(64, "0"), + native_addresses: [inventedAddress], reliability: 1, + })); + nodes[bootstrap].peers = [...invented, listing(holder), listing(slow)]; + nodes[holder].chunk = datamap.content; + nodes[slow].delay = method => method === "find_node" ? slowLookupMs : 0; + const client = new BrowserNetworkClient([rtc.endpoints[bootstrap]]); + let reader, deadline; + try { + reader = await Promise.race([ + client.openPublicFile(datamap.address), + new Promise((_, reject) => deadline = setTimeout(() => reject(new Error("unproven peers ruled out the holder")), readDeadlineMs)), + ]); + assert.equal(reader.size, content.length); + assert.ok(rtc.requests.some(r => r.node === holder && r.method === "get_chunk")); + } finally { clearTimeout(deadline); reader?.close(); client.close(); } +}); + test("shared Client rejects corrupt content immediately, matching native GET", async () => { const rtc = mockWebRtc(Array.from({ length: 24 }, () => ({ chunk: new Uint8Array([1, 2, 3]), respond: failDiscovery }))); const client = new BrowserNetworkClient(rtc.endpoints); @@ -348,6 +433,29 @@ test("an ordinary reader reads ahead only once a read continues another", async } finally { reader?.close(); client.close(); } }); +// The first record's GET takes this long, and timers may fire this much early. +const firstRecordMs = 400; +const timerSlackMs = 20; + +test("a read that continues no other fetches the record after it only once it has finished", async () => { + // The record after a header read is fetched in case the read starts a + // stream. Fetched alongside, its GET would compete with the one the read, + // such as a player's first, waits for. + const { original, encrypted } = recordFile(3); + const rtc = serve(encrypted, new Set(), new Map([[0, firstRecordMs]])); + const client = new BrowserNetworkClient(rtc.endpoints); + let reader; + try { + reader = await client.openPublicFile(encrypted.address); + assert.deepEqual(await reader.readRange(0, 10), original.slice(0, 10)); + await settled(rtc); + const sent = index => rtc.requests.find(r => r.method === "get_chunk" && r.address === encrypted.chunks[index].dst_hash); + assert.ok(sent(1), "the record after the read was not fetched"); + assert.ok(sent(1).at - sent(0).at >= firstRecordMs - timerSlackMs, + `the next record was fetched ${Math.round(sent(1).at - sent(0).at)} ms after the read's record`); + } finally { reader?.close(); client.close(); } +}); + test("a zero-length read is not a read that a later one continues", async () => { const { original, encrypted } = large(); const rtc = serve(encrypted); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 857b0a10..94054fdc 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-10-13 +- **Last amended:** 2026-10-28 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -826,3 +826,105 @@ median instead of 1.9-2.7 s, a failed one 3.0 s instead of 10.4-11.0 s, and a lookup round 1.2 s instead of 1.6-3.8 s. Failing dials held 14 pool slots on average, and the first data read started at 7-8 s instead of 13.5-14.3 s. The lookup width, its grace period, the pool size and the dial-failure cooldown are unchanged. + +## First record of a read (2026-10-28) + +Related: [V2-1769](https://linear.app/autonominetwork/issue/V2-1769); the +node-side DTLS delay is +[saorsa-transport#229](https://github.com/WithAutonomi/saorsa-transport/issues/229). + +A stream's first frame waits for its first record. This was traced on mainnet +with a test-utils build of `7a432e7`, over 15 rounds in Chromium. A warm client +read the first 1 MiB of a 54.9 MB video 3 s after connecting. The GET to the +record's holder started a median 4.1 s after the read began: + +- A lookup reply first named the holder at 1.1 s. +- The read's early GETs went first to hints that could not hold the record. + Its first hints are its bootstrap peers, which share 0-2 leading bits with + the record. A median 6 GETs ran one at a time before the holder's, and each + waited 0.46 s for a new connection lane before its NotFound. +- In 15 of 15 rounds, the preconnect slots were full when the holder was named. + The peers in them shared a median 1 bit with the record, and half were dead + endpoints. A third had come from the lookup for the record after this one, + which a header read started at the same time. A skipped node is not queued, + so the holder waited for the lookup's next round or another reply. + +The decisions: + +- **Peers that cannot hold the record are not read early.** + `ReadProgress::listed` records the peers each lookup reply lists, with its + responder. A record is stored by its close group, so a peer with + `CLOSE_GROUP_SIZE` listed peers nearer the record cannot hold it, and + `ReadProgress::may_hold` is false for it. Such a peer leaves the read's hints. + A holder is in the close group by definition, so the rule never excludes one, + whether or not the peers nearer it are reachable. + - Only proven identities are listed: the responder, which its session + authenticated, and the peers whose owner-signed address records `find_node` + verified. A peer a reply lists without a record proves nothing, so it does + not count. + - Listing only removes hints from early reads. Discovery, its preconnects, + the read after it, and that read's absence and closeness decisions are + unchanged. A responder with signed records for invented identities near the + record can therefore at most suppress early reads, leaving the read to + finish after discovery. + - Native reads never list peers, so their hints are unchanged. +- **A read that continues no other fetches the record after it only once it + has finished.** It used to start that fetch at once, in case the read starts + a stream, so its lookup and transfer competed with the record the read waits + for. Reads that continue a stream are unchanged. + +This was measured against main on mainnet, from one macOS machine, on +2026-10-28, with the arms in rotating order and a fresh browser context per +sample. The traced read of the first 1 MiB, at the median: + +| engine | rounds | holder's GET, main → this | 1 MiB read, main → this | sooner | +| --- | --: | --: | --: | --: | +| Chromium | 10 | 4.3 → 1.9 s | 5.0 → 3.4 s | 10/10 | +| WebKit (Playwright) | 6 | 4.4 → 2.2 s | 5.1 → 3.0 s | 5/6 | + +GETs before the holder's fell from 6 to 1 in Chromium, and from 7 to 1 in +WebKit. Once the read knew the holder, it asked it at once. + +The same video was then streamed on try.autonomi.com's own page. Two copies of +the page differed only in the core, main's and this change's: +- each sample clicked Stream 3 s after the page's client connected; +- it was timed to the first decoded frame; +- the video played for ten seconds, then was sought to 85%, beyond the + read-ahead window. + +| engine | rounds | first frame, median | sooner | seek to 85%, median | sooner | +| --- | --: | --: | --: | --: | --: | +| Chromium | 20 | 5.8 → 3.1 s | 16/20 | 5.1 → 4.1 s | 14/20 | +| WebKit (Playwright) | 6 | 4.0 → 3.1 s | 5/6 | 2.9 → 2.8 s | 3/6 | + +- In Chromium, the median paired saving was 1.6 s for the first frame and + 1.0 s for the seek. +- First frames over 6 s fell from 9 of 20 to 3. +- The WebKit seek was level, at a median paired difference of 0.4 s later. +- All 52 samples played, and no page reported an error. + +**Cost: first reads concentrate on the first holder found.** In the Chromium +traces, three holders served main's first reads (5, 4 and 1 of 10); with this +change one served 9 of 10. In WebKit it served all 6, against 5 of 6. Early +GETs still choose uniformly among the hints that may hold the record, but the +first such hint is now asked at once. A holder that is full declines new +sessions and is skipped for 15 s (ant-node ADR-0023). + +Unchanged: the lookup's width and grace period, its preconnects, the hedge +delay, and the read after discovery. + +Left for later: + +- **DTLS:** it took 1.39 s at the median after ICE connected (p90 3.5 s), in + both engines, and its times cluster at the browsers' DTLS resend timers. The + node's ICE-lite agent starts DTLS only once the browser nominates the + connection, which the browser does late; that is V2-1770 + (saorsa-transport#229). +- **Preconnect slots:** they are still first come, first served, and a node + that finds them full is skipped, not queued. Ordering pending preconnects by + distance to the target would help every lookup, not only reads. +- **Lookup answers:** each took about five of its node's round trips. It + carries 20 owner-signed records, whose ML-DSA-65 keys and signatures alone + come to over 100 KB. +- **Lookup rounds:** each round waits for its slowest answer, up to its grace + period.