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:
- 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.
- 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.
- 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).
- 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.
| Scenario | Before | After |
|---|---|---|
| Join, 6×6 grid, 2 anchors: originations / announce frames | 80-82 / 16,860-17,251 | 4 / 896 |
| One periodic round, busy 4×4 grid | 59-61 / 2,832-2,928 | 16 / 768 |
| Relayed announces over 3 churn cycles | 3 | 0 |
| 20 fresh signed identities on one link | 21 announces | 1 |
| 100 unsigned identities on one link | 100 announces | 0 |
| Completed handshake | 1 announce | 0 |
| Rename right after an announce | lost | reaches the peer |
| Unchanged nickname set again | 1 announce | 0 |
| Link between already-known nodes | 3 | 3 |
mesh.joinAnnounce benchmark (8×8 king grid; a phone joins 2 adjacent nodes), joins per second:
| Build | Before | After |
|---|---|---|
| Debug | 0.5-0.6 | 20-21 |
| Release | 0.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 scenario | Originations / announce frames, before | After |
|---|---|---|
| 7-node chain | 21 / 273-285 | 3 / 40 |
| 5×5 grid | 58-59 / ~4,800 | 4 / 336 |
| Each of 4 sequential joins into a 4×4 grid | ~43 / ~4,000 | 4 / 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'sdeliverAnnounceUIEventsclosure;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).
sprayCourierMailsent 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
scenePhaseuse is view-level, inContentHeaderView, to commit the nickname draft.
Tests
- Harness:
SimulatedMeshcounts 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.joinAnnouncebenchmark, 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
SimulatedMeshAnnounceBudgetTests21/21 andNicknameCommitTests6/6.
🤖 Generated with Claude Code
Activity
13This pull request is a draft
Merging is unavailable until it is marked ready for review.