#1811

Stop announce storms: re-announce only when the neighbour set grows

Draftjackjackbitswants to merge intofrom

Summary

Announces were amplifying themselves. On main:

  • every verified announce a node accepted, including relayed ones, triggered a forced, throttle-bypassing (0.15 s floor), TTL-7 announce of its own, once per peer per "marker lifetime";
  • the marker lived in the shared 1,000-entry packet dedup FIFO, so ordinary traffic evicted it and re-armed the answers;
  • every first-seen peer also scheduled an "afterglow" re-announce;
  • every new link fired 2-4 forced announces per side;
  • every completed Noise handshake forced one more;
  • the nickname field committed, and announced, on every keystroke.

In a dense mesh, each of these floods every node, and every receiver pays radio time and battery for it.

This PR keeps the wire format byte-for-byte and changes only when iOS chooses to announce:

  1. Never answer an announce. A node re-announces only when its own neighbour set grows: when it is connected to a peer that its last broadcast announce did not list. That is the edge source routing needs both endpoints to claim. Afterglow is deleted.
  2. One link-up announce per side. Both CoreBluetooth link-up triggers go through one helper. It skips the announce if one was already broadcast after the link came up. If the forced throttle drops it, the announce is owed and retried once.
  3. No post-handshake announce. The one real job of that announce was the courier hand-over. Mail is now handed over directly when the carrier authenticates the recipient, and spray sends run on the engine (see Risks).
  4. The nickname commits on Return, focus loss, a sheet opening, or the app leaving the foreground, never per keystroke. One rename means one announce and one write, and the rename's announce can no longer be swallowed by the throttle.

P010 (the traffic-driven 10 s announce cadence) was deliberately dropped from this PR. Items 1-3 remove forced announces that also kept presence fresh. Slowing the dense-mesh cadence in the same change would remove both margins at once: one lost announce could push a peer past the 45 s UI window and the 60 s registry window. It is better revisited with field data.

Measured

SimulatedMesh runs in-process with real BLEService instances and a deterministic virtual clock. Before = the harness commit on main.

ScenarioBeforeAfter
Join, 6×6 grid, 2 anchors: originations / announce frames80-82 / 16,860-17,2514 / 896
One periodic round, busy 4×4 grid59-61 / 2,832-2,92816 / 768
Relayed announces over 3 churn cycles30
20 fresh signed identities on one link21 announces1
100 unsigned identities on one link100 announces0
Completed handshake1 announce0
Rename right after an announcelostreaches the peer
Unchanged nickname set again1 announce0
Link between already-known nodes33

mesh.joinAnnounce benchmark (8×8 king grid; a phone joins 2 adjacent nodes), joins per second:

BuildBeforeAfter
Debug0.5-0.620-21
Release0.7-0.9 (1.1-1.5 s per join)36-37 (27 ms per join), ~46×

The perf gate's floor (2 joins/s, provisional until the first CI run) fails all three attempts on main, so it guards against the storm returning.

An independent probe in verification, with the same reach in every scenario:

Join scenarioOriginations / announce frames, beforeAfter
7-node chain21 / 273-2853 / 40
5×5 grid58-59 / ~4,8004 / 336
Each of 4 sequential joins into a 4×4 grid~43 / ~4,0004 / 375

Nodes not adjacent to the joiner stopped announcing entirely: 6 / 23 / 8 → 0 across the probes. The "before" figures are conservative, because the harness doesn't model main's third link-up trigger, which this PR removes.

An idle mesh was already quiet on main once directed frames go only to their recipient, so the gains come from joins, churn and busy rounds. Periodic announces now fire on schedule instead of being reset by forced ones, and they become the floor of announce traffic. The real-world reduction will therefore be smaller than the forced-announce counts suggest.

