Skip to content

NE memory self-trace + in-app memory usage chart - #239

Merged
torlando-tech merged 16 commits into
mainfrom
feat/ne-memory-chart
Oct 27, 2026
Merged

torlando-tech merged 16 commits into
mainfrom
feat/ne-memory-chart

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

Why

We cannot see what the Network Extension is using before it dies. iOS gives an app no way to read another process's footprint: proc_pid_rusage on a foreign pid is EPERM (and libproc.h is not in the iOS SDK at all), and there is no programmatic jetsam report - a memory kill is a bare SIGKILL that delivers no callback, and NEProviderStopReason has no memory case.

So the extension has to record itself, and the app charts what it wrote.

What ships

  • Sources/Shared/NEMemoryTrace.swift (compiled into both targets) - samples task_info(TASK_VM_INFO).phys_footprint on a detached native Thread (the NE's main dispatch thread is blocked during bring-up and announce floods, so a Task/Timer sampler silently records nothing) and writes a fixed 8192 x 64 B ring plus an in-place live slot to <App Group>/ne-memory.bin. Cadence is batched - 1 Hz sample, live write >= 5 s, bucket flush >= 60 s, both forced on a >= 1 MB jump - because a per-sample persist has been measured to accelerate the very kill it measures. Every record carries an FNV-1a checksum, so a slot torn open by a kill is skipped instead of plotted as a plausible-looking wrong sample. File protection is completeUntilFirstUserAuthentication, re-asserted on open, so sampling survives a locked screen.
  • NEMemoryHistory - segments the trace by pid. A generation that ends with no .stop marker is a kill: absence is the only evidence available. Reports last-seen and lifetime-peak footprint for the dead generation, restarts, and 24 h peak.
  • NEMemoryView - Settings -> Reticulum Hosting -> Memory Usage. Swift Charts line, broken into separate series at process boundaries and at device-sleep gaps (CLOCK_UPTIME_RAW vs wall clock, so sleep is not mistaken for idle), shaded spike envelope per sample window, dashed kill markers, a derived iOS-limit rule when the OS reports remaining budget, and a card naming the memory at the kill. Also shows the app's own footprint, which is the half iOS lets us measure directly.
  • PacketTunnelProvider - start() first thing in startTunnel (after the hosting-mode guard, so a boot marker exists even if bring-up later wedges), recordStop(reason:) first thing in stopTunnel (before best-effort teardown, so a clean stop is recorded even if teardown hangs).

Metric facts that shaped the design

  • phys_footprint, not resident_size: the kernel's memorystatus ... ActiveHard ceiling is enforced on footprint, and RSS also counts shared file-backed pages.
  • os_proc_available_memory() is unusable as a ceiling probe here - it returns 0 for a process that "isn't an app", which an app extension may be, so 0 can never be read as "at the limit". The trace records task_vm_info.limit_bytes_remaining and treats 0 as unknown.
  • No measureRNSFootprint() sampler has ever existed in this repo's history (git grep across every rev: nothing); docs/MODEL_B_TESTING_TODO.md §3 claimed one did, and claimed NEProviderStopReason.memoryLow exists. Both corrected, along with the PASS criterion that keyed off os_proc_available_memory() == 0.

Bugs found while building it

  • A stopTunnel immediately followed by startTunnel inside one process flipped the shared running flag back to true before the old sampler thread read it, so the old thread never exited and the ring gained a second permanent writer. Threads now exit on their own generation token.
  • Boot/stop markers store the process-lifetime peak in the windowMax field, so plotting markers drew a spike at every boot and teardown and inflated the 24 h peak. Markers are events now, not samples.

Tests

Tests/ColumbaAppTests/NEMemoryTraceTests.swift (17): byte-layout round trip, torn/empty/short slot rejection, foreign-file rejection, ring wrap ordering, flush/live-write policy, kill vs clean-stop attribution, the ambiguous newest generation, stale live-slot merge suppression, chart continuity across sleep and process boundaries, and an end-to-end three-generation file (clean stop / killed mid-ramp / alive) that pins the exact figures the UI prints.

Full app test target on the Mac mini, iPhone 17 simulator: 643 tests, 0 failures, both ColumbaApp and ColumbaNetworkExtension compiled. Tests/static/test_modelb_target_isolation.rb 7/7 (the new Shared file is in both targets). The three static suites that fail (attachment_preview, task11, task9) fail identically on main - pre-existing.

Not verified

On-device: that the real NE writes the ring over hours of background running, and that the chart renders on a phone. Needs a device build; the simulator cannot bring up the tunnel without a consent dialog. docs/MODEL_B_TESTING_TODO.md §3c now describes the idevicesyslog jetsamEvent cross-check against the chart's kill cards.

The app cannot measure the Network Extension: it is a separate sandboxed
process, `proc_pid_rusage` on a foreign pid is EPERM (and libproc.h is not
even in the iOS SDK), and iOS exposes no programmatic jetsam report. So the
NE now records its own footprint and the app charts what it wrote.

- `Sources/Shared/NEMemoryTrace.swift` (app + NE): `task_info(TASK_VM_INFO)`
  sampler on a detached native Thread — the NE's main dispatch thread is
  blocked during bring-up and announce floods, so a Task/Timer sampler
  silently records nothing. Writes a fixed 8192x64B ring + an in-place live
  slot to `ne-memory.bin` in the App-Group container, with a per-record FNV-1a
  checksum so a slot torn by a SIGKILL is rejected instead of plotted as a
  bogus sample. Cadence is batched (1 Hz sample, live write >= 5 s, bucket
  flush >= 60 s, both forced on a >= 1 MB jump) because a per-sample persist
  has been observed to accelerate the very kill it measures. File protection
  is `completeUntilFirstUserAuthentication`, re-asserted on open, so sampling
  survives a locked screen.
- `NEMemoryHistory`: segments the trace by pid. A generation ending with no
  `.stop` marker is a kill — `NEProviderStopReason` has no memory case and a
  memory kill delivers no callback, so absence is the only evidence. Reports
  last-seen and lifetime-peak footprint for the killed generation.
- `NEMemoryView` (Settings -> Reticulum Hosting -> Memory Usage): Swift Charts
  line with segments broken at process boundaries and device-sleep gaps
  (CLOCK_UPTIME_RAW vs wall clock), kill markers, derived iOS-limit rule line
  when the OS reports remaining budget, and a card naming the memory at kill.
- `PacketTunnelProvider`: start on `startTunnel`, stop marker on `stopTunnel`.
- Tests: byte-layout round trip, torn/empty/short slot rejection, ring wrap
  ordering, flush policy, kill vs clean-stop attribution, the ambiguous newest
  generation, stale live-slot merge suppression, chart continuity.

Metric notes: `phys_footprint` is the number the kernel's `ActiveHard` ceiling
is enforced on; `resident_size` is not comparable. `os_proc_available_memory()`
is unused because it returns 0 for processes that "isn't an app", so 0 cannot
be read as "at the limit" in an extension; `limit_bytes_remaining` is recorded
with 0 = unknown. `docs/MODEL_B_TESTING_TODO.md` §3 corrected accordingly
(including the claim that a `measureRNSFootprint()` sampler exists in history:
it never did).
@greptile-apps

greptile-apps Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge based on this re-review.

Summary

Adds a memory trace written by the Network Extension and a Memory Usage page in Settings.

  • The background transport records its own memory as it runs.
  • Settings shows memory history and marks runs that ended without a stop.
  • Extension diagnostic logs live where device tools can retrieve them.
  • Existing test references stay attached to their Xcode targets.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Network Extension] --> B[Native sampler thread]
  B --> C[Shared memory trace file]
  C --> D[NEMemoryHistory]
  E[Tunnel state] --> D
  D --> F[Memory Usage page]
  C --> G[Launch-time Documents copy]
