1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
//! Idle balloon policy (pure logic) and the controller that applies it.
//!
//! Numeric policy lives here; [`controller`] owns the balloon as a
//! message-driven task. The split keeps every decision unit-testable.
//!
//! Invariants (from the 2026-07-15 idle-thrash incident, where a blind
//! shrink to 128 MB starved a running compose stack for 18 hours):
//!
//! - Never shrink without a fresh guest memory reading. An unreachable
//! agent means we know nothing about guest load — keep the balloon.
//! - The idle target tracks actual guest usage plus headroom; it is never
//! an unconditional constant. A guest that is actively *working* (load)
//! is not idle at all, no matter how quiet the host-side API is.
//! - Entry-time stats are the only ones used for sizing: they are taken
//! while the balloon is empty, the one state where `/proc/meminfo` is
//! unambiguous under both virtio-balloon accounting modes (with and
//! without `DEFLATE_ON_OOM`). While shrunk, the balloon perturbs the
//! very numbers a host-side re-computation would need, so pressure
//! detection is delegated to the guest (see `WatchMemoryPressure`)
//! and the only moves are "keep" or "give it all back".
//! - **Idle shrinking runs only on reclaim-capable backends — and no macOS
//! backend is one today** (2026-07-29 measurements, macOS 26.4). VZ:
//! Apple neither deallocates nor `madvise`s the pages the guest gives up
//! — a 15.35 GB inflation left the daemon's `phys_footprint`
//! byte-identical, and under real host memory pressure the kernel
//! *compressed* the ballooned pages as live data (calibrated against a
//! `MADV_FREE` probe, which the same pressure discards within seconds).
//! HV: its device inflates with `MADV_DONTNEED`, which Darwin treats as
//! a deactivation hint — calibrated footprint-inert, contents preserved
//! (compressed, never discarded, under pressure); real Darwin reclaim
//! needs `MADV_FREE_REUSABLE` (calibrated: footprint drops instantly).
//! So shrinking is guest starvation with zero host benefit on either
//! backend. See [`controller::BalloonDeps::reclaim_capable`]; flip a
//! backend only with a measured host footprint drop on inflate.
//! - **What the host actually pays is the high-water mark of guest-touched
//! pages, not the configured `memory_mb`** (2026-08-01, VZ, macOS 26.4).
//! A fresh idle 16 GB VM costs ~718 MB of host `phys_footprint`; the
//! guest allocating 3 GB of tmpfs takes it to 3717 MB, and the guest
//! freeing that memory leaves it at 3717 MB. Absent any reclaim path the
//! mark only ratchets upward, which is precisely the cost a working
//! balloon would release. Measure the XPC helper process
//! (`com.apple.Virtualization.VirtualMachine`) on VZ, not the daemon —
//! guest RAM lives there, which is also why VZ can never be made
//! reclaim-capable: that memory is not ours to `madvise`.
//!
//! # Before re-enabling the host-driven descent, read this
//!
//! The descent below (probe → stage a target → watch guest pressure) is
//! dormant, not merely unused, and two independent attempts to tune it
//! (2026-07-21, 2026-07-31) were abandoned for the same reason: **it has
//! no backend to serve.** VZ can never be reclaim-capable (above). HV
//! already advertises `VIRTIO_BALLOON_F_REPORTING` and its guest kernel
//! enables page reporting, so an HV guest hands back free ranges *on its
//! own, continuously* — the host side only has to release them, and today
//! it `madvise(MADV_DONTNEED)`s, which is footprint-inert on Darwin
//! (`virt/arcbox-virtio-balloon/src/lib.rs`). So the work that would make
//! HV reclaim is `MADV_FREE_REUSABLE` on the reporting path, and once
//! that lands the guest is already driving; a host-side idle descent is
//! the wrong shape for it, not a missing piece. Delete this machinery
//! before you tune it.
//!
//! If a descent is nevertheless the answer for some future backend, both
//! measured failure modes must be designed out first:
//!
//! - **`used + IDLE_BALLOON_HEADROOM` degenerates in exactly the idle case
//! it exists for.** An idle guest has `MemAvailable ≈ total`, so
//! `used ≈ 0` and the target collapses onto [`IDLE_BALLOON_FLOOR`] — an
//! unconditional constant, which is what the invariant above forbids.
//! Measured 2026-07-29 (VZ, 16 GB, before the gate closed): the descent
//! asked the guest for 15.8 GB of its 15.96 GB available, pinned
//! `MemAvailable` at literally 0 for ~98 s, pressure-restored, and
//! repeated every ~8.5 min — ~2.8 cores averaged while "idle", with every
//! running container an OOM candidate inside each zero-available window.
//! A target that cannot walk the guest to the edge needs two floors, not
//! one: a working reserve above `used` (~1 GiB) *and* a cap on how much
//! of the observed slack a single entry may take (~half). A reclaim too
//! small to repay its guest-side cost (~512 MB) should not happen at all.
//! - **On a memory-pressured host the descent oscillates, and macOS gives
//! no signal to gate on.** Inflation churns guest pages through exhausted
//! host swap and stalls, the guest floods `Out of puff!`, the pressure
//! watch times out, the controller fails open (restore + note activity),
//! and the 5-minute idle timeout starts the cycle again. Measured
//! 2026-07-21: an idle 16 GiB System VM on a host at 113/128 GiB with swap
//! full oscillated every 5 minutes for over an hour — 5477 `Out of puff`
//! lines in one daemon log — while `kern.memorystatus_vm_pressure_level`
//! read *normal* throughout. A host-pressure gate is therefore not
//! available; the only signal-agnostic answer measured was backing off on
//! consecutive fail-opens (10 min, doubling to an hour) and clearing the
//! streak when a shrink holds.
pub
/// Bytes per MiB.
const MIB: u64 = 1024 * 1024;
/// Headroom added above observed guest usage when computing an idle target.
pub const IDLE_BALLOON_HEADROOM: u64 = 256 * MIB;
/// Absolute floor for a computed idle target. Below this the guest kernel
/// itself becomes unstable regardless of workload.
pub const IDLE_BALLOON_FLOOR: u64 = 384 * MIB;
/// A guest with at least this 1-minute load average is doing real work —
/// it is not idle, regardless of host-side API silence (in-guest workloads
/// such as published-port services never cross the host).
pub const IDLE_BUSY_LOADAVG: f64 = 1.0;
/// Budget for the agent stats query. A guest that cannot answer within
/// this window is treated as unreachable.
pub const GUEST_STATS_TIMEOUT_SECS: u64 = 3;
/// Maximum balloon movement per shrink step.
///
/// Inflation pins guest `MemAvailable` near zero until it converges, and its
/// speed is not under host control — a 15 GB single-step shrink was measured
/// to stay unsettled for minutes on VZ. Stepping bounds each scarcity window
/// to a couple of seconds and lets the pressure detector arm between steps,
/// so the fast (armed) pressure path guards nearly the whole descent.
pub const SHRINK_STEP: u64 = 2 * 1024 * MIB;
/// Next target when stepping the balloon from `current` toward
/// `final_target`.
pub
/// Guest memory + load snapshot, taken from the agent's `GetSystemInfo`
/// reply while the balloon is empty.
pub
/// The move to make when the VM enters idle.
pub
/// Computes the idle balloon target for the observed guest usage:
/// used memory plus [`IDLE_BALLOON_HEADROOM`], clamped to
/// [`IDLE_BALLOON_FLOOR`] and the full configured size.
///
/// The floor is itself capped at `full`: a machine configured below
/// [`IDLE_BALLOON_FLOOR`] would otherwise make this `clamp(min > max)`,
/// which panics — inside the lifecycle actor, so it takes the daemon down
/// on that machine's first idle transition.
pub
/// Decides the balloon move when the VM enters idle.
///
/// `stats` is `None` when the agent could not be queried — in that case the
/// balloon must be kept, never shrunk blind.
pub