Behavior changes and trade-offs

  • Groups meeting: right after two groups link and sync, 21-22 of 30 cross-group pairs know each other, versus 30/30 before. The rest are learned within one periodic interval (about 10-40 s, unchanged).
  • Rate-limited resubscriptions: a resubscription that BCH-01-004 rate-limits now gets its link-up announce at the end of the backoff instead of never. The enumeration-attack path still suppresses it.
  • Rename timing: peers, our own row, and the QR code show a new nickname after Return or leaving the field, not while typing.

Android assumptions (please field-check)

  • (a) Discovery beyond neighbours. An Android joiner used to learn non-adjacent iOS peers because iOS answered its relayed link-up. It now relies on its own initial sync, or on iOS periodic announces. iOS still sends an Android neighbour its link-up plus one owed announce.
  • (b) Dropping the central notify-on trigger assumes Android's GATT server accepts our write that arrives with or before the CCCD write. If it drops it, discovery still completes through the owed announce once Android's announce arrives.
  • (c) No post-handshake announce from iOS. No Android logic keyed to it is known.

Device checklist

  • Two iPhones, first meeting: connected within ~1 s both ways, and a DM works
  • iPhone ↔ Android, each as central in turn: both connected within ~1 s; queued DMs flush after the handshake both ways
  • Android joining 3 or more iPhones sees the non-adjacent ones within ~2 s
  • Panic with a live link: the rotated phone is seen again within ~1 s
  • Courier: A deposits with C for B; B arrives near C (once each as central); the hand-over happens right after the handshake
  • Rename: one announce on Return; no partial names; QR code, own row, and new notices carry the new name; panic with an uncommitted edit gives anon####
  • A backgrounded iPhone in a 3-device mesh stays present for 10 minutes

Merge order

This PR conflicts with #1809 (peer-list coalescing) in three files:

  • BLEService.swift: both PRs reshape the announce handler's deliverAnnounceUIEvents closure;
  • perf-floors.json;
  • BLEAnnounceHandlerTests.swift.

The plan: land #1809 first, then rebase this branch and resolve them. The merged closure keeps #1809's throttle and drops the announce-back and afterglow hooks. This PR is a draft pending the device checklist above, so the rebase will happen after that.

Risks

  • Owed-announce retry. Review found that a coalesced owed announce could be refused by the throttle and lost. It now gets one retry per distinct broadcast count. Pinned by two deterministic SimulatedMesh tests that failed before the fix.
  • Courier spray on main (pre-existing, now reached on every authentication). sprayCourierMail sent from the main actor, reading engine-owned link state: a Debug trap and a Release data race. The trust gate still runs on main; the sends now go back to the engine queue. Test coverage was extended to a third-party envelope.
  • Unchanged areas: no wire, Tor, or app-lifecycle changes. The only scenePhase use is view-level, in ContentHeaderView, to commit the nickname draft.

Tests

  • Harness: SimulatedMesh counts announces deterministically: a virtual-time announce clock, a fixed relay delay, and test-driven initial sync, all behind #if DEBUG.
  • New, mutation-checked:
    • SimulatedMeshAnnounceBudgetTests: join, link, churn, busy round, rotation, rename, identity flood, owed-announce retry, groups meeting
    • nickname commit tests (NFC, defaults isolation)
    • courier hand-over-on-authentication tests
    • the mesh.joinAnnounce benchmark, with a floor of 2 joins/s (provisional; calibrate from the first CI run)
  • SwiftPM: 2,188 Swift Testing + 215 XCTest, twice, 0 failures.
  • Builds and static checks:
    • macOS Debug, plus universal Release iOS-simulator and macOS builds, with 0 warnings. The test hooks do not leak into Release.
    • SwiftLint and Periphery: no new findings versus main.
    • Perf gate: passes.
  • iOS simulator: 2,406/2,406 passed (215 XCTest + 2,191 Swift Testing; dedicated iPhone 17 Pro Max simulator, iOS 27.0), including SimulatedMeshAnnounceBudgetTests 21/21 and NicknameCommitTests 6/6.

🤖 Generated with Claude Code