feat(vpn): adaptive memory monitor for the mobile VPN daemon#550
Conversation
Add vpn/memmon, a Normal/Soft/Hard memory-pressure monitor with hysteresis and cliff prediction, plus an admission gate that rejects new connections at the clashServer dial path while pressure is high. Both are wired into the tunnel on mobile only and torn down with it. Native signals are platform-gated: iOS reads phys_footprint via task_info and jetsam headroom via os_proc_available_memory; Android reads /proc/self/statm RSS; other targets fall back to the Go heap. Crash dumps are written atomically so a jetsam SIGKILL mid-write cannot truncate them.
Active reclamation and admission gating only make sense on iOS, where the Network Extension is a dedicated process under a real jetsam cap. Android exposes no queryable per-process memory budget and the OS already protects the foreground VPN service, so reacting on a synthetic cap only misfires. - Restrict the admission gate and reclaim executor to iOS; on Android the monitor runs observe-only (nil executor, no gate) against a generous display-only cap so it logs usage without acting. - Scope the box high-memory reject rule to iOS to match. - Add structured logging to the monitor: per-tick DEBUG (throttled to a heartbeat while Normal), level-change INFO, and reclaim, crash-dump, and admission-gate events. - Read the Android footprint from /proc/self/status VmRSS instead of /proc/self/statm, whose resident field the kernel documents as approximate. - Drop noisy bypass-proxy trace log.
radiance's slog handler casts any attribute keyed "level" (slog.LevelKey) to slog.Level, so the "memory tick" log passing a string under that key panicked on the first tick and crashed the daemon on connect. Rename the attribute to "pressure_level".
The iOS Network Extension's resting footprint runs at ~0.8 of its ~50 MB jetsam ceiling, which sat above the old SoftEnter (0.75) and above SoftExit (0.65) — a threshold the process can never reach. The monitor therefore entered Soft within seconds of connect and never recovered to Normal, soft-evicting the oldest connections every ~2s for the entire session without meaningfully reducing the footprint. Raise SoftEnter to 0.88 and SoftExit to 0.80 so the resting footprint stays Normal, Soft is reserved for a genuine climb toward the 0.92 Hard line, and SoftExit is reachable so the monitor can recover. Pin explicit thresholds into the engine-mechanics tests so they assert hysteresis/dwell/settle/prediction independent of production tuning, and add a guard that the production defaults keep a resting series in Normal while still entering Soft on a climb.
Soft eviction and the Hard close-all shared one FreeOSMinInterval gate, so a Hard reclaim firing within the interval of a soft scavenge skipped FreeOSMemory — suppressing the forced page release exactly when the footprint is closest to the cliff. On iOS, Hard can follow Soft within the window, since escalation is not blocked while the footprint rises. Route the Hard path through an unconditional freeOSMemoryNow; the rate-limited path now serves soft eviction only. The decision core already edge-triggers and hardCooldown-spaces Hard reclaim, so dropping the executor's floor for it cannot spin FreeOSMemory into a GC spiral.
# Conflicts: # common/settings/settings.go # internal/paths.go
Throttle the per-tick DEBUG log while pressure is elevated to elevatedTickLogInterval (5s) instead of one line per sub-second tick, and demote the per-batch soft-reclaim line from INFO to DEBUG since a soft episode is already bracketed by the INFO level-change lines. Level changes, hard reclaim, crash-dump, and admission-gate lines keep their levels — the noise reduction targets steady-state and soft-episode volume, not the rare high-signal events.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds a memory-pressure monitoring pipeline with sampling, decisioning, reclamation, crash dumps, and dial admission control. It wires the system into clash-server and tunnel startup, updates mobile memory-limit handling, and adds related config, env, and issue-report changes. ChangesMemory monitor and admission control
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Monitor
participant DecisionEngine
participant Executor
participant Reclaimer
Monitor->>DecisionEngine: Decide(Sample)
DecisionEngine-->>Monitor: Decision(level, flags, interval)
Monitor->>Executor: Apply(Decision, now)
Executor->>Reclaimer: CloseOldest / CloseAllConnections / FreeOSMemory
sequenceDiagram
participant Dialer
participant ClashServer
participant AdmissionGate
Dialer->>ClashServer: RoutedConnection/RoutedPacketConnection
ClashServer->>AdmissionGate: admitConnection / RecordDial(now)
AdmissionGate-->>ClashServer: setRejectMode(true/false)
ClashServer-->>Dialer: route connection or reject
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
Adds a new adaptive memory monitoring subsystem (vpn/memmon) and wires it into the mobile VPN tunnel to proactively reclaim resources (and optionally refuse new connections) under iOS jetsam pressure, while running observe-only on Android for visibility.
Changes:
- Introduces
vpn/memmon(sensor + decision engine + executor + admission gate + dump writer) with unit tests. - Integrates the monitor into tunnel bring-up, adds iOS-only reject routing rule, and provides tracker-backed connection eviction/close-all paths.
- Adds a single-slot memory dump file and includes it in issue-report attachments when present.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vpn/tunnel.go | Starts the memory monitor on mobile; limits Go memory settings to iOS; replaces mode-switch conntrack close with routed-connection close helper. |
| vpn/tunnel_test.go | Updates memory-limit ordering assertions to use the monitor budget constant. |
| vpn/memmon/types.go | Defines core types (Sample/Decision/Snapshot/levels), pressure ratio helpers, and logging helpers. |
| vpn/memmon/sensor.go | Implements sampling logic combining native footprint/headroom + runtime/metrics Go stats. |
| vpn/memmon/reclaimer.go | Defines the executor-side reclamation interface and connection reference type. |
| vpn/memmon/native_stub.go | Provides a non-iOS/non-Android stub native reader. |
| vpn/memmon/native_ios.go | Adds iOS native sampling via task_info phys_footprint and optional os_proc_available_memory. |
| vpn/memmon/native_android.go | Adds Android native sampling via /proc/self/status VmRSS parsing. |
| vpn/memmon/monitor.go | Implements the single-goroutine sample→decide→apply loop with rate-limited logging. |
| vpn/memmon/monitor_test.go | Tests monitor run-loop ticking and cancellation behavior. |
| vpn/memmon/goprobe.go | Adds alloc-free Go memory sampling via runtime/metrics. |
| vpn/memmon/executor.go | Implements soft eviction, hard close-all, OS memory freeing, dump writing, and admission-gate observation. |
| vpn/memmon/executor_test.go | Verifies batch sizing, hard edge-triggering, and FreeOSMemory rate limiting behavior. |
| vpn/memmon/dumpfile.go | Implements atomic single-slot crash-dump formatting and writing. |
| vpn/memmon/dumpfile_test.go | Tests dump output contents and alloc-free behavior after warmup. |
| vpn/memmon/decision_engine.go | Implements hysteresis/dwell/settle, slope-based prediction, adaptive cadence, and snapshot ring. |
| vpn/memmon/decision_engine_test.go | Exercises thresholds, prediction, dumping, settle semantics, interval selection, and clamp behavior. |
| vpn/memmon/config.go | Adds executor-side tuning defaults (soft batch divisor/cap, FreeOSMemory minimum interval). |
| vpn/memmon/admissiongate.go | Adds the dial-path admission gate with single-flight sampling and burst-trigger arming. |
| vpn/memmon/admissiongate_test.go | Tests arming/disarming, single-flight sampling, burst trigger, hysteresis, and concurrency. |
| vpn/memmon.go | Wires memmon into vpn (iOS vs fixed-budget modes), provides tracker-backed reclaimer, and monitor runner. |
| vpn/memmon_test.go | End-to-end tests for reclaimer ordering, reject-mode masking, gate→clashServer wiring, and monitor behavior. |
| vpn/conntrack.go | Extends connection records with closers and adds per-conn / close-all tracked shutdown helpers. |
| vpn/clash.go | Adds reject-mode plumbing, admission-gate hook on dial path, and stores per-connection closers. |
| vpn/boxoptions.go | Prepends an iOS-only reject rule keyed on a dedicated Clash mode. |
| internal/paths.go | Adds a filename constant for the memory dump artifact. |
| common/settings/settings.go | Reorganizes settings keys and adds a dev-only flag to disable admission rejection. |
| common/env/env.go | Adds RADIANCE_MEM_LIMIT_MB env var key for the fixed iOS monitor budget override. |
| bypass/bypass.go | Removes a trace log (and its imports) when bypass proxy is unreachable. |
| backend/radiance.go | Includes the memory dump file in issue-report attachments when it exists (prioritized first). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (5)
backend/radiance.go (1)
427-442: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueMinor TOCTOU on memdump attachment check.
os.Statconfirms the dump exists before adding it tofiles, but the file could be deleted or actively rewritten by the memmon dump writer before the report actually reads/uploads it, since dumps can be overwritten each episode. Low risk given this only affects best-effort issue-report attachments.🤖 Prompt for 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. In `@backend/radiance.go` around lines 427 - 442, The memdump existence check in baseIssueAttachments has a small TOCTOU window because os.Stat is only used to decide whether to include the dump path, while the file may be deleted or rewritten before report collection uses it. Keep the logic in baseIssueAttachments and the memdump handling, but avoid relying on the pre-check for correctness; instead, always add the computed internal.MemoryDumpFileName path to the attachment list only as a best-effort candidate and let the later read/upload step handle missing or changed files safely.vpn/memmon/types.go (1)
19-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDoc comments deviate from the required format.
The per-value comments use
// LevelNormal: descriptioninstead of the// Foo does X.sentence form the guideline requires.✏️ Suggested rewording
const ( - // LevelNormal: GC and the scavenger handle memory; the monitor only observes. + // LevelNormal is the level at which GC and the scavenger handle memory; the monitor only observes. LevelNormal PressureLevel = iota - // LevelSoft: close oldest connections first, reclaiming gradually. + // LevelSoft closes the oldest connections first, reclaiming gradually. LevelSoft - // LevelHard: force-close all connections and reclaim maximally. + // LevelHard force-closes all connections and reclaims maximally. LevelHard )As per coding guidelines, "Go doc comments must start with the identifier's name and a concise summary in the format
// Foo does X., with additional context following."🤖 Prompt for 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. In `@vpn/memmon/types.go` around lines 19 - 26, The doc comments on the PressureLevel constants are not in the required Go doc format. Update the comments in the PressureLevel const block so each one starts with the constant name and a concise sentence summary, following the `// LevelNormal ...` / `// LevelSoft ...` / `// LevelHard ...` style expected by the guideline. Keep the descriptions brief and sentence-form, and ensure the comments still clearly describe the behavior of each level.Source: Coding guidelines
vpn/memmon/native_stub.go (1)
1-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a doc comment for consistency with the android/ios variants.
The sibling implementations (
native_android.go,native_ios.go) document whyreadNativereturns what it does; this stub doesn't explain that returning(0, 0, false)intentionally routes the Sensor to the goBytes fallback path.As per coding guidelines, "Use Go doc comments (`// Foo ...`) for exported identifiers and any unexported ones with non-obvious contracts."📝 Suggested doc comment
+// readNative always reports no native reading available, so Sensor.Sample +// falls back to Go runtime memory stats on this platform. func readNative() (footprint, available uint64, availableSupported bool) { return 0, 0, false }🤖 Prompt for 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. In `@vpn/memmon/native_stub.go` around lines 1 - 5, Add a Go doc comment to readNative in the native stub to match the android/ios variants and explain the contract: this build-tagged fallback intentionally returns (0, 0, false) so Sensor uses the goBytes fallback path. Keep the comment near readNative in native_stub.go and make it consistent in tone and purpose with the sibling native_android.go and native_ios.go comments.Source: Coding guidelines
vpn/memmon/admissiongate.go (1)
92-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winStale parameter name in doc comment.
The doc comment says
src is the dedicated footprint sampler, but the parameter is namedsampler, notsrc. Likely left over from a rename.📝 Proposed fix
-// NewAdmissionGate builds a gate. src is the dedicated footprint sampler; +// NewAdmissionGate builds a gate. sampler is the dedicated footprint sampler; // setReject toggles reject mode.🤖 Prompt for 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. In `@vpn/memmon/admissiongate.go` around lines 92 - 96, The doc comment for NewAdmissionGate still refers to the sampler parameter as src, which is stale after the rename. Update the comment on NewAdmissionGate to use the current parameter name sampler and keep the description of setReject accurate; make sure the wording matches the function signature so the documentation stays in sync with the API.vpn/clash.go (1)
27-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMissing Go doc comments on new admission-control identifiers.
dialAdmissionGateandSetAdmissionGate/setRejectModehave non-obvious contracts (e.g., when reject actually takes effect, idempotency of the swap) but no doc comments, unlikeHistoryStoragejust below which does have one.As per coding guidelines, "Use Go doc comments (
// Foo ...) for exported identifiers and any unexported ones with non-obvious contracts" and comments should start with the identifier's name.📝 Example doc comments
+// dialAdmissionGate lets the memory monitor's admission gate record dials and +// latch/lift clashServer's reject mode based on sampled pressure. type dialAdmissionGate interface { RecordDial(time.Time) }Also applies to: 142-157
🤖 Prompt for 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. In `@vpn/clash.go` around lines 27 - 31, Add Go doc comments for the new admission-control identifiers with non-obvious behavior: document `dialAdmissionGate`, `SetAdmissionGate`, and `setRejectMode` so their contracts are clear, especially when reject mode takes effect and whether repeated swaps are idempotent. Place the comments directly above the relevant declarations/methods in `clash.go`, and make sure each comment starts with the identifier’s name to follow the Go doc convention, similar to `HistoryStorage` below.Source: Coding guidelines
🤖 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 `@vpn/memmon/executor.go`:
- Around line 24-29: Update the doc comment for NewExecutor so it starts with
NewExecutor and accurately describes the current signature and behavior. Remove
the stale mention of a nil emit disabling pressure events, and keep the summary
concise while still documenting dumpDir, platform/version stamping, and the gate
parameter as they relate to newExecutor and reactionConfig.
- Around line 106-120: The maybeDump method in executor marks the dump as
written even when e.dump.write fails, which suppresses later retries for the
same episode. Update maybeDump so e.dumped is set only after a successful write
in the success branch, and leave it false when the write returns an error; keep
the existing behavior in executor.maybeDump and the write/logging flow unchanged
otherwise.
---
Nitpick comments:
In `@backend/radiance.go`:
- Around line 427-442: The memdump existence check in baseIssueAttachments has a
small TOCTOU window because os.Stat is only used to decide whether to include
the dump path, while the file may be deleted or rewritten before report
collection uses it. Keep the logic in baseIssueAttachments and the memdump
handling, but avoid relying on the pre-check for correctness; instead, always
add the computed internal.MemoryDumpFileName path to the attachment list only as
a best-effort candidate and let the later read/upload step handle missing or
changed files safely.
In `@vpn/clash.go`:
- Around line 27-31: Add Go doc comments for the new admission-control
identifiers with non-obvious behavior: document `dialAdmissionGate`,
`SetAdmissionGate`, and `setRejectMode` so their contracts are clear, especially
when reject mode takes effect and whether repeated swaps are idempotent. Place
the comments directly above the relevant declarations/methods in `clash.go`, and
make sure each comment starts with the identifier’s name to follow the Go doc
convention, similar to `HistoryStorage` below.
In `@vpn/memmon/admissiongate.go`:
- Around line 92-96: The doc comment for NewAdmissionGate still refers to the
sampler parameter as src, which is stale after the rename. Update the comment on
NewAdmissionGate to use the current parameter name sampler and keep the
description of setReject accurate; make sure the wording matches the function
signature so the documentation stays in sync with the API.
In `@vpn/memmon/native_stub.go`:
- Around line 1-5: Add a Go doc comment to readNative in the native stub to
match the android/ios variants and explain the contract: this build-tagged
fallback intentionally returns (0, 0, false) so Sensor uses the goBytes fallback
path. Keep the comment near readNative in native_stub.go and make it consistent
in tone and purpose with the sibling native_android.go and native_ios.go
comments.
In `@vpn/memmon/types.go`:
- Around line 19-26: The doc comments on the PressureLevel constants are not in
the required Go doc format. Update the comments in the PressureLevel const block
so each one starts with the constant name and a concise sentence summary,
following the `// LevelNormal ...` / `// LevelSoft ...` / `// LevelHard ...`
style expected by the guideline. Keep the descriptions brief and sentence-form,
and ensure the comments still clearly describe the behavior of each level.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: e6d2b01e-7f6d-4ca8-98c3-72bb2e58cf85
📒 Files selected for processing (30)
backend/radiance.gobypass/bypass.gocommon/env/env.gocommon/settings/settings.gointernal/paths.govpn/boxoptions.govpn/clash.govpn/conntrack.govpn/memmon.govpn/memmon/admissiongate.govpn/memmon/admissiongate_test.govpn/memmon/config.govpn/memmon/decision_engine.govpn/memmon/decision_engine_test.govpn/memmon/dumpfile.govpn/memmon/dumpfile_test.govpn/memmon/executor.govpn/memmon/executor_test.govpn/memmon/goprobe.govpn/memmon/monitor.govpn/memmon/monitor_test.govpn/memmon/native_android.govpn/memmon/native_ios.govpn/memmon/native_stub.govpn/memmon/reclaimer.govpn/memmon/sensor.govpn/memmon/types.govpn/memmon_test.govpn/tunnel.govpn/tunnel_test.go
💤 Files with no reviewable changes (1)
- bypass/bypass.go
Summary
Adds an adaptive memory monitor for the mobile VPN daemon (
vpn/memmon). On iOS the Network Extension runs under a hard per-process jetsam limit (~50 MB); this watches the process footprint and reclaims proactively so the OS doesn't kill the extension. On Android it runs observe-only, since there's no comparable per-process budget to steer against.How it works
A single serial loop — sample → decide → react — on one goroutine:
runtime.ReadMemStats(no stop-the-world). On iOS the cap is dynamic (phys_footprint + os_proc_available_memory); Android/dev use a fixed cap.[0,1]pressure ratio and derives graded levels (Normal / Soft / Hard) with separate enter/exit thresholds for hysteresis, an EWMA-slope cliff predictor, and an adaptive sample cadence (1 s → 500 ms → 100 ms as pressure rises).Softevicts the oldest connections in batches + a rate-limitedFreeOSMemory;Hardcloses all connections and scavenges immediately; a pre-cliff crash dump with all the memory stats is written preemptively in case it's killed by the jetsam.Platform behavior
Field validation (iOS)
Thresholds were calibrated against on-device logs. The NE's resting footprint sits at ~0.8 of its ~50 MB ceiling, so
SoftEnter/SoftExitare 0.88/0.80. In the latest run the monitor stays in Normal during casual browsing, enters Soft only under load, recovers to Normal on its own, and never reached Hard or a jetsam kill. Soft eviction +FreeOSMemorydemonstrably walked the footprint back down under heavy connection load.Summary by CodeRabbit
Summary by CodeRabbit
New Features
Bug Fixes