Skip to content

feat(swarm): host a swarm rendezvous on every node - #262

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

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

Conversation

@grumbach

@grumbach grumbach commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Every node hosts swarm rooms: it introduces clients reading the same file to each other, carries their WebRTC offers and answers, answers their STUN on its WebRTC Direct socket, and stays the last seed for records. No new binary or port. Design, bounds and rollout: ADR-0026 (new, Proposed). The commits after the feature point the three dependencies at their matching branches, let the notices generator match git dependencies on a branch whose name has a slash, and meet Rust 1.99's clippy.

One change across six repositories, as draft PRs. 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 #262
  3. ant-client feat(swarm): share records with other readers of a file, in every client ant-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 and core, 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

No linked Linear issue was identified for this work; one can be added by a maintainer.

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: handles the swarm messages on the chunk protocol, advertises swarm_rendezvous to browsers and answers plain STUN for hosts that announced in the last 60 s; clients without the swarm see no change.
  • Storage: none (rooms live in memory, bounded)
  • API: none for users; a swarm module the node and its devnet use.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • cargo test --lib: 1232 passed (on the git dependencies of this PR). cargo fmt --check, cargo clippy --all-targets --all-features -D warnings, cargo clippy -D warnings and cargo doc -D warnings: clean. cargo clippy --no-default-features -D warnings reports main's five existing unused_self in replication/audit_metrics.rs, nothing new.
  • Unit tests (src/swarm.rs, src/web_rtc.rs): requests naming another node, introductions, answers nobody asked for, long-poll wake-up and shutdown, caps and leaving, expiry, the STUN allowlist, the unanswered-offer cap, browsers never waiting, replayed or stale native requests, histories and members capped per host, ended memberships refused however late, a relayed SDP keeping no public address but its sender's, a stored SDP charged the memory it holds, and the browser request budgets (swarm requests on a budget of their own, refunded to the reads, taken on the swarm's when the reads' is spent, all-or-nothing across buckets).
  • Adversarial: hostile SDPs (spellings a receiver reads leniently, third-party addresses, oversized lists), forged and replayed native requests, a member moving hosts, request floods against the read and swarm budgets.
  • End to end: the client's native swarm tests run this node in-process with real webrtc-rs; the client's browser job runs this node in CI against real Chromium pages.
  • Local lab on these PRs' final code (one VM, 56 nodes: 40 on this branch, 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 this 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 MB from the others; where readers' own CPU was the limit, 4% fewer node bytes and 3% sooner. The first chunk came no later: 3.6 s sooner at the median over the 12 staggered readers, and +0.3 s (95% interval -2.0 to +3.0 s) pooled with 52 busy-node readers of earlier builds. 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 MB from each other). No dev testnet run yet.
  • Reviewed with Codex xhigh, per repository and as one system with the five sibling PRs, until no finding remained.

New dependency

none

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

Revert: clients pass over nodes that host no rooms and read from nodes alone, as today.

Clients reading the same file share its records over WebRTC, BitTorrent
style. Every node now introduces them and stays the last seed: a record no
client has comes from the nodes as before. There is no new binary and no
new port. Design: ADR-0026.

The rendezvous (`src/swarm.rs`):
- Rooms are keyed by a file's root DataMap address. Readers announce at the
  2 closest nodes to it that host rooms, on the connections they already
  have (QUIC for native clients, WebRTC Direct for browsers).
- The node names each member from its connection, hands each offer to a
  different member, and passes an answer back only for an offer it
  delivered to the answerer, once, before it expires (the protocol's 70 s
  signal lifetime).
- An offer or answer is passed on with only the candidates another reader
  may try: no public address but its sender's own host, as the node reaches
  it on that request, no local address or mDNS name unless the node reaches
  the sender at a local address itself, and at most 16 (ant-protocol's
  `constrain_swarm_sdp`), so a stranger cannot point readers' connectivity
  checks at a third party or at their own home networks. A stored SDP is
  charged the memory it holds.
- A native membership continued over a connection from another address
  counts against that address from then on, if it has room.
- Native announces may wait up to 20 s for a signal. Browser announces never
  wait, so they hold no browser request slot.
