Skip to content

perf(ingest): bound memory under sustained packet load — actor recycling, bounded event stream, DM badge throttle#2106

Open
garthvh wants to merge 3 commits into
mainfrom
perf/ingest-batching
Open

perf(ingest): bound memory under sustained packet load — actor recycling, bounded event stream, DM badge throttle#2106
garthvh wants to merge 3 commits into
mainfrom
perf/ingest-batching

Conversation

@garthvh

@garthvh garthvh commented Jul 17, 2026

Copy link
Copy Markdown
Member

Follow-up to #2105 — together they're the two halves of the stress-replay findings: #2105 fixed the main-thread view storms; this bounds the ingestion side's memory.

The problem

Two unbounded accumulators, found by heap analysis of a long session against a 1,600-node DEF-CON-shaped TCP replay at ~140 packets/second:

  1. The ingest actor's ModelContext never lets go. Every entity it inserts or faults stays registered. MeshPackets.recreateShared() exists for exactly this — its doc comment says to call it periodically — but it was only invoked at connect time, so a session that stays connected accumulates without bound. A long run reached over a million live model objects, with the SwiftData observation machinery alone holding hundreds of MB.
  2. The transport event stream buffers without limit. AsyncStream's default unbounded policy let the TCP reader queue every frame the main-actor consumer hadn't gotten to. Under sustained overload the backlog grew to essentially the whole session's packets — hundreds of thousands of live protobufs — while the app processed traffic minutes old.

Plus a small quadratic: the per-DM unread-badge recount is O(unread) and ran for every incoming DM (the channel path was already throttled).

The fixes

  • Periodic ingest recycle: didReceive flushes pending debounced saves and recycles the MeshPackets actor every 5,000 packets while subscribed — between packets (no in-flight handler holds the retiring instance) and never during node-DB retrieval. Proportional to traffic: a quiet mesh may never recycle, which is fine.
  • Bounded event streams: all three transports use .bufferingNewest(4096). Under sustained overload the oldest frames drop first — stale mesh traffic, what a saturated real radio would shed. A node-DB dump fits well within the bound; error/teardown events are the newest when they occur so they can't be displaced. The TCP reader logs drops so backpressure is visible.
  • DM unread recount throttled to ~1/sec, matching the existing channel-badge throttle.
  • DEBUG launch argument -meshtastic-connect-tcp <host[:port]> connects straight to a TCP radio at startup, making stress runs fully scriptable (simctl launch + replay server) with no UI automation. Same pattern as the existing --marketing-capture / perf-seed hooks.

Verification (same replay, before → after)

  • Live model objects (heap analysis): >1.3M → under 400K, bounded by the recycle window instead of session length.
  • Stream backlog: ~all session packets → pinned at the 4,096 bound with drops logged.
  • RSS: previously grew unbounded (multi-GB in under half an hour, plateau never reached); now holds flat for minutes at a time with steps tracking replay phases, at a fraction of the old footprint.
  • Badges, messages, map, node list, and the Discovery analyze all behave normally under flood; a real radio's packet rates never approach the buffer bound or drop path.

Known remainder (future work)

The main context still grows slowly under flood (message-view fetches, notification donations) and the consumer saturates around half the replay's rate, so a sustained >100pps source sheds oldest traffic by design. Both are follow-up material; neither affects real-mesh rates.

Summary by CodeRabbit

  • Performance

    • Improved memory management during extended device connections.
    • Reduced unnecessary unread-message badge recomputation during high-volume messaging.
  • Reliability

    • Improved stability when receiving sustained bursts of Bluetooth, serial, or TCP data by preventing unbounded connection event backlogs.
    • Added safeguards to drop excess connection events under overload instead of queueing indefinitely.
    • Added connection-state reset when starting a new connection attempt.

garthvh added 2 commits July 16, 2026 23:04
…e DM unread recount

The ingest actor's ModelContext registers every entity it inserts or
faults and never releases them. recreateShared() exists for exactly
this ('call ... periodically to release accumulated memory') but was
only invoked at connect time, so a session that stays connected under
sustained traffic accumulates registered objects without bound — a
long TCP stress-replay run reached millions of live model objects and
multi-gigabyte RSS, with the SwiftData observation machinery alone
holding hundreds of megabytes.

didReceive now flushes pending debounced saves and recycles the actor
every 20,000 packets while subscribed — between packets, where no
in-flight handler holds the retiring instance, and never during the
node-DB retrieval phase. On a busy mesh that's every few minutes; on a
quiet mesh it may never fire, which is fine because accumulation is
proportional to packets processed.

Also rate-limits the per-DM unreadMessages recount to ~1/sec, matching
the throttle the channel-badge path already has: the recount is
O(unread), so under a DM burst the per-message recompute turned the
badge update into quadratic work.
…-connect hook

The transport event streams used AsyncStream's default unbounded
buffering, so a producer faster than the main-actor consumer (a TCP
radio or replay can sustain >100 packets/s) queued every unprocessed
frame indefinitely: under a sustained stress replay the backlog grew
to effectively every packet of the session — hundreds of thousands of
live protobufs — while the app fell minutes behind real time.