Loading

Reviews (10) · Last reviewed commit: "ci: re-trigger review of the flush-order..." · Reviewed by Greptile

Comment thread Sources/Shared/NEMemoryTrace.swift Resolved
@torlando-tech

Copy link
Copy Markdown
Owner Author

On-device verification (iPhone 14, iOS 27.0.1, USB)

Built to the device (BUILD SUCCEEDED, INSTALL_EXIT=0), launched, and the NE came up on its own via the armed on-demand rule (PID 6881 alongside the app, PID 6882). Pulled the ring straight off the phone:

xcrun devicectl device copy from --device <UDID> --domain-type appGroupDataContainer \
  --domain-identifier group.network.columba.Columba \
  --source Library/ne-memory.bin --destination /tmp/ne-memory.bin
magic_ok=True cap=8192 writeIndex=4 recordCount=4 boots=1 bytes=524416 expected=524416
ring_records=4 torn=0 live_slot=present
  ring  17:03:41 boot   pid=6881 fp=  3.5 winmax=  3.5 peak=  3.5 lim= 46.5
  ring  17:03:42 bucket pid=6881 fp= 12.5 winmax= 12.5 peak= 13.0 lim= 37.5
  ring  17:03:44 bucket pid=6881 fp= 22.3 winmax= 22.3 peak= 23.2 lim= 27.7
  ring  17:04:44 bucket pid=6881 fp= 22.2 winmax= 22.3 peak= 23.2 lim= 27.8
  live  17:05:15 live   pid=6881 fp= 22.2 winmax= 22.2 peak= 23.2 lim= 27.8
  derived iOS ceiling = footprint + headroom = 50.0 MB
