fix(balloon): stop idle-shrink oscillation (fail-open backoff + HV free-page-reporting gate) - #469
fix(balloon): stop idle-shrink oscillation (fail-open backoff + HV free-page-reporting gate)#469AprilNEA wants to merge 3 commits into
Conversation
On a memory-pressured host, balloon inflation stalls: every idle descent fail-opens (the guest hits PSI pressure or cannot answer the watch), the controller restores full memory and notes activity, and the 5-minute idle clock restages the whole reclaim storm on the next cycle — indefinitely. macOS reports "normal" memory pressure throughout (kern.memorystatus_vm_ pressure_level == 1 even at 113/128 GiB + swap full), so there is no host signal to gate on. Track consecutive fail-opens and suppress the idle-shrink entry for an exponentially growing window (10m, doubling to a 60m cap) after each failure, re-probing past the window so reclaim self-heals once the host recovers. A shrink that actually holds — settles at the final target, or is still healthy when activity arrives — clears the streak. The prior design prevented tight shrink/restore thrash but not this periodic re-attempt; the module doc is corrected accordingly. Observed: a genuinely idle 16 GiB System VM (~700 MiB of containers) on a host at 113/128 GiB with swap exhausted oscillated every 5 minutes for over an hour, flooding 5477 "Out of puff" lines into one daemon log.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a5af2d529
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Final target reached; keep watching. | ||
| // Final target reached and the guest armed: | ||
| // the shrink held, so clear the streak. | ||
| self.note_shrink_held(); |
There was a problem hiding this comment.
Wait before declaring the final shrink successful
When a retry reaches its final target, SETTLED only means the guest detector observed three healthy samples and armed (guest/arcbox-agent/src/memory_pressure.rs:25-36,133-152); it does not prove inflation has caught up, as the adjacent descent branch itself notes. Clearing the streak here means that if the remaining inflation subsequently stalls and this same watch reports pressure or silence, fail_open increments the streak from zero again, so identical failures after every final SETTLED frame remain permanently at the 10-minute base interval instead of growing to the promised one-hour backoff. Clear the streak only after the final target has survived a meaningful hold period rather than immediately upon detector arming.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — adds exponential backoff to the idle-balloon controller so a shrink that fail-opens the same way every idle cycle (host itself OOM, inflation stalls, macOS reports "normal" pressure so there is no host signal to gate on) stops restaging its 5-minute reclaim storm.
- Track a fail-open streak — new
consecutive_fail_opens+last_fail_open_atonBalloonController;fail_open()increments (saturating_add) and stamps the instant. - Suppress idle entry within the backoff window —
enter_idle()returnsIdleUnshrunkwithout even probing while insidefail_open_backoff(streak). fail_open_backoff(n)—BASE (600s) << min(n-1, 3), capped atMAX (3600s); the shift clamp also guards against1u32 << shiftoverflow for large streaks (tested atn=99).- Self-heal via
note_shrink_held()— clears the streak at final-targetSettled, healthy poll at final, and onExitIdlefrom any shrunk mode. - Corrected the over-claiming doc — the
"Oscillation is structurally impossible"comment now describes the periodic-reattempt failure mode and the backoff that fixes it.
ℹ️ Streak reset on any idle-exit is slightly broader than the doc claims
note_shrink_held runs on ExitIdle from a shrunk mode (Watching/Dwelling/Polling) and resets the streak unconditionally, while the method doc frames it as "settled at the final target, or was still healthy when activity arrived." In practice this is benign — reaching a shrunk mode without having fail-opened already implies the shrink was holding, and activity restores full memory regardless.
The only theoretical gap is a tokio::select! race where both a Pressure frame and an ExitIdle command are ready in the same poll and ExitIdle wins: the streak resets despite pending pressure, dropping an escalated backoff back to base on the next idle cycle. Surfaced for awareness only — no change needed; the design choice is documented and the worst case costs one extra reclaim attempt.
Claude Opus | 𝕏
The HV backend's virtio-balloon already advertises free page reporting (VIRTIO_BALLOON_F_REPORTING) and deflate-on-oom, and the guest kernel enables page reporting (CONFIG_VIRTIO_BALLOON `select PAGE_REPORTING`), so the guest drains idle free pages back to the host continuously and recovers balloon pages under OOM on its own — no host-driven target needed. Gate the aggressive inflate/pressure-watch descent to VZ, whose traditional balloon (VZVirtioTraditionalMemoryBalloonDevice) exposes only a target and has no such mechanism. On HV the controller now stays out of the way, which also removes the inflate-stall pathology entirely for that backend. BalloonDeps grows guest_self_reclaims(); enter_idle returns early on it. RealBalloonDeps keys it off the VM backend (Hv). Note: end-to-end HV verification is deferred until the HV blk-capacity bug is fixed; the change is grounded in the device feature bits and the verified guest kernel config.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers the second commit (a947f25), which gates the whole host-driven idle-shrink descent off on the HV backend, on the grounds that HV's guest self-reclaims idle memory via virtio-balloon free page reporting.
- Add
BalloonDeps::guest_self_reclaims()—RealBalloonDepsreturns true iff the System VM backend is HV (self.shared.backend(), an atomic load, so a runtime VZ↔HV switch is picked up on the next idle entry); VZ's traditional balloon has no such mechanism and keeps the descent. - Short-circuit
enter_idle()on HV — returnsMode::Active(controller goes inert with no retry timer) whenguest_self_reclaims(), checked afterfull_memory_bytes()but before the fail-open backoff/probe path, so HV never shrinks, opens no pressure watch, and fires no activity callback. - Document the rationale — module doc + the new trait-method doc explain that HV advertises free page reporting + deflate-on-oom so the guest kernel drains idle pages itself, making the host-driven target policy both unnecessary and the sole source of the inflate-stall pathology.
- Add
self_reclaiming_backend_skips_idle_shrink— asserts a self-reclaiming backend produces no target, no watch, andactivity_count == 0.
The central premise checks out end-to-end: arcbox-virtio-balloon advertises VIRTIO_BALLOON_F_REPORTING + VIRTIO_BALLOON_F_DEFLATE_ON_OOM (virt/arcbox-virtio-balloon/src/lib.rs:132-138), and the reporting-queue handler actually releases the guest-reported free ranges via madvise(MADV_DONTNEED) (handle_reported_range, lib.rs:364-397) — so HV genuinely returns idle memory to the host rather than merely acking descriptors. Returning Mode::Active rather than IdleUnshrunk is the right choice for a backend that will never shrink (no pointless retry timer; Mode is private with no external consumer). The author's
Claude Opus | 𝕏
Greptile SummaryThis PR updates idle balloon reclaim behavior across the VZ and HV backends. The main changes are:
Confidence Score: 5/5This looks safe to merge after resolving the existing capability-negotiation finding. No additional blocking issue was found in the updated code. The remaining HV capability concern is already covered by the existing finding.
What T-Rex did
Important Files Changed
Reviews (2): Last reviewed commit: "fix(balloon): use MADV_FREE on macOS and..." | Re-trigger Greptile |
…ny VMs Two defects surfaced by the cross-repo audit, both in the balloon subsystem. 1. madvise(MADV_DONTNEED) is a no-op reclaim on Darwin. XNU maps VM_BEHAVIOR_DONTNEED to a deactivation hint that never releases physical backing, so every HV balloon inflate and every free-page-reporting range reported rc=0 while freeing nothing — the VM process footprint never shrank. Switch to MADV_FREE on macOS (MADV_DONTNEED elsewhere), matching libkrun's macOS balloon path, so the host can actually reclaim the pages. This is what makes the HV free-page-reporting path (which the idle-shrink controller now defers to) real rather than a silent no-op. 2. idle_target() panics for a VM configured below the 384 MiB floor: clamp(IDLE_BALLOON_FLOOR, full) with full < FLOOR is min > max, which std::clamp asserts on — crashing the daemon on the idle transition of any sub-384-MiB machine. Cap the floor at full. Comments that asserted the old MADV_DONTNEED "re-fault zero-filled" Linux semantics are corrected.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — this run covers the third commit (8a9269a), which switches the HV balloon device to MADV_FREE on macOS and hardens idle_target against VMs configured below the reclaim floor.
MADV_FREEon macOS viaRELEASE_ADVICE— new backend-conditional const (MADV_FREEon macOS,MADV_DONTNEEDelsewhere) swapped into bothmadvisesites (handle_pfn_listinflate,handle_reported_rangereporting) invirt/arcbox-virtio-balloon/src/lib.rs, with module/field doc updates. XNU mapsMADV_DONTNEEDto a mere deactivation hint so the VM footprint never shrinks;MADV_FREEis the advice that actually lets the host reclaim under pressure (matching libkrun's macOS balloon path).- Guard
idle_targetagainst tiny VMs —.clamp(IDLE_BALLOON_FLOOR.min(full), full)inballoon/mod.rsavoids themin > maxclamp panic when a VM is configured below the 384 MiB floor; a below-floor machine has nothing to reclaim, so the target isfulland entry decidesKeep. Covered by the newidle_target_below_floor_machine_does_not_panicregression test.
The MADV_FREE swap is sound for both descriptor paths: the guest keeps inflated pages off-limits until deflate, and free-page-reporting pages are re-initialized by the guest on realloc, so MADV_FREE's stale-content-until-reclaim behavior stays within the balloon / free-page-reporting contract. The one behavioral consequence — RSS now drops only when the host is under memory pressure rather than immediately — is inherent to what XNU can actually do and is called out in the constant's doc comment. The idle_target change matches the corrected file (the range-diff artifact happens to display both the old and new clamp lines together; the committed source has the single correct clamp). Balloon device code is HV/macOS-only and untestable in Linux CI, consistent with the repo's HV-verification-deferred posture.
Claude Opus | 𝕏
|
CI failure is unrelated to this PR — no changes were pushed. The failing check is a timing-sensitive flake in Evidence it's a pre-existing load flake, not a regression:
I did not modify
|

Balloon-subsystem fixes. Commits 1–2 fix the idle-shrink oscillation; commits 3–4 are cross-repo-audit findings in the same subsystem.
1. Back off idle shrink after repeated fail-opens (VZ + HV)
On a memory-pressured host, balloon inflation stalls and the controller oscillates indefinitely: VM idles → shrink → inflation churns guest pages through exhausted host swap and stalls → guest hits
available_bytes=0/ floodsOut of puff!→ pressure watch times out → fail-open (restore full + note activity →idle→running) → 5-min idle timeout → repeat. macOS reports normal pressure throughout (kern.memorystatus_vm_pressure_level == 1), so there is no host signal to gate on.Fix: track consecutive fail-opens; suppress the idle-shrink entry (no probe) within
fail_open_backoff()— 10 min, doubling to a 60 min cap. A shrink that holds clears the streak, so reclaim self-heals when the host recovers. Corrected the over-claimed"Oscillation is structurally impossible"doc.Observed: a genuinely idle 16 GiB System VM (~700 MiB of containers, 14.6 GiB free) on a host at 113/128 GiB + swap full oscillated every 5 minutes for over an hour — 5477
Out of pufflines in one daemon log.2. Skip host-driven idle shrink on the HV backend
HV's
arcbox-virtio-balloonadvertises free page reporting + deflate-on-oom and the guest kernel enables reporting (CONFIG_VIRTIO_BALLOONselect PAGE_REPORTING, verified inarcboxlabs/kernel), so the HV guest self-reclaims idle memory.BalloonDeps::guest_self_reclaims()(true on HV) gates the inflate/pressure-watch descent off; it runs only on VZ.3. MADV_FREE on macOS (audit finding)
madvise(MADV_DONTNEED)is a no-op reclaim on Darwin — XNU mapsVM_BEHAVIOR_DONTNEEDto a deactivation hint that never releases physical backing, so every HV balloon inflate / free-page-reporting range freed zero bytes while returning success. Switch toMADV_FREEon macOS (matching libkrun's macOS balloon path). This is what makes commit 2's "HV self-reclaims via FPR" real rather than a silent no-op — without it, HV had no working reclaim at all.4. Guard idle_target() against sub-384-MiB machines (audit finding)
idle_target()didclamp(IDLE_BALLOON_FLOOR, full); for a VM configured below the 384 MiB floor that ismin > max, whichstd::clampasserts on — crashing the daemon on the idle transition of any tiny machine. Cap the floor atfull.Tests
vm_lifecycle::balloon— 4 new controller tests +idle_target_below_floor_machine_does_not_panic;arcbox-virtio-balloonunchanged-green. 160 + 16 pass; clippy + fmt clean.Notes / deferred