#1809

Coalesce per-announce peer-list updates and make their consumers incremental

Openjackjackbitswants to merge intofrom

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 UserDefaults and 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 ConversationStore audit 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

CommitWhat it does
Add peer-list fan-out benchmarks6 benchmarks for the paths below (PerformanceBaselineTests+PeerList.swift).
Count each mesh peer once per day in memoryMeshSightingsTracker.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 engineThe 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 announcesUnverified and registry-refused announces never touch the registry, so they no longer cost a UI hop.
Stop registering ephemeral sessions on every peer-list updateRemoves a per-peer identity-queue barrier for a map with no production reader.
Keep the registry's connected count instead of recountingconnectedCount 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 changeEquality-guarded writes; PrivateConversationModel and VerificationModel stop republishing per peer snapshot.
Audit the store in debug builds only; prune receipts after removalsThe 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 unreadOne unread snapshot per refresh, with an empty-set early exit.
Fingerprint each roster key once per rebuildRemoves a double SHA-256 per peer in UnifiedPeerService.updatePeers.
Rebuild the mesh palette only when its seeds changeMirrors #1788 for the mesh list, keyed on roster membership.
Gate peer-list benchmarks with performance floors6 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, beforeAfterPer snapshot publish, beforeAfter
20020.5 ms0.25 ms2.33 ms1.50 ms
506.18 ms0.12 ms0.59 ms0.54 ms
102.88 ms0.15 ms0.58 ms0.17 ms

Benchmarks (ops/s):

BenchmarkDebug before → afterRelease before → after
peerList.update N=20051 → 2,763 (54×)54 → 6,611 (122×)
peerRegistry.reachability136k → 2.65M (19×)263k → 5.8M (22×)
peerList.refresh378 → 1,665 (4.4×)636 → 2,581 (4.1×)
peerList.refreshUnread374 → 1,202 (3.2×)624 → 2,579 (4.1×)
mesh.peopleListColors5,074 → 776,637 (153×)12,614 → 4.0M (321×)
peers.rosterRebuild1,054 → 1,735 (1.6×)2,799 → 3,318 (1.2×)

Emissions per update at 200 peers:

EmissionBeforeAfter
MeshSightingsTracker.objectWillChange2000
$encryptionStatuses80
PrivateConversationModel.objectWillChange160
VerificationModel.objectWillChange160

Sightings: 98 µs → 0.13 µs per already-counted peer.

Deliveries (SimulatedMesh, deterministic clock):

ScenarioBeforeAfter
Join burst13-15 list deliveries1 + 1 trailing
Known-peer re-announce burst81 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)
    • BLEPeerListRefreshThrottleTests
    • SimulatedMeshPeerListRefreshTests (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
    • UnifiedPeerService fingerprint
    • 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 .peerListUpdated at 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 by SimulatedMeshPeerListRefreshTests and the announce handler tests.
  • Counter drift: connectedCount is 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

Activity

20
chatgpt-codex-connector[bot]6d ago

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code Review✅ Completed 2026-10-25T01:19:45.379161Zebe9e9fPR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.