- A node hosts rooms only once browsers can reach it: until its browser
  listener has an endpoint and a STUN server to name, it refuses announces
  as `NotHosting`, so native readers pass it over at once and meet browsers
  at the closest nodes both reach.
- Native requests are signed and checked for freshness and replay. A
  request naming another node is refused before it touches that history.
- Membership numbers let the node keep each connection's newest
  membership. Ended memberships are remembered for as long as a request sent
  before they ended can be taken, so a late request cannot end, take over
  or revive one.
- Native replies go only on the connection the request came on, through
  saorsa-core's `send_message_on_channel`.
- Hard limits with no configuration:
  - rooms, members per room and in all, and rooms per connection;
  - members, histories, ended memberships and stored SDP bytes per host
    (an IPv4 address or an IPv6 /64);
  - unanswered offers per member and waiting announces.
- The WebRTC Direct socket answers plain STUN Binding requests for hosts
  that announced in the last 60 s, rate limited in saorsa-transport.

Routing: `NativeSwarmRouter` in `src/node.rs` (shared with the devnet)
handles native swarm requests on their own task budget. `src/web_rtc.rs`
handles browser ones before the storage check, so nodes that store no
chunks host rooms too, and advertises `swarm_rendezvous`. Browser swarm
requests run on a request budget of their own (64 a second, 8 per source):
one within it gives the reads' budget back the token it took on arrival,
one arriving when the reads' budget is spent is taken on the swarm's, and
one past the swarm's budget is refused without closing the channel the
reads share, so a room's announces never spend the request budget reads
need, nor lose the channel to it.
Patch ant-protocol, saorsa-core and saorsa-transport 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) and the plain STUN answers
(WithAutonomi/saorsa-transport#231). Once those merge, these point at
their main branches, or at the released versions at the release train.
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's clippy (assert_is_empty, which CI's stable toolchain runs)
asks for assert_eq when a test checks that a value is empty, so a
failure shows the value.
The pending-offer test never let B take its offers, so B's 32-signal inbox
stopped delivery whatever A's own cap on unanswered offers did. B now
collects every offer without answering, and A's 33rd offer is still refused
while B's inbox is empty.

The expiry test swept past the 70 s signal deadline, which is also past the
60 s a silent member lasts, so the members left and took their offers with
them whether or not signals expired. The members are now kept fresh across
that deadline, the offer and its SDP must still be there just before it and
gone just after it, and silent members expiring is a test of its own.

Each test fails with the limit or expiry it covers removed.
The earlier test answered an offer that did not exist, so it passed with the recipient check removed. Now A's live offer goes to B, a third member of the same room answers it with its real id and is refused, the offer stays pending, and B's answer still reaches A. It fails if any member of the room may answer.
The client now counts each of its own fetches from the nodes, not only reads of a file's data records, when it limits what it sends other readers: opening a file's map, a raw read or an existence check needs its CPU and line first too.
Browsers' swarm requests run on a budget of 64 a second per node and 8 per
source, but native clients' only had a cap on tasks running at once. A
native client can make identities for free, so one host could announce
with wait_ms 0 and fresh memberships as fast as the slots turned over, and
every replacement does its work under the rendezvous lock browsers use too.

Native swarm requests now take a place from a budget of their own with the
same figures, 64 a second in all and 8 from one host (an IPv4 address or
an IPv6 /64), before they are decoded or given a task; past it they are
dropped and counted (native_over_budget), and the client reads from nodes
alone. The node's budget is taken first, so once it is spent a flood costs
one check a request. At most 4096 hosts' budgets are remembered, those that
have refilled forgotten first, which always leaves room for a new host
since the node's own budget keeps all but a few full.

Tests: a host's identities and an IPv6 /64 share one budget, many hosts
together get the node's and no more, the hosts remembered stay bounded and
a forgotten one starts afresh, and a flood through the router from one host
is held to its budget while another host gets through. They fail with the
router's check or the per-host budget removed. ADR-0026 says so.
…or read

The client now tries a waiting room join again when the file is joined again or a read of one of its records starts, not only when a record of it comes in, so a file whose reads the nodes fail still joins once a room has gone unread.
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