Summary
In a dense mesh, every verified announce sent its own .peerListUpdated to the main actor. A crowd re-announces every ~10 s, so a node hears about N/10 announces a second. Each delivery then walked all N peers on the main actor:
- mesh sightings hashed every already-seen peer, and each sighting wrote
UserDefaultsand published once; - every reachability check re-counted the registry;
- an identity-queue barrier was queued per peer, for a map nothing reads;
- unchanged encryption statuses and DM header state were republished;
- a full
ConversationStoreaudit ran; - unread state was rebuilt per row;
- the mesh palette rebuilt O(N²).
Measured (release, M5 Max): 20.5 ms of main-thread work per announce at 200 peers. That is roughly 55-100 ms on an older iPhone, at up to ~20 announces/s, which saturates the main thread in a busy mesh.
This PR throttles the announce-driven refresh on the engine and makes each consumer incremental: 0.25 ms per update at 200 peers, and at most 2 deliveries per second regardless of crowd size. One commit per finding; each builds and passes its suites on its own.
Changes
| Commit | What it does |
|---|---|
| Add peer-list fan-out benchmarks | 6 benchmarks for the paths below (PerformanceBaselineTests+PeerList.swift). |
| Count each mesh peer once per day in memory | MeshSightingsTracker.recordSightings(of:) takes the present set. It hashes only peers that were absent from the previous batch, and writes only when someone is new today. It drops the per-sighting lastSeenAt write and publish (nothing displayed them). The stored hash format is byte-identical, so today's tally survives the upgrade. |
| Throttle announce-driven peer-list refreshes on the engine | The first change after a quiet 0.5 s refreshes at once; later ones collapse into one trailing refresh that reads the registry when it runs. Unchanged: .peerConnected/.peerDisconnected, initial sync, and every message/ACK path stay per-event and ordered. A newcomer's roster entry is still published at once, so its first DM resolves. |
| Refresh the peer list only for verified announces | Unverified and registry-refused announces never touch the registry, so they no longer cost a UI hop. |
| Stop registering ephemeral sessions on every peer-list update | Removes a per-peer identity-queue barrier for a map with no production reader. |
| Keep the registry's connected count instead of recounting | connectedCount is maintained on every mutation, so isReachable is O(1) instead of an O(N) filter (O(N²) per update). |
| Publish encryption statuses and DM header state only on change | Equality-guarded writes; PrivateConversationModel and VerificationModel stop republishing per peer snapshot. |
| Audit the store in debug builds only; prune receipts after removals | The audit's only output is SecureLogger, which compiles to a no-op outside DEBUG, so release skips it. Debug samples it (first + every 32nd). The receipt prune skips work until a receipt can actually be stale. |
| Skip per-row unread work when nothing is unread | One unread snapshot per refresh, with an empty-set early exit. |
| Fingerprint each roster key once per rebuild | Removes a double SHA-256 per peer in UnifiedPeerService.updatePeers. |
| Rebuild the mesh palette only when its seeds change | Mirrors #1788 for the mesh list, keyed on roster membership. |
| Gate peer-list benchmarks with performance floors | 6 floors in perf-floors.json. |
The 7 Address review: commits come from a 3-lens review:
- a relayed newcomer mid-window still gets its roster publish;
- unread DMs from a peer the transport still lists are kept;
- the panic wipe drops the formatter's palettes and the injected sightings tracker;
- the new tests leave no preference plists behind;
- doc fixes.
Measured
M5 Max. Before = origin/main with the new benchmarks; interleaved runs, medians.
End to end: a real radio-less BLEService registry and identity manager, release build, including the downstream main-queue work.
| Peers (K=8 connected) | Per announce, before | After | Per snapshot publish, before | After |
|---|---|---|---|---|
| 200 | 20.5 ms | 0.25 ms | 2.33 ms | 1.50 ms |
| 50 | 6.18 ms | 0.12 ms | 0.59 ms | 0.54 ms |
| 10 | 2.88 ms | 0.15 ms | 0.58 ms | 0.17 ms |
Benchmarks (ops/s):
| Benchmark | Debug before → after | Release before → after |
|---|---|---|
peerList.update N=200 | 51 → 2,763 (54×) | 54 → 6,611 (122×) |
peerRegistry.reachability | 136k → 2.65M (19×) | 263k → 5.8M (22×) |
peerList.refresh | 378 → 1,665 (4.4×) | 636 → 2,581 (4.1×) |
peerList.refreshUnread | 374 → 1,202 (3.2×) | 624 → 2,579 (4.1×) |
mesh.peopleListColors | 5,074 → 776,637 (153×) | 12,614 → 4.0M (321×) |
peers.rosterRebuild | 1,054 → 1,735 (1.6×) | 2,799 → 3,318 (1.2×) |
Emissions per update at 200 peers:
| Emission | Before | After |
|---|---|---|
MeshSightingsTracker.objectWillChange | 200 | 0 |
$encryptionStatuses | 8 | 0 |
PrivateConversationModel.objectWillChange | 16 | 0 |
VerificationModel.objectWillChange | 16 | 0 |
Sightings: 98 µs → 0.13 µs per already-counted peer.
Deliveries (SimulatedMesh, deterministic clock):
| Scenario | Before | After |
|---|---|---|
| Join burst | 13-15 list deliveries | 1 + 1 trailing |
| Known-peer re-announce burst | 8 | 1 at the window's end |
Projected older iPhone (3-5× slower), 200 peers:
- Before: ~1-2 s of main-thread work per second (saturated).
- After: ~10-18 ms per second.
Behavior changes
- Lag: announce-driven peer-list changes can show up to 0.5 s later. The first change after a quiet window is immediate.
- Debug log format: debug builds log the store audit's heartbeat less often (sampled); violations are still logged.
- Deferred: P136 (bulk DM-thread migration on link flap) gets its own PR. The link-loss path is unchanged here.
Tests
- New, each mutation-checked (the test fails with the fix reverted):
MeshSightingsTrackerTests(8)BLEPeerListRefreshThrottleTestsSimulatedMeshPeerListRefreshTests(join burst, steady state, roster freshness)BLEPeerRegistryTests(seeded 2,000-step recount equivalence)- encryption-status and DM-header republish tests
- read-receipt prune tests (mock and real view model)
- unread snapshot
UnifiedPeerServicefingerprint- palette membership tests
- Changed:
BLEAnnounceHandlerTests' six unverified/refused cases now expect no delivery. - SwiftPM: 2,199 Swift Testing + 220 XCTest, 4 full parallel runs (2 from a fresh build), 0 failures.
- Builds and static checks: macOS build has 0 warnings. SwiftLint and Periphery show no new findings vs main.
- Perf gate: all floors pass on the first attempt. The 6 new floors are ~10% of local debug, so calibrate them from the first CI run.
- iOS simulator: 2,422/2,422 passed (dedicated iPhone 17 Pro Max simulator, iOS 27.0), including every new suite.
- Independent re-measurement in verification (release, interleaved against main): the synchronous cost of one
.peerListUpdatedat 200 peers went from 17.45 ms to 0.13 ms (135×). - Known minor issue: the benchmark fixture can leave empty
PerformanceBaselineTests.peerList.*preference plists behind (cfprefsd writes them back after the domain is removed), as several existing tests already do.
Risks
- Ordering: the throttle lives on the engine, and the main-actor hop keeps
.peerConnected→ initial sync → snapshot publish →.peerListUpdated. Pinned bySimulatedMeshPeerListRefreshTestsand the announce handler tests. - Counter drift:
connectedCountis maintained incrementally. Every write to the registry goes through two helpers, pinned by the randomized recount test. - Unchanged areas: no wire-format, Tor, or app-lifecycle changes.
🤖 Generated with Claude Code
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
ebe9e9fℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.