From b910db8bf99f129a9f1b80cd5eb9aea0b10ee969 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:32:06 +0100 Subject: [PATCH 1/4] perf(browser): read a record from peers that may hold it, and fetch the next record after the read A browser read's first hints are its bootstrap peers, which share few or no leading bits with the record. Its early GETs walked them one at a time, each paying a new connection lane and a round trip, while a holder that discovery had already authenticated waited: 1.25-1.77 s at the median on mainnet. Lookup replies also named peers that cannot hold the record, and their preconnects held the slots a holder needed. - ReadProgress records the peers each lookup reply lists. A peer with a whole close group listed nearer the record cannot hold it: it is dropped from the read's hints, and the read's preconnects skip it. A holder is in the close group by definition, so the rule never excludes one, reachable or not. - A read that continues no other fetches the record after it only once it has finished, so that record's lookup and transfer no longer compete with the one the read waits for, such as a video's head. Co-Authored-By: Claude Opus 5.5 --- ant-core/src/browser/wasm_transport.rs | 16 ++ .../src/browser/wasm_transport/read_ahead.rs | 15 +- ant-core/src/data/network.rs | 153 +++++++++++++++++- ant-core/wasm-tests/download.test.mjs | 65 ++++++++ 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 45da9813..57153ca5 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -575,12 +575,21 @@ impl BrowserClientPool { /// Overlap ICE/PQ/HELLO with the iterative walk, using only owner-signed /// addresses that `find_node` has already verified. Publish a read hint /// after authentication; these are not lookup votes or storage receipts. + /// A read's preconnects skip nodes its listed peers show cannot hold the + /// record, so they do not hold the preconnect slots a holder needs. fn preconnect( self: &Rc, nodes: &[BrowserNode], read_progress: Option, ) { for node in nodes { + let ruled_out = read_progress.as_ref().is_some_and(|progress| { + parse_lookup_key(&node.peer_id, "peer ID") + .is_ok_and(|key| !progress.may_hold(&PeerId::from_bytes(key))) + }); + if ruled_out { + continue; + } let at_capacity = { let clients = self.clients.borrow(); let pending = self.preconnecting.borrow(); @@ -2050,6 +2059,13 @@ impl LookupQuery for BrowserNetworkLookupQuery { match result { Ok(nodes) => { if let Some(progress) = &read_progress { + progress.listed( + std::iter::once(responder) + .chain(nodes.iter().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/data/network.rs b/ant-core/src/data/network.rs index 8b58be7c..4b3b5156 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,22 @@ 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, authenticated or +/// not. 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. Such peers are dropped from the hints, and transports need not +/// connect to them for the read: 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. #[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 +125,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 +147,47 @@ 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_modify(|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 distance = xor_distance(peer.as_bytes(), &self.target); + self.listed + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|listed| xor_distance(listed.as_bytes(), &self.target) < distance) + .count() + < CLOSE_GROUP_SIZE + } + + fn retain_possible_holders(&self, hints: &mut PeerAddresses) { + hints.retain(|peer| self.may_hold(&peer.0)); + } } /// Browser transport boundary for the shared client. Implementations perform @@ -704,6 +752,97 @@ 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_repeats_and_farther_peers() { + let (progress, _receiver) = read_progress(); + let group = close_group(); + let farthest = group[CLOSE_GROUP_SIZE - 1]; + let local = peer(LOCAL, 0); + progress.listed(std::iter::repeat_n(group[0], CLOSE_GROUP_SIZE).chain([local])); + assert!(progress.may_hold(&farthest)); + + progress.listed(group.iter().copied()); + progress.listed((0..CLOSE_GROUP_SIZE as u8).map(|tie| peer(BEYOND_CLOSE_GROUP, tie))); + assert!(progress.may_hold(&farthest)); + } + #[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..fbae7637 100644 --- a/ant-core/wasm-tests/download.test.mjs +++ b/ant-core/wasm-tests/download.test.mjs @@ -162,6 +162,48 @@ test("cold closer hints cannot occupy the read slots before a connected holder", } finally { clearTimeout(deadline); reader?.close(); client.close(); } }); +// A record's close group, which stores it, and the bootstrap nodes a client +// connects to at most. +const closeGroupSize = 7; +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; + +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 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 close 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) { + nodes[index].view = group; + 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("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 +390,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); From ea1175aaf0922ea73c285f7a5f52d09a2682ea92 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:55:57 +0100 Subject: [PATCH 2/4] docs(adr): record the first record of a browser read in ADR-0004 Where a warm browser read's first record waited on mainnet, the two changes, their measurements against main, the cost to holder spreading, and what is left: the node-side DTLS delay (saorsa-transport#229), large lookup answers and round gating. Co-Authored-By: Claude Opus 5.5 --- .../ADR-0004-direct-browser-read-client.md | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 857b0a10..e7afc911 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,94 @@ 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 or preconnected.** + `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, + and the read's preconnects skip it. A holder is in the close group by + definition, so the rule never excludes one, whether or not the peers nearer + it are reachable. + - Listing only removes candidates from early reads. The read after discovery, + and its absence and closeness decisions, are unchanged. + - A responder that lists invented identities near the record can 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, 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 | 3.7 → 1.6 s | 4.8 → 2.6 s | 9/10 | +| WebKit (Playwright) | 6 | 6.3 → 3.0 s | 7.4 → 3.8 s | 6/6 | + +GETs before the holder's fell from 6 to 1 in Chromium, and from 8 to 2 in +WebKit. The time from the read knowing the holder to asking it fell from 0.55 +s to 0 in Chromium, and from 1.66 s to 0 in WebKit. + +Through the SDK's `createMediaSource()`, as released in 0.1.4 and with this +change's bindings, the same video was timed to its first decoded frame. It then +played for ten seconds and was sought to 85%, beyond the read-ahead window: + +| engine | rounds | first frame, median | sooner | seek to 85%, median | sooner | +| --- | --: | --: | --: | --: | --: | +| Chromium | 20 | 4.4 → 3.0 s | 13/20 | 4.9 → 4.4 s | 12/20 | +| WebKit (Playwright) | 6 | 4.8 → 3.8 s | 4/5 | 5.3 → 2.9 s | 4/5 | + +In Chromium the median paired saving was 0.7 s for the first frame and 0.5 s +for the seek. Seeks over 6 s fell from 5 of 20 to 3. One baseline WebKit sample +failed: its seek never produced a frame. The SDK reads the file's tail beside +its head, and that read competes with the head's. + +**Cost: first reads concentrate on the first holder found.** In the WebKit +traces on main, two holders each served three of the six first reads. With +this change, one served all six; in Chromium it served 10 of 10, against 9 of +10. 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, the preconnect bound, 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 creates its DTLS endpoint only once its own ICE has completed, and drops + an earlier ClientHello; that is saorsa-transport#229. +- **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. From 3e9b2bcd63a18be11a0a39774d6afff6d60580d6 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:57:42 +0100 Subject: [PATCH 3/4] fix(browser): count only proven peers toward the read's rule, and keep preconnects Review fixes for the first-record read: - A lookup reply's peers count toward `ReadProgress::listed` only when they are proven: the responder, which its session authenticated, and peers whose owner-signed address records `find_node` verified. A responder without signed addresses could otherwise list invented identities near the record and rule out its holders. - Preconnects are no longer gated by the read's rule, so a responder that lists signed records for invented identities can at most suppress early reads; discovery and its preconnects are unchanged. - `ReadProgress` reuses the read engine's `may_hold`, takes its lock once per pruning, and notifies the read only when a listing removes a hint. - Tests: signed lookup replies from the mock (`BrowserTestNode::signed_peer`), a regression test for unproven listings, the close group size from Rust (`test_close_group_size`), a test that the listing stays bounded, and a test that a listing that rules out nothing does not wake the read. - CHANGELOG entry, and ADR-0004's decision updated to match. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ ant-core/src/browser/wasm_transport.rs | 23 +++--- .../src/browser/wasm_transport/test_utils.rs | 38 +++++++++ ant-core/src/client_engine/read.rs | 15 ++-- ant-core/src/data/network.rs | 79 +++++++++++++------ ant-core/wasm-tests/download.test.mjs | 59 ++++++++++++-- .../ADR-0004-direct-browser-read-client.md | 31 +++++--- 7 files changed, 191 insertions(+), 63 deletions(-) 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 57153ca5..e10cb608 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -575,21 +575,12 @@ impl BrowserClientPool { /// Overlap ICE/PQ/HELLO with the iterative walk, using only owner-signed /// addresses that `find_node` has already verified. Publish a read hint /// after authentication; these are not lookup votes or storage receipts. - /// A read's preconnects skip nodes its listed peers show cannot hold the - /// record, so they do not hold the preconnect slots a holder needs. fn preconnect( self: &Rc, nodes: &[BrowserNode], read_progress: Option, ) { for node in nodes { - let ruled_out = read_progress.as_ref().is_some_and(|progress| { - parse_lookup_key(&node.peer_id, "peer ID") - .is_ok_and(|key| !progress.may_hold(&PeerId::from_bytes(key))) - }); - if ruled_out { - continue; - } let at_capacity = { let clients = self.clients.borrow(); let pending = self.preconnecting.borrow(); @@ -2059,11 +2050,19 @@ 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_map(|node| { - parse_lookup_key(&node.peer_id, "peer ID").ok() - })) + .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) 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 4b3b5156..9698544d 100644 --- a/ant-core/src/data/network.rs +++ b/ant-core/src/data/network.rs @@ -98,14 +98,15 @@ pub type PeerAddresses = Vec<(PeerId, Vec)>; /// 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, authenticated or -/// not. 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. Such peers are dropped from the hints, and transports need not -/// connect to them for the read: 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. +/// 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], @@ -168,25 +169,31 @@ impl ReadProgress { } if !self.sender.is_closed() { self.sender - .send_modify(|current| self.retain_possible_holders(current)); + .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 distance = xor_distance(peer.as_bytes(), &self.target); - self.listed - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .filter(|listed| xor_distance(listed.as_bytes(), &self.target) < distance) - .count() - < CLOSE_GROUP_SIZE + let listed = self.listed.lock().unwrap_or_else(|e| e.into_inner()); + self.may_hold_given(&listed, peer) } - fn retain_possible_holders(&self, hints: &mut PeerAddresses) { - hints.retain(|peer| self.may_hold(&peer.0)); + 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 } } @@ -759,7 +766,8 @@ mod tests { const LOCAL: u8 = 0x01; /// First byte of the nearest listed peer; a close group follows it. const NEAREST_LISTED: u8 = 0x10; - /// First byte of a peer with only two listed peers nearer the record. + /// First byte of a peer inside the listed close group: with a nonzero tie + /// it has three listed peers nearer the record. const WITHIN_CLOSE_GROUP: u8 = NEAREST_LISTED + 2; /// First byte of a peer with a whole close group listed nearer the record. const BEYOND_CLOSE_GROUP: u8 = 0xf0; @@ -830,17 +838,36 @@ mod tests { } #[test] - fn listing_ignores_the_local_peer_repeats_and_farther_peers() { + fn listing_ignores_the_local_peer_and_repeats() { let (progress, _receiver) = read_progress(); - let group = close_group(); - let farthest = group[CLOSE_GROUP_SIZE - 1]; + let farthest = close_group()[CLOSE_GROUP_SIZE - 1]; let local = peer(LOCAL, 0); - progress.listed(std::iter::repeat_n(group[0], CLOSE_GROUP_SIZE).chain([local])); + progress.listed(std::iter::repeat_n(close_group()[0], CLOSE_GROUP_SIZE).chain([local])); assert!(progress.may_hold(&farthest)); + } - progress.listed(group.iter().copied()); + #[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!(progress.may_hold(&farthest)); + 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] diff --git a/ant-core/wasm-tests/download.test.mjs b/ant-core/wasm-tests/download.test.mjs index fbae7637..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,21 +162,32 @@ test("cold closer hints cannot occupy the read slots before a connected holder", } finally { clearTimeout(deadline); reader?.close(); client.close(); } }); -// A record's close group, which stores it, and the bootstrap nodes a client +// The close group the read's rule counts, and the bootstrap nodes a client // connects to at most. -const closeGroupSize = 7; +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 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 close group. + // 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}`); @@ -189,7 +200,7 @@ test("a read stops asking hints once a lookup lists a close group nearer the rec nodes[holder].chunk = datamap.content; for (const index of group) nodes[index].connectDelay = groupConnectMs; for (const index of far) { - nodes[index].view = group; + 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])); @@ -204,6 +215,38 @@ test("a read stops asking hints once a lookup lists a close group nearer the rec } 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); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index e7afc911..a29c2100 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -851,18 +851,22 @@ record's holder started a median 4.1 s after the read began: The decisions: -- **Peers that cannot hold the record are not read or preconnected.** +- **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, - and the read's preconnects skip it. A holder is in the close group by - definition, so the rule never excludes one, whether or not the peers nearer - it are reachable. - - Listing only removes candidates from early reads. The read after discovery, - and its absence and closeness decisions, are unchanged. - - A responder that lists invented identities near the record can at most - suppress early reads, leaving the read to finish after discovery. + `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 @@ -903,15 +907,18 @@ this change, one served all six; in Chromium it served 10 of 10, against 9 of 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, the preconnect bound, the -hedge delay, and the read after discovery. +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 creates its DTLS endpoint only once its own ICE has completed, and drops - an earlier ClientHello; that is saorsa-transport#229. + an earlier ClientHello; that is saorsa-transport#229 (V2-1770). +- **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. From f70c39ff08d3caa1792434d7058d32b7bf5521f2 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:28:19 +0100 Subject: [PATCH 4/4] docs(adr): re-measure the first record of a browser read after the review fixes The traced read and try.autonomi.com's page, both against main on 3e9b2bc, replace the earlier revision's measurements. The DTLS note now gives the cause V2-1770 found: the node's ICE-lite agent starts DTLS only once the browser nominates the connection. Co-Authored-By: Claude Opus 5.5 --- .../ADR-0004-direct-browser-read-client.md | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index a29c2100..94054fdc 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -873,39 +873,42 @@ The decisions: 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, with the -arms in rotating order and a fresh browser context per sample. The traced read -of the first 1 MiB, at the median: +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 | 3.7 → 1.6 s | 4.8 → 2.6 s | 9/10 | -| WebKit (Playwright) | 6 | 6.3 → 3.0 s | 7.4 → 3.8 s | 6/6 | +| 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 8 to 2 in -WebKit. The time from the read knowing the holder to asking it fell from 0.55 -s to 0 in Chromium, and from 1.66 s to 0 in WebKit. +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. -Through the SDK's `createMediaSource()`, as released in 0.1.4 and with this -change's bindings, the same video was timed to its first decoded frame. It then -played for ten seconds and was sought to 85%, beyond the read-ahead window: +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 | 4.4 → 3.0 s | 13/20 | 4.9 → 4.4 s | 12/20 | -| WebKit (Playwright) | 6 | 4.8 → 3.8 s | 4/5 | 5.3 → 2.9 s | 4/5 | - -In Chromium the median paired saving was 0.7 s for the first frame and 0.5 s -for the seek. Seeks over 6 s fell from 5 of 20 to 3. One baseline WebKit sample -failed: its seek never produced a frame. The SDK reads the file's tail beside -its head, and that read competes with the head's. - -**Cost: first reads concentrate on the first holder found.** In the WebKit -traces on main, two holders each served three of the six first reads. With -this change, one served all six; in Chromium it served 10 of 10, against 9 of -10. 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). +| 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. @@ -914,8 +917,9 @@ 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 creates its DTLS endpoint only once its own ICE has completed, and drops - an earlier ClientHello; that is saorsa-transport#229 (V2-1770). + 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.