generations=[6881] stop_markers=0

What this proves:

  • The file is byte-exact: 524,416 B = 64 header + 64 live slot + 8192 x 64 ring. Every checksum validates, torn=0.
  • The boot marker catches the process before Python loads (3.5 MB), then the ramp is visible: 3.5 -> 12.5 -> 22.3 MB as the Reticulum runtime initializes. That step is exactly the number this feature exists to see.
  • Cadence is as designed: buckets at 17:03:44 and 17:04:44 (60 s), live slot rewritten 31 s later. Steady-state footprint 22.2 MB with the sampler running - no measurable self-cost.
  • limit_bytes_remaining works on device and the derived limit is exactly 50.0 MB (22.2 + 27.8). That independently confirms the documented ActiveHard ceiling for NE-class processes and validates using limit_bytes_remaining instead of os_proc_available_memory(): the chart's limit line is derived from what iOS actually enforces, not hardcoded.

Path change forced by the device

The ring originally sat at the App-Group container root. devicectl device copy from only exposes Library / Documents / tmp inside a container, so the file resolved but could not be pulled (remoteservices error 11007) - which defeats the purpose of keeping it. Moved to <App Group>/Library/ne-memory.bin (3fc28ab), with the parent directory created on demand, and both docs updated with the working pull command.

Still not covered

The chart itself needs one tap on the phone (Settings -> Reticulum Hosting -> Memory Usage); the data feeding it is now proven end-to-end from the device. Kill attribution is unit-tested but not exercised on hardware - it needs a real jetsam kill, which cannot be provoked on demand at 22 MB of a 50 MB budget.

@torlando-tech

Copy link
Copy Markdown
Owner Author

Chart verified rendering on device

devicectl has no touch injection, so navigation used a DEBUG-only launch argument (fb2cad9) following the existing ui-screenshotter convention: MainTabView presents NEMemoryView in a full-screen cover when ui-open-memory-usage is in ProcessInfo.arguments. Unreachable in Release.

xcrun devicectl device process launch --device <UDID> --terminate-existing \
  network.columba.Columba ui-open-memory-usage
xcrun devicectl device capture screenshot --device <UDID> --destination /tmp/columba-memory.png

