Repository navigation
NE memory self-trace + in-app memory usage chart - #239
Conversation
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).
[Medium impact] The PR appears safe to merge based on this re-review. SummaryAdds a memory trace written by the Network Extension and a Memory Usage page in Settings.
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]
Reviews (10) · Last reviewed commit: "ci: re-trigger review of the flush-order..." · Reviewed by Greptile |
On-device verification (iPhone 14, iOS 27.0.1, USB)Built to the device ( What this proves:
Path change forced by the deviceThe ring originally sat at the App-Group container root. Still not coveredThe 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. |
Chart verified rendering on device
What the screen showed, all from the on-device ring:
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 |
…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.
…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.
…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).
|
@greptile review |
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_rusageon a foreign pid is EPERM (andlibproc.his not in the iOS SDK at all), and there is no programmatic jetsam report - a memory kill is a bareSIGKILLthat delivers no callback, andNEProviderStopReasonhas 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) - samplestask_info(TASK_VM_INFO).phys_footprinton a detached nativeThread(the NE's main dispatch thread is blocked during bring-up and announce floods, so aTask/Timersampler 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 iscompleteUntilFirstUserAuthentication, re-asserted on open, so sampling survives a locked screen.NEMemoryHistory- segments the trace by pid. A generation that ends with no.stopmarker 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_RAWvs 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 instartTunnel(after the hosting-mode guard, so a boot marker exists even if bring-up later wedges),recordStop(reason:)first thing instopTunnel(before best-effort teardown, so a clean stop is recorded even if teardown hangs).Metric facts that shaped the design
phys_footprint, notresident_size: the kernel'smemorystatus ... ActiveHardceiling 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 recordstask_vm_info.limit_bytes_remainingand treats 0 as unknown.measureRNSFootprint()sampler has ever existed in this repo's history (git grepacross every rev: nothing);docs/MODEL_B_TESTING_TODO.md§3 claimed one did, and claimedNEProviderStopReason.memoryLowexists. Both corrected, along with the PASS criterion that keyed offos_proc_available_memory() == 0.Bugs found while building it
stopTunnelimmediately followed bystartTunnelinside 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.windowMaxfield, 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
ColumbaAppandColumbaNetworkExtensioncompiled.Tests/static/test_modelb_target_isolation.rb7/7 (the new Shared file is in both targets). The three static suites that fail (attachment_preview,task11,task9) fail identically onmain- 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 theidevicesyslogjetsamEventcross-check against the chart's kill cards.