All three transports now use .bufferingNewest(4096): under sustained
overload the OLDEST frames drop first (stale mesh traffic, exactly
what a saturated real radio sheds), a node-DB dump fits well inside
the bound, and error/teardown events are always the newest when they
occur. The TCP reader counts and logs drops so backpressure is
visible instead of silent. Also drops the ingest-recycle interval to
5,000 packets — at the consumer's real throughput 20,000 meant many
minutes between recycles, letting hundreds of MB of registered
objects pile up between them.

Adds a DEBUG-only launch argument for automated stress testing:
  -meshtastic-connect-tcp <host[:port]>
connects straight to a TCP radio at startup, so perf runs are fully
scriptable (simctl launch + replay server) with no UI automation.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 512be159-8860-459c-86fd-e9bba66a6126

📥 Commits

Reviewing files that changed from the base of the PR and between cbafc94 and 4e1f558.

📒 Files selected for processing (1)
  • Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Meshtastic/Accessory/Transports/TCP/TCPConnection.swift

📝 Walkthrough

Walkthrough

The changes bound BLE, serial, and TCP event streams, add TCP backpressure logging, periodically recycle packet ingestion state, throttle direct unread recomputation, and add a DEBUG-only TCP connection launch path.

Changes

Packet ingest and transport backpressure

Layer / File(s) Summary
Bounded transport event streams
Meshtastic/Accessory/Transports/...
BLE, serial, and TCP streams use .bufferingNewest(4096); TCP routes decoded frames through a helper that logs saturated yields.
Periodic ingest actor recycling
Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift, Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
AccessoryManager tracks packet intervals, resets the counter for new connections, flushes debounced saves, and recreates the shared MeshPackets actor while subscribed.
Throttled direct unread recomputation
Meshtastic/Helpers/MeshPackets.swift
Direct-message unread badge recomputation is limited to approximately once per second.
Debug TCP connection harness
Meshtastic/MeshtasticApp.swift
DEBUG builds accept -meshtastic-connect-tcp, resolve a TCP device, and initiate an asynchronous connection after startup.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LaunchArguments
  participant MeshtasticAppleApp
  participant AccessoryManager
  participant TCPConnection
  LaunchArguments->>MeshtasticAppleApp: provide -meshtastic-connect-tcp host[:port]
  MeshtasticAppleApp->>AccessoryManager: resolve TCP device
  MeshtasticAppleApp->>AccessoryManager: connect(to:) after startup delay
  AccessoryManager->>TCPConnection: establish packet stream
Loading

Poem

I’m a rabbit with packets tucked neat,
Bounded streams hop on nimble feet.
Actors recycle, unread counts slow,
TCP backpressure starts to show.
Debug carrots connect with a glow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main change: bounding ingest memory with actor recycling, bounded streams, and DM badge throttling.
Description check ✅ Passed The description provides substantive problem, fix, and verification details, so it is mostly complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Meshtastic/Accessory/Transports/TCP/TCPConnection.swift`:
- Around line 212-219: Update yieldDataEvent in yieldDataEvent(_:) to inspect
the continuation.yield result as .enqueued(let remaining), treating remaining ==
0 as the backpressure/drop signal; retain the existing counter increment and
periodic warning behavior within that condition, and remove the ineffective
.dropped handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cc53971c-e03c-4666-9fa5-e812f89e4d5a

📥 Commits

Reviewing files that changed from the base of the PR and between e531f15 and cbafc94.

📒 Files selected for processing (7)
  • Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
  • Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
  • Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift
  • Meshtastic/Accessory/Transports/Serial/SerialConnection.swift
  • Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
  • Meshtastic/Helpers/MeshPackets.swift
  • Meshtastic/MeshtasticApp.swift

Comment thread Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
…ropped

With .bufferingNewest the new element is always enqueued and the
OLDEST buffered frame is evicted, so yield never returns .dropped and
the backpressure warning was dead code. Count yields that report zero
remaining capacity instead — each one evicts an oldest frame (to
within the one yield that exactly fills the buffer). Verified live:
the warning now fires under a saturating replay.
@github-actions

Copy link
Copy Markdown

📄 Docs staleness warning

This PR modifies user-facing Swift source files but does not update any page under docs/user/ or docs/developer/.

Changed source files:

Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift
Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift
Meshtastic/Accessory/Transports/Serial/SerialConnection.swift
Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
Meshtastic/Model/Firmware/FirmwareUpdateNotifier.swift
Meshtastic/Views/Connect/Connect.swift

What to check:

Changed area Likely doc page
Views/Messages/ docs/user/messages.md
Views/Nodes/ docs/user/nodes.md
Views/Map/ docs/user/map.md
Views/Settings/Bluetooth/ docs/user/bluetooth.md
Views/Settings/Discovery/ docs/user/discovery.md
Views/Settings/MQTT/ docs/user/mqtt.md
Views/Settings/TAK/ docs/user/tak.md
Views/Settings/Firmware/ docs/user/firmware.md
Views/Settings/ (telemetry/sensor) docs/user/telemetry.md
Views/Settings/ (general) docs/user/settings.md
Meshtastic Watch App/ docs/user/watch.md
Model/ docs/developer/swiftdata.md or docs/developer/architecture.md
Accessory/Transports/ docs/developer/transport.md

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this warning.

After updating docs/, re-run bash scripts/build-docs.sh --output Meshtastic/Resources/docs locally and commit the regenerated HTML bundle.

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