What the screen showed, all from the on-device ring:

  • Current 22.1 MB now / 27.9 MB left, running 54 s.
  • Peak (24 h) 24.0 MB, Restarts (24 h) 2, Samples 25.
  • Orange dashed iOS limit line at ~50 MB - the derived limit, computed from footprint + limit_bytes_remaining, not hardcoded.
  • Purple footprint line at 22-24 MB with the shaded in-window band, a gap where nothing was recorded, and a red dashed kill marker. Line segments were not bridged across the gap.
  • Kill card: "Stopped unexpectedly - Last recorded 23.9 MB, peak 24.1 MB - 2 minutes ago, 26.1 MB of headroom was left at the last sample."

Worth being precise about that last card: the generation it names was terminated by this reinstall, not by memory pressure. That is the design behaving as specified - no .stop marker means the process was killed, and the copy deliberately says "stopped unexpectedly" rather than claiming memory was the cause. A true jetsam kill still has not been exercised, because at 22 MB of a 50 MB budget there is nothing to force it with.

…ng to Documents

- ExtensionDiagLog: write under the App Group container's Library/ (devicectl
  can only pull Library/Documents/tmp from a container; a root-level log was
  unreachable for post-kill forensics - same constraint the memory ring hit).
  Create the directory on first write.
- NEMemoryHistory.stopReasonName: map the NEProviderStopReason raw value a
  .stop marker carries to the SDK's case names (values from the iPhoneOS 27.1
  SDK NEProvider.h); unknown codes fall through to a raw-value string.
- NEMemoryView: show 'Teardown reason: ...' when the newest generation ended
  cleanly instead of leaving clean stops unexplained.
- DiagLog.copyMemoryRingToDocuments: snapshot the NE's memory ring into the
  app's Documents once per launch (mtime-guarded), so the post-kill footprint
  history is pullable via appDataContainer even if the App-Group copy is
  rotated out.
Comment thread Sources/Shared/ExtensionDiagLog.swift Resolved
…onnecting, clock-skew gaps, lastSeen, legacy log migration, live reload

- NEMemoryTrace.start(): flush a foreign (dead generation) live slot into the
  ring before the new boot marker — the live slot is the freshest record
  (5 s cadence vs the ring's 60 s) and the reader ignores one whose pid no
  longer matches the newest generation, so a kill+relaunch lost up to 60 s of
  the dead process's final memory.
- NEMemoryHistory.analyze: replace the connected/not boolean with a tri-state
  TunnelState (.alive/.gone/.undecided mapped from NEVPNStatus). .connecting
  no longer shows 'stopped unexpectedly' for a recording process; an
  undecided state shows neither a kill nor a current reading.
- Kill card 'last recorded' is now the generation's last footprint, not its
  maximum; the peak field still reports the maximum.
- Chart continuity: break the line when wall time runs ahead of the uptime
  clock by more than 30 s (device sleep), even below the 180 s wall threshold.
- NEMemoryView: observe the TunnelManager live (5 s reload loop + onChange)
  instead of freezing the state captured at construction.
- ExtensionDiagLog.migrateLegacyRootLog: move the pre-move root ext-diag.log
  (and .1) into Library/ at app launch so upgraded devices keep the previous
  session's diagnostics pullable.
Comment thread Sources/Shared/NEMemoryTrace.swift Resolved
…t record

Greptile round 2: a clean-stopped generation's stale live record appended
after its .stop marker made the generation read as killed, and repeated early
restarts could re-append the same stale record. The boot-time flush now
skips when the ring's tail is at least as new (clean stop, or an earlier boot
already flushed it).
@torlando-tech

Copy link
Copy Markdown
Owner Author

@greptile review

@torlando-tech
torlando-tech merged commit c99ae58 into main Oct 27, 2026
3 checks passed
@torlando-tech
torlando-tech deleted the feat/ne-memory-chart branch October 27, 2026 00:51
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