Skip to content

feat(swarm): share records with other readers of a file, in every client - #244

Draft
grumbach wants to merge 19 commits into
WithAutonomi:mainfrom
grumbach:feat/peer-swarm
Draft

grumbach wants to merge 19 commits into
WithAutonomi:mainfrom
grumbach:feat/peer-swarm

Conversation

@grumbach

@grumbach grumbach commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Every client, native and browser, shares the records of the files it reads with the other readers of each file, BitTorrent style: it joins the file's room at the nodes, links with other readers over WebRTC data channels, and races a peer that has a record against the nodes, which stay the last seed. Peers only ever add to the node path; every record is checked against its address. The branch is rebased onto main as linear commits: main's ADR-0020 lookup runs inside the node read the swarm races, so a reader that sends a record first spares the nodes the lookup too, and main's per-chunk download progress (who served a chunk and the GETs it cost) now tells a node, the cache and another reader apart (from a reader). The commits after the feature point the four dependencies at their matching branches (the lock starting from main's), let the notices generator match git dependencies on a branch whose name has a slash, avoid the atomics' fetch_update, which Rust 1.99 deprecates, make e2e_swarm wait for what the other reader holds and share records small enough that a debug build's link beats the nodes on slow runners, admit new sends to other readers only one at a time while any fetch of the client's runs, build e2e_swarm only with test-utils, count close-group reads as fetches, let a browser's discovery dial only once the page's ICE gathering turn is idle, name the peer each open link reaches for pages, make a waiting room join when its file is joined again or read, build the running-fetch count only with test-utils, space a native file download's last two retry rounds by its slowest round so far (up to 5 minutes), correct two docs, count a chunk's GETs once sent rather than while they wait for their turn, ask for a file's records in one order per client (so one client's downloads of a file keep sharing fetches), report a record the swarm already held as from the cache, and lock ant-node at its branch's head.

One change across six repositories. Merge order:

  1. ant-protocol feat(swarm): add swarm rendezvous messages for clients sharing records ant-protocol#50, saorsa-transport feat(webrtc): answer plain STUN Binding requests on the WebRTC Direct socket saorsa-transport#231 and saorsa-core feat(network): send a message on one named channel only saorsa-core#183, independent of each other
  2. ant-node feat(swarm): host a swarm rendezvous on every node ant-node#262
  3. ant-client feat(swarm): share records with other readers of a file, in every client #244
  4. Try https://github.com/WithAutonomi/try/pull/21, once a browser SDK release carries the swarm

This PR depends on: protocol, transport, core and node, as git branch dependencies on their feat/peer-swarm branches. Once those merge, the dependency commit moves to their main branches (or the released versions at the release train).

Linear issue

Closes V2-1892

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Proposed; a human confirms at review.

Compatibility

  • Wire: a browser sends swarm messages only to nodes that advertise rooms; a native client, which cannot tell, asks, and passes over for 10 minutes a node that does not let it join (refused, failed or unanswered); readers talk to each other over WebRTC data channels labelled ant-swarm-<room tag>.
  • Storage: none (shared records are held in memory: 256 MiB native, 64 MiB browser)
  • API: adds Client::join_swarm, swarm_stats, swarm_room_stats; the browser's joinSwarm, swarmStats, swarmRoomStats; BrowserNetwork::supports_swarm (default false) and connect_for_swarm (default no-op).

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • On this PR's git dependencies at their branches' heads, at e44ea44 (rebased onto main 26199fe): cargo test -p ant-core --lib 989 passed; cargo test -p ant-core --features test-utils --test e2e_swarm 4 passed (a second reader served records by the first over real webrtc-rs on an in-process devnet, the nodes asked for every record it did not send; a reader whose peers left served by the nodes; readers of different files not meeting; a close-group read counting as a fetch while it runs). CI's Browser WASM job at e44ea44: 261 of 261 WASM tests (wasm-tests/*.test.mjs). Locally, before e44ea44, which changes only Cargo.lock's ant-node revision to 008a76d6 (ADR-0026's text only): the same 261, and main's large-download tests 15 times in a row. CI's end-to-end jobs at e44ea44, on Linux and on macOS, with test-utils: e2e_chunk 13, e2e_data 7, e2e_file 10, e2e_manifest 7, e2e_swarm 4, e2e_payment 8, e2e_native_payment 4, e2e_security 10, e2e_cost_estimate 7 and e2e_adr0004 3 passed, and e2e_merkle 8. fmt, clippy (all targets and features, wasm32 browser-wasm, -D warnings, Rust 1.99) and RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps: clean.
  • Main's per-chunk browser progress, reconciled with the swarm: a chunk names the node that served it, cache (the chunk cache or the swarm's own store held it) or a reader, and the GETs it cost; a GET counts once sent, or once it ends unsent, never while it still waits for its turn (unit test); one client asks for a file's records in one order, fixed and a permutation, another client's differing (unit test), so two downloads in one client keep sharing fetches; a download after a range read in the same client reports the records its swarm held as from cache (WASM test, failing with the old labelling).
  • Unit tests over an in-memory WebRTC stand-in and webrtc-rs on loopback, timed on a paused clock: a stalled, failed or slow peer costs a read its hedge delay, or the patience for one on pace, at most; a peer beating busy nodes keeps the read; nodes that fail leave a peer still sending to finish, and their miss still reaches the limiter; a read asks no link twice (it may ask the same peer again on a duplicate link) and its budget starts at its first real ask; only a room gone unread makes way for another, never one whose read still runs or that joined during one; a link whose first message beats its opening still opens; a room starts a bounded number of answers; while any fetch of the client's runs (close-group reads included) new sends to other readers start one at a time, and sends already running finish; open links name their peer once per peer; a waiting room join is made when its file is joined again or a read of it starts; in WASM, discovery dials never put two ICE gatherings ahead of a read's connection; and a native file download's last two retry rounds wait at least as long as the batch's slowest round so far took, up to 5 minutes, also when its other records came quickly, while without a pace (a browser read) they keep their 15 and 45 s (these two tests fail with the pacing removed).
  • Adversarial: forged records and out-of-order pieces end the link; a relayed SDP gives webrtc-rs's own SDP and ICE parsers no fingerprint or candidate but the sender's allowed ones (lenient spellings, Unicode whitespace, carriage returns, out-of-range components); a node that is no member cannot pass one reader's proof to another; nodes without rooms receive no swarm request (the mock nodes' own log, WASM).
  • CI: the native end-to-end job now runs e2e_swarm, and the browser job also runs swarm.spec.js in real Chromium against the ant-node this client is built with (REQUIRE_SWARM), beside the existing run against the pinned older node.
  • On the rebased heads, in the lab of Many downloaders below (32 nodes of these PRs at 008a76d6, the swarm client at e44ea44 and, as the released client, client main 26199fe):
    • 16 readers, four runs of each client in balanced order (two with every reader starting within 3 s, two with one starting every second): the swarm client finished 61 of 64 downloads (16, 15, 15 and 15 of 16) against 27 of 64 (8, 3, 11 and 5; Fisher exact p < 0.0001), its median lower in every pair (218 to 385 s against 541 to 1153 s); the nodes sent 2.6 to 3.4 GiB a run against 6.4 to 12.9 GiB (66% less on average) and used 83 to 172 against 161 to 848 CPU seconds, 306 to 415 MiB coming from peers. The swarm client's 3 failures: 1 reader found no node at start, 2 could not fetch the file's DataMap (after 59 and 78 s); the released client's 37: 34 gave up on a chunk after their retry rounds, 2 found no node at start, 1 could not fetch the DataMap (after 70 s). Against the earlier batch on the merged heads, the swarm client finished 61 rather than 58 of 64 downloads and the released client 27 rather than 40, the nodes sending it 6.4 to 12.9 rather than 4.3 to 8.1 GiB a run; client main changed only its browser download progress, its install script and CI's audit ignores in between. Separate batches do not isolate the cause of the released client's drop.
  • Before the rebase onto the current mains, on the merged heads, in the lab of Many downloaders below (32 nodes of these PRs at 039ce7c5, the swarm client at 40258e4 and, as the released client, client main 42b68ff, which reads through ADR-0020's get-or-closer lookup; the later commits change only docs, a test's name and locks):
    • 16 readers, four runs of each client in balanced order (two with every reader starting within 3 s, two with one starting every second): the swarm client finished 58 of 64 downloads (16, 16, 15 and 11 of 16) against 40 of 64 (14, 9, 12 and 5; Fisher exact p = 0.0003), its median lower in every pair (249 to 607 s against 355 to 701 s); the nodes sent 3.0 to 5.0 GiB a run against 4.3 to 8.1 GiB (42% less on average) and used 80 to 149 against 87 to 197 CPU seconds, 215 to 389 MiB coming from peers. Against the runs before the merges, the released client finished more (40 of 64 against 21 of 64) and the swarm client fewer and later (64 of 64 at medians of 219 to 283 s), so the gap narrowed. The swarm client's 6 failures: 3 readers found no node at start, 2 could not fetch the file's DataMap (after 61 and 66 s) and 1 gave up on a chunk after its retry rounds; the released client's 24: 17 gave up on a chunk, 4 found no node at start, 3 could not fetch the DataMap (after 66 to 80 s). DataMap fetch failures also occurred before the merges; both clients now read the map through the same lookup.
    • Half the nodes still on the released v0.23.1 (16 of these PRs' nodes beside 16 of v0.23.1, half the readers on each client, three runs): every run overloaded the nodes, harder than before the merges, the nodes sending 16.4 to 21.0 GiB a run (5.3 to 14.3 GiB before). The swarm client finished 11 of 24 downloads (2, 2 and 7 of 8) against 1 of 24 for the released client in the same runs (Fisher exact p = 0.002); 11 of the swarm client's 13 failures and all 23 of the released client's gave up on a chunk after their retry rounds, the other two found no node at start. Today's network beside the same old nodes, run after those three rather than alternated with them (16 nodes of ant-node main e61446b8, every reader on the released client, three runs), finished 2 of 48 downloads, the nodes sending 22.4 to 25.0 GiB a run (7 of 64 and 8.6 to 16.9 GiB before the merges): the harder collapse comes with the merged mains, ADR-0020's read among them, and the mix with the swarm client finished more (12 of 48 against 2 of 48, p = 0.007) with the nodes sending 22% fewer bytes (18.5 against 23.7 GiB a run on average); its released readers did as well as today's (1 of 24 against 2 of 48). The old nodes logged no panic and no ERROR line, and 31 to 325 decode warnings a run beside these PRs' nodes, the swarm client's room requests.
  • Every other lab result below, the browser lab, Many downloaders, Controls and Rollout, was measured before these PRs merged their mains, so before ADR-0020's native chunk read, on both clients and on the nodes.
  • Local lab with browsers, on these PRs as first published (one VM, 56 nodes: 40 built from these PRs, 6 restarted during runs, 10 on the released v0.23.1; 6 Chromium readers, each on its own address and CPU share; the same page on both sides, nodes only with the published SDK 0.1.5 against nodes plus readers with the swarm client), run back to back: where the nodes were the bottleneck, six staggered readers used 15% and 16% fewer node bytes in two pairs and finished 13% and 20% sooner (median 138 to 121 s, 144 to 115 s), each taking 17 to 61 MiB from the others; where readers' own CPU was the limit, 4% fewer node bytes and 3% sooner. First chunk: 3.6 s sooner at the median over the 12 staggered readers, but +0.3 s (95% interval -2.0 to +3.0 s) pooled with 52 busy-node readers of earlier builds, so a small delay is not ruled out. Every reader had the file's SHA-256. The real ant file download CLI joins the same rooms as the pages (25 s beside four pages, which took 144 MiB from each other). No dev testnet run yet.
  • Many downloaders (local lab: one VM, 32 nodes built from these PRs with their upload shared at 100 Mbit/s and 1.5 CPUs between them, each reader the real ant file download on its own address with a fifth of a vCPU, one 40 MiB file, a fresh network for every run), the swarm client at a5cb5a2 (the client's later commits space a file download's last retry rounds after slow rounds, 19715a9, in the rollout runs below, update Cargo.lock: the native devnet's ant-node and a Windows-only dependency resolution, and merge main) against the released one (client main, no swarm), alternating:
    • 16 readers, four runs of each (two with all starting within 3 s, two with one starting every second): the swarm client finished 64 of 64 downloads, median 219 to 283 s, the nodes sending 2.6 to 3.3 GiB and using 63 to 82 s of CPU, 217 to 366 MiB coming from peers; the released client finished 21 of 64 (1, 9, 3 and 8 of 16), median 431 to 815 s, the nodes sending 5.1 to 9.4 GiB and using 114 to 230 s of CPU. The nodes sent 60% less for the swarm client.
    • 8 readers, nine pairs: the nodes' shared link is still the limit, so a run's time follows the bytes the nodes send, which vary from run to run with either client (87 to 214 MiB per file downloaded, for a 40 MiB file). Medians 47 to 116 s (the median run 64 s) against 49 to 140 s (71 s); the swarm client was faster in five pairs and slower in four; the nodes sent 8.2 GiB against 8.7 GiB in all, 0.2 to 73 MiB a run coming from peers. Each client lost one download: the swarm client's reader found no node at start, the released client's gave up on a chunk after its retry rounds, as it also does on main's nodes. The build before the swarm client's last fix (ae8e997, without the waiting room join on read) had been slower in its first two 8-reader pairs (73 and 78 s against 47 and 55 s, the nodes sending 16 to 25% more) and mixed in the next four.
  • Controls on nodes built from ant-node main, which host no rooms, alternating builds:
    • The released client. 8 readers, three pairs: medians 59 to 62 s on these PRs' nodes against 59 to 98 s on main's, 23 of 24 downloads against 24 of 24 (the failure found no node at start). 16 readers, one starting every second, nine pairs: 89 of 144 downloads on these PRs' nodes against 98 of 144 on main's (5 to 15 of 16 a run), fewer in six pairs and more in three; the last five pairs, run in balanced order, gave 50 of 80 against 52 of 80. Run medians 245 to 724 s (the middle run 455 s) against 342 to 548 s (453 s), the nodes sending 2.9 to 8.5 GiB against 4.1 to 6.6 GiB. Failures were alike on both: 52 of 55 and 44 of 46 gave up on a chunk after their retry rounds, the rest found no node at start or, once, missed the file's DataMap. Also 11 and 8 of 16 on main with only these PRs' dependency branches, and 13 and 15 on main with its dependencies' unreleased main branches. Run back to back before builds were alternated, these PRs' nodes had given 7, 5, 13, 4 and 5 and main's 13 and 16. These runs vary widely and are few: the gap, one download a run of 16, is well inside their spread (exact paired test p = 0.6), but its 95% interval runs from 4.8 fewer to 2.8 more, so a loss of that size is not ruled out. Within the node's own chunk routing, the only work added for a released native client is a check that sets swarm requests aside; the compared builds also run different dependency code (among others the protocol's stricter decoding and the transport's unreleased changes), and no cause of these differences has been found.
    • The released client's own chunk fetch at fixed rates, so a slow node cannot slow the load down (8 clients, four 40 MiB files, the same 32 nodes, link and CPU share, builds alternated in balanced order, a fresh network per run): at 1 and 1.5 fetches a second, the nodes' link up to four fifths full, all 928 fetches succeeded on each build in four pairs, the median 1.01 and 0.71 s on these PRs' nodes against 1.09 and 1.03 s on main's (90th percentile 2.2 and 1.9 s against 3.2 and 2.8 s), the nodes using 0.15 and 0.11 CPU seconds per fetch on average against 0.15 and 0.12. From 1.75 a second a run either stays healthy or the client's repeated asks to further nodes fill the link and fetches give up after about 100 s, on every build. At 1.75 a second, seven runs of each: 69.9% of fetches succeeded on these PRs' nodes against 77.2% on main's (one and two runs healthy; exact permutation test p = 0.4), the median successful fetch 2.7 s against 1.9 s and the 90th percentile 10.9 s against 10.7 s; three runs each gave 73.8% on main with only these PRs' dependency branches and 77.1% on main with its dependencies' unreleased main branches; against the first, these PRs' nodes gave 69.9% pooled (p = 0.9) and 76.0% within the batch where both ran, which does not isolate the node's own changes (their locked dependencies also differ slightly). At 2 a second, eleven runs of each: 37.1% against 39.5% (p = 0.4), the median successful fetch 4.1 s against 3.6 s and the 90th percentile 15.7 s against 11.8 s; over the first 30 s of a run the median was 3.6 s against 2.8 s on average (p = 0.07). In overload the released client did slightly worse on these PRs' nodes than on main's in every pooled comparison, here and with 16 readers above, none of the differences significant. Sent straight to one of the chunk's two closest nodes instead (the released client's own GET encoding and wait, GETs started at a fixed rate whatever earlier ones do, no repeated asks, the lookups done before the timed part, four runs of each in balanced order), its GETs were served alike at 2 and 2.75 a second (the link 60 to 65% and 81 to 88% used): a run's median successful GET took 2.35 s on average on these PRs' nodes against 2.37 s on main's at both rates (95% interval of the difference -0.17 to +0.12 s), its 90th percentile 2.80 and 2.82 s against 2.82 and 2.85 s; the nodes sent 5.4 to 5.5% more than the chunks' own bytes on every run of both and used 0.079 and 0.066 CPU seconds per successful GET against 0.080 and 0.066; 48 against 49 GETs found the chunk missing at that node, and none failed otherwise (exact permutation tests p = 0.2 to 1.0). At 3.5 a second, past the link's capacity, 84.1% of these GETs succeeded on these PRs' nodes against 70.7% on main's (p = 0.09), the rest timing out (59 against 224) or finding the chunk missing (78 against 29), a run's median successful GET 3.74 s against 4.10 s (p = 0.2). The lookup each fetch starts with (the released client's own call for the 7 closest peers to the chunk) was served alike too, timed on its own at a fixed rate on the chunks' addresses, four runs of each in balanced order: at 8 a second (the link 56% used) a run's median lookup took 0.512 s on average on these PRs' nodes against 0.509 s (95% interval of the difference -0.015 to +0.021 s), and at 16 a second (94 to 99% used) 9.22 s against 9.31 s (-0.30 to +0.12 s), the 90th percentile 0.61 s on both and 16.11 against 16.12 s; the nodes sent 0.83 and 0.84 MiB and used 0.021 and 0.026 CPU seconds per lookup on both, and all 11,520 lookups were answered. So each step a node takes for the released client's fetch, the lookup and the GET, measured alike on both builds at every load tested, with no significant difference; at normal load the 95% intervals put any slowdown of a run's median at no more than 0.12 s for a GET and 0.02 s for a lookup, though they do not prove the two equal. As one-sided 95% bounds (from 90% intervals of each measure as a share of main's), no step was more than 5.1% worse on these PRs' nodes at any of the five loads, in a run's median or 90th percentile time or in CPU or bytes per successful operation. These tests run the two steps apart and do not reproduce the client's collapse. To size that collapse's own spread, main's binary was run against a second copy of itself, alternating, at 1.75 a second: 67.8% and 70.5% of fetches succeeded (six runs each, single runs 86 to 160 of 160), and the same binary had given 77.2% in the earlier batches; so a gap like 69.9% against 77.2% above also appears between identical builds, and these PRs' seven runs against all nineteen of main's binary gave 69.9% against 72.1% (p = 0.7). A loss of a few points in this overload is still not strictly ruled out.
    • The swarm client against the released one on main's nodes, 8 readers, three pairs: medians 88, 48 and 49 s against 59, 80 and 65 s, every download correct.
  • Rollout (the same lab, nodes and link, a fresh network per run, 16 readers starting one a second; the released client is client main without the swarm; the swarm client at a5cb5a2, or where marked at 19715a9, the client's commit that spaces a file download's last two retry rounds by its slowest round so far):
    • Every node from these PRs, half the readers on each client (five runs, alternated with three runs of every reader on the released client): 39 of 40 swarm-client and 38 of 40 released-client downloads finished (two of the three failures found no node at start), the swarm readers' median 184 s against 300 s and lower in every run. With every reader on the released client, 34 of 48 finished (the released readers, 38 of 40 against 34 of 48: Fisher exact p = 0.005), and the nodes sent 5.3 GiB a run on average against 3.2 GiB with half the readers on the swarm client (exact permutation test p = 0.02) and used 115 CPU seconds against 69 (p = 0.04). Two more runs at 19715a9: 16 of 16 swarm-client and 14 of 16 released-client downloads finished (both failures could not fetch the file's DataMap), median 191 s against 205 s.
    • Half the nodes still on the released v0.23.1 (16 of these PRs' nodes beside 16 of v0.23.1, half the readers on each client, five runs, alternated with four runs of today's network: 16 of main's nodes beside 16 of v0.23.1, every reader on the released client): every one of these runs collapsed, and at a5cb5a2 the swarm client did worse than the released one. Beside these PRs' nodes 4 of 40 swarm-client and 13 of 40 released-client downloads finished (p = 0.03); past the first reader of each client, 0 of 35 against 10 of 35 (p = 0.001), the ten in two runs, at 944 to 1198 s, each time after every swarm reader there had given up (at 618 to 940 s). Beside main's nodes 7 of 64 finished (every reader beside these PRs' nodes, 17 of 80: p = 0.1; their released readers, 13 of 40: p = 0.01), the nodes sending 9.1 to 16.3 GiB against 8.6 to 16.9 GiB a run. Both clients give up on a record after the same four tries. The swarm readers had most records from each other, so their rounds held only the records none of them had, took little time and put all four tries in the worst of the overload: they gave up first (median 729 s against 1014 s), and some released readers finished after every swarm reader there had stopped. Every failed swarm reader lacked a record no other swarm reader held. Five more runs of this mix at 19715a9, after the others: the swarm client finished 36 of 40 against 23 of 40 for the released client in the same runs (p = 0.002), past the first reader 33 of 35 against 21 of 35 (p = 0.001); two of its four failures were the first swarm reader, with no reader ahead of it, and the four gave up at 707 to 1015 s. The released readers did no worse beside it (23 of 40 against 13 of 40 beside a5cb5a2, a later batch, p = 0.04), the nodes sending 5.3 to 14.3 GiB a run. Load varied from run to run: one of the five never overloaded and all 16 finished (without it, 28 of 32 against 15 of 32). The readers also met later than with every node upgraded (the first record from another reader at a median of 167 s against 116 s, at a5cb5a2): a node on the old release cannot read a room request and leaves it unanswered, and a joining reader passes such a node over after about 60 s, then waits 10 s before trying the next, twice as long after each further node that never answers (up to 5 minutes). So with 19715a9, while half the nodes run the old release and the network is overloaded like this, a reader on the swarm client gave up less often than one on the released client.
    • The old nodes' logs, colour codes stripped, in the last mixed run and the last run of today's network: no panic and no ERROR line in either; 55 warnings that a message could not be decoded beside these PRs' nodes and none beside main's. Those are the swarm client's room requests, as these PRs' nodes send swarm messages only in reply. The logs kept from the earlier runs (4 to 11 of 16 a run, four runs) agree: no panic or ERROR line; 40, 37 and 0 such warnings in three runs beside these PRs' nodes and 0 in one beside main's. The five mixed runs at 19715a9: no panic or ERROR line, 0 to 279 such warnings a run.
  • CI at e44ea44: every build, test and lint job passes, among them the end-to-end and Merkle end-to-end jobs on Linux and macOS, Browser WASM and both real-Chromium jobs (the pinned older node and the swarm's); Security Audit fails. It fails only on RUSTSEC-2026-0387 (crypto-bigint 0.5.5, through elliptic-curve 0.13), which cargo audit reports on this repository's main lockfile too, with the same dependents; its fixes wait on alloy and webrtc-rs releases. linear-link and pr-template, which check this description, failed on its previous version, which named no Linear issue; this version passes both when their script is run on it locally.

New dependency

webrtc 0.14 and rustls 0.23 become direct, optional dependencies of the native build (both were already in the tree through saorsa-transport), and a few more web-sys features.

ADR

https://github.com/grumbach/ant-node/blob/feat/peer-swarm/docs/adr/ADR-0026-clients-share-records-nodes-introduce-them.md (new in the ant-node PR, status Proposed)

Mitigation / rollback

It need not wait for the nodes: in the lab, with half the nodes still on the old release and the network overloaded, the swarm client finished more downloads than the released one (Rollout, under Test evidence), though its readers meet later beside old nodes. Revert: reads go to the nodes alone, as today; nodes with rooms then hold nothing for this client.

Every client, native and browser, now takes part in a BitTorrent-style
swarm for each file it reads, always on. Readers meet through the file's
rendezvous nodes (ADR-0026 in ant-node), share records over WebRTC data
channels, and fall back to the nodes, which stay the last seed.

- `ant-core/src/swarm/`: rooms, links and the wire between clients, on
  webrtc-rs natively and the browser's `RTCPeerConnection` in WASM.
- **Rooms.** A room is keyed by the root DataMap address, at most 4 at a
  time, joined only for a valid root map. A read keeps its room in use for
  as long as it runs, however long the nodes take, a room joined while it
  runs included; joining past 4 leaves the room unread longest once no
  read has used it for 30 s, and otherwise waits (2 joins at most) to be
  made at a later read of the file, so reading more files at once cannot
  make every read a new membership, nor push out a long download's room.
  Each room has a membership number and a random per-room name. Its
  rendezvous nodes are the closest of the 8 nodes nearest its key that
  host rooms: one lookup serves both places for 30 s, and a node whose
  session says it hosts no rooms, or that never let this client join, is
  passed over for 10 minutes, so readers still meet while some of the
  closest nodes run an older release. Offers go through both rendezvous
  nodes, which are always distinct, counted per node, without doubling up
  while a connection is being made. Every negotiation step is bounded and
  ends with the room; a native client announces at a node at most once a
  second, and a room starts at most 8 answers in 20 s, so a node handing
  out offers, well formed or not, costs a bounded amount however fast it
  replies. In a browser a membership lives on the session to its node:
  once that session has closed, an answer or a leave for that node fails
  at once, and the next announce connects again (in the swarm's discovery
  turn), joins anew and drops the offers made to the old membership. A
  node's STUN servers are used only at an IP address the client reached
  that node on. Each room has a tag derived from its secret: its links'
  data channels are labelled `ant-swarm-<tag>`, and an answerer refuses a
  channel labelled for another room. Another member's offer or answer is
  applied with only the candidates a receiver may try (ant-protocol's
  `constrain_swarm_sdp`): local addresses and mDNS names only from a node
  this client reached at a local address itself, loopback ones only over
  loopback. Every swarm reply is checked before it is used. An offer waits
  for its answer as long as the protocol's signal lifetime and both trips
  need, checked at compile time against the slowest answer any client
  gives; making a round's offers and each local WebRTC step are bounded so
  the announces keep their pace, and a link this client answered waits for
  the offerer to collect and apply the answer before it must open.
- **Links.** A link opens only after the peer proves it knows the room's
  secret and holds the key it names itself by. The secret is derived from
  the root map, and the proof is bound to both ends' DTLS fingerprints as
  each SDP is applied (only their standard lines remain, see
  `constrain_swarm_sdp`), so a node knowing only a private file's room key
  cannot join or sit in the middle. A message before the channel reports
  itself open opens the link and is read first (webrtc-rs reports the
  opening from a task of its own), so a peer's first message is never
  lost. Each membership has its own ML-DSA-65 key; a peer's name in the
  room comes from it and is taken only once its signature over the link
  verifies, so no member can pose as another. Duplicates settle on one
  link per peer, one per introduced member: each end sends its own proof
  before reading the peer's, and the end closing a duplicate keeps its
  connection 2 s more, still counted, so both ends settle the same way and
  stop offering again. Each answer is bounded.
- **Reads.** Records the swarm holds are used first. Peers complement the
  nodes and never hold a read up: a read asks at most 2 peers that
  announced the record, counted across all its asks from the first that
  found one, one record per link at a time with at most one
  more read waiting behind it, within strict deadlines and at most 8 at
  once, and gives them the read's hedge delay (3 s in a browser, 1 s
  natively); past that a peer keeps the read while it is on pace to finish
  within the read's patience (about as long as the nodes take a record
  now, read-ahead a reader waits on included, at least twice the hedge
  delay, at most 12 s), then the nodes are
  read beside it, at once if it fails; the first record that checks
  against its address wins, and a node read that ends without one
  (refused, busy or missing) leaves a peer still sending to finish within
  its deadline (the node limiter still learns of the nodes' miss). A link
  settled away mid-read hands the read to its twin. In a browser, a read
  still waiting for its turn at the nodes, with no GET sent, takes its
  record from a linked reader that has it meanwhile, while it keeps its
  place in that queue. A browser download asks for its records in an order
  of its own, as BitTorrent picks pieces, so readers starting together
  hold different records early on. A hidden browser page reads from nodes.
- **Serving.** A store of 256 MiB (native) or 64 MiB (browser) holds every
  record at a minimum charge. Uploads are limited per link and in all (4,
  and 1 while any of the client's own reads runs, whichever download API
  started it, so serving never takes the CPU and line its own download
  needs), with transfer numbers per ask, and
  sends take turns in order and yield through a posted message in
  browsers.
- **Integration.** File and data downloads, browser readers, streams and
  read-ahead join the file's room as soon as they have its root map, and
  seed what they fetch; joining returns at once, and the other readers are
  found beside the reads from the nodes. A room joined by a read asks the
  nodes for nothing until that read's first record is in (30 s at most),
  so discovery never competes with a download's first record; one joined
  on request (`join_swarm`) starts at once. A browser reader joins again
  on a read if reading other files has left the room meanwhile. Records
  already held are announced to links opened before the room learnt its
  file's records. Raw `chunk_get`, `chunk_exists` and DataMap records (a
  shrunk map's wrappers included) neither ask peers nor share. Clients on
  one native node identity share one swarm. A browser client takes part
  only on a network adapter that says which nodes host rooms
  (`BrowserNetwork::supports_swarm`, false by default; the client's own
  adapter says true), since a node that predates rooms closes the channel
  a swarm request comes on.
- **What a page sees.** `Client::join_swarm`, `swarm_stats` and
  `swarm_room_stats`, and the browser's `joinSwarm`, `swarmStats` and
  `swarmRoomStats`, expose it; `swarm_stats().discovery` counts what
  finding peers cost (lookups, announces and their failures by cause,
  signalling bytes, offers, answers, channels and links). A browser
  reports `Swarm room <tag>` when it opens the file, and counts are kept
  per room, with each open link named by this end's DTLS fingerprint, so a
  page can tell which file each link and record belongs to, and which of
  its connections are proven links. A downloaded chunk's progress line,
  which names the node that served it or `cache`, says `from a reader`
  when another reader sent it (who served a chunk is a `ChunkServer`).
- **Tests.** Unit tests over an in-memory WebRTC stand-in and over
  webrtc-rs on loopback; timing tests, on a paused clock, that a stalled,
  failed or slow peer delays a read by the hedge delay, or the patience
  for one on pace, at most, that a peer beating busy nodes keeps the read,
  that nodes that fail leave a peer still sending to finish and that the
  miss is still reported, that only a room gone unread makes way for
  another, that a room whose read still runs never does, and that a
  queued read keeps its place while a peer tries, and asks no peer twice;
  a test that a relayed SDP gives webrtc-rs's own parser no fingerprint or
  candidate but the sender's allowed ones; a test that a room starts a
  bounded number of answers; a
  test that a client still reading sends one record at a time; a test that
  readers pick the same closest nodes that host rooms; an end-to-end test
  over webrtc-rs on a devnet (whose test node now hands the rendezvous
  each request's source address, as a real node does); WASM tests that
  opening a private file, or starting its download, joins its room at once
  without waiting for it; and a real Chromium spec that checks sharing on
  swarm nodes and silence on older ones.
Patch ant-protocol, saorsa-core, saorsa-transport and ant-node to their
feat/peer-swarm branches, which this change needs: the swarm messages
(WithAutonomi/ant-protocol#50), the send on one named channel
(WithAutonomi/saorsa-core#183), the plain STUN answers
(WithAutonomi/saorsa-transport#231) and the rendezvous on every node
(WithAutonomi/ant-node#262). Once those merge, these point at their
main branches, or at the released versions at the release train.

Cargo.lock starts from main's lock and differs from it only where those
branches need it: the swarm's WebRTC and STUN crates, saorsa-transport's
main dropping its CLI (and its clap and reqwest dependencies), and
ant-node's main adding winapi-util on Windows. iana-time-zone keeps the
windows-core 0.62.2 main's lock has.
cargo tree percent-encodes the slash in a git branch's name and cargo
metadata does not, so the notices generator matched no package on such a
branch (this PR's dependency branches are feat/peer-swarm): it now
compares both decoded.

saorsa-transport's main pulls granit-parser 2.0.0, which ships its
licence texts as 1.3.0 did; its entry names the new version.
Rust 1.99, which CI's stable toolchain runs, deprecates the atomics'
fetch_update in favour of try_update, which older toolchains lack. The
swarm's four uses go through a compare-exchange loop of its own that
does the same on both.
On a slow runner (CI's macOS) the second reader's reads could start
before the first's opening messages, which say what it holds, had
arrived: each read then went to the nodes at once, as a read must, and
the test found no record from the other reader. The test now waits
until the second reader has heard that the first holds every record,
through Client::swarm_could_serve, which only the test-utils feature
builds.
…odes on CI

The second-reader test shared three 2.7 MiB records and failed on both
CI runners with no record from the first reader. A read gives a linked
reader that offers a record a 1 s head start, then keeps it while it is
on pace to finish within about 2 s; past that the nodes serve it. In a
debug build the link's DTLS encrypts in unoptimised Rust (RustCrypto
aes-gcm) while the nodes' QUIC uses aws-lc-rs native code: on a laptop
a 2.7 MiB record took the link about 1.3 s and the nodes about 1.0 s,
and on the slower runners the nodes won every record. Waiting for the
other reader's offer, added before, stays: a read that starts before it
still goes to the nodes, as it should.

The test now shares three 64 KiB records, each still several 16 KiB
pieces on the link, and checks that every record from a peer came
whole. On a laptop the second reader's whole download then took 0.14 s
with two records from the first. Records of the largest size stay
covered by the wire assembly unit test and by the real Chromium swarm
job, which shares 2 MiB records over the browser's link.
A client sends other readers one record at a time while its own reads run,
since a record sent costs the CPU and line its own download needs. Only
reads of a file's data records counted, so opening a file's map, a raw read
or an existence check let a client seeding another file keep four sends
going, and slowed the file it was opening.

Every fetch from the nodes now counts (Swarm::fetching); only a read of a
file's data record also keeps the file's room as read, as before. The new
test fails if a fetch that shares nothing stops holding sends to one.
The suite waits on Client::swarm_could_serve, which only test-utils builds have, so 'cargo test --all' failed to compile it. It now requires native and test-utils, as e2e_native_payment does; CI already passes test-utils.
chunk_get_from_close_group and chunk_get_from_closest_peer_group ask the
nodes directly, so a client seeding a file kept four sends to other readers
going while one ran. They now hold sends to one for their whole run, lookup
included, as every other fetch does.

e2e_swarm checks it through the public entry point: once a close-group read
has taken its first step the client counts one fetch running, and none after
it ends (Client::swarm_fetches_running, test-utils only). It fails without
the fix.
Where a browser makes connections take turns to gather ICE (every engine
but Chromium), a read's connection may wait behind at most one of the
swarm's gatherings. A discovery dial let the swarm's discovery turn go once
it had said hello, while its ICE turn stayed held until its gathering
ended (up to 1 s). The next discovery dial, seeing no read waiting yet,
queued for the ICE turn behind it, and a read arriving then queued behind
both: two turns, close to 2 s.

Discovery dials (a room's lookup, its seed and its announce connections)
now wait until the ICE turn is free and no read waits for it
(ice_gathering_turn_idle, which replaces reads_not_waiting_to_gather), so
one never queues for the turn and a read waits behind one discovery
gathering at most. The seed loop waits before each seed's dial.

The new WASM test makes two discovery dials whose gatherings never finish,
then a read's dial 50 ms later: the read connects within one turn and before
the second discovery dial. With the old condition it waited 2 s.
A page counts and draws the peers it shares with by their links, and two
readers can hold two links to each other for a moment while the client
settles on one (met through both rendezvous nodes, or offering to each
other at once). A page then counted one person twice and drew them twice.

RoomStats gains open_link_peers: for each open link, its fingerprint (as in
open_links) and a name for the peer at the other end, derived from the name
the peer uses in that room alone, so two links to one peer share it and the
same client in another room has an unrelated one. Exported as OpenLinkPeer.

The new test opens two links from one member and one from another: the
first two name one peer, the third another, and the name is not the
member's own name in the room. It fails if links are named apart.
… read

A fifth file's room joined while every room was still read waits for one
to go unread. The waiting join was retried only when a record of the file
came in from the nodes, so if the nodes failed the file's reads it never
joined, even once a room had gone unread, and the reads could not reach
another reader who held the file; joining the file again did nothing either.

A join of a file whose join waits now tries it again at once, and so does
the start of a read of one of the file's records, before any record is in.
Both take the waiting join off the list and put it back if still no room
makes way, as the retry after a record already did (Swarm::admit_waiting).

The new test fills the four rooms, makes a fifth wait, then lets the rooms
go unread: joining the fifth again makes it, and a sixth waiting join is
made when a read of its record starts. It fails with either retry removed.
Swarm::fetches_running is read only by Client::swarm_fetches_running, a test-utils accessor, but it was also built for unit tests, where nothing reads it: CI's unit tests, built without test-utils and with warnings denied, failed on dead code. It is now built with test-utils alone.
A native file download retries a record still missing after the
immediate retry 15 s and then 45 s later. A read whose other records
came quickly, from other readers or in a short batch, so spent its last
tries in a minute or two of an overload, while a read with more records
to fetch spread them over its own longer rounds. In a lab rollout with
half the nodes on the old release and the network overloaded, swarm
readers gave up first for this reason.

Those two rounds now wait at least as long as the batch's longest round
so far took, up to 5 minutes, on a clock the caller passes as it passes
the timer. The first pass and the immediate retry, which catches a
record missing only for a moment, are unchanged, and so are browser
reads. Waiting sends nothing, so it adds no load to busy nodes.
… are

`room_of`'s doc said a public file's room is its address. The room is the
address of the file's root DataMap, which a reader resolves a shrunk map to
first, so a public file published with a shrunk map has a room other than
its address; the doc now says so, as ADR-0026 does, and the test of a root
map's room is named for what it checks.

The browser tests' README said the second page must take every data record
from the first. `swarm.spec.js` requires at least one: a link takes one
record asked and one waiting at once, and further reads go to the nodes,
which a busy machine may also let win. The README now says that.
A browser download reports per chunk the GETs it cost (main's WithAutonomi#249). The
count was raised when a GET's future started, before the GET waited for
its turn under the browser's read budget. A GET still waiting there is
dropped, unsent, when a linked reader sends the record meanwhile, and
the chunk then reported a GET that never left; a download that joined
the fetch was charged the same.

A GET now counts once it is sent, or once it ends without being sent
(it failed first, which main counts as a try that cost the chunk), and
not when it is dropped while it still waits. A unit test covers the
three cases.
A browser download asked for a file's records in an order picked at
random for that download, so readers of one file starting together hold
different records early on. Two downloads of a file in the same client
then asked in different orders as well: they no longer overlapped record
by record, a record one had cached could be reported from the cache to
the other, and they shared fewer fetches. Main's test that a joined
fetch charges both downloads its GETs could then fail intermittently.

The order is now picked once per client, from a seed its swarm draws at
random when made: readers in different clients still ask in different
orders, while one client's downloads of a file ask in the same one. A
unit test checks that one client's order is fixed and a permutation, and
that another client's differs.
A browser download reports per chunk who served it. A record the
client's own swarm store held already came back through the same path as
one a linked reader sent, so it was reported `from a reader`. That
happens after a range read: read-ahead keeps the records it fetches for
the file's room and out of the client's chunk cache, so a later
download of the file finds them only in the swarm's store, with no
reader involved.

The read's pace now notes when the swarm answered from its store, and
such a record is reported `from cache`, as one the chunk cache held is.
Like a reader's record, it still does not move the node limiter: the
nodes were not asked. A WASM test reads a file through a reader, then
downloads it in the same client, and fails with the old labelling.
ant-node's feat/peer-swarm branch moved from e5e86480 to 008a76d6, which only
says in ADR-0026 that a client's downloads of one file share their record
order. Cargo.lock follows; iana-time-zone keeps the windows-core 0.62.2
main's lock has.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant