Skip to content

fix(balloon): keep a guest reserve in the idle target instead of the floor (CORE-45) - #518

Open
AprilNEA wants to merge 7 commits into
masterfrom
fix/idle-balloon-reserve
Open

fix(balloon): keep a guest reserve in the idle target instead of the floor (CORE-45)#518
AprilNEA wants to merge 7 commits into
masterfrom
fix/idle-balloon-reserve

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Problem

idle_target = used + 256MB degenerates for an idle guest: MemAvailable ≈ totalused ≈ 0 ⇒ the target collapses onto the 384MB floor. The module's own invariant says the target is "never an unconditional constant" — the formula violated it in exactly the idle case it exists for.

Measured 2026-07-29 (VZ, 16 GB VM, before #504 disabled the balloon): the descent asked the guest for 15.8 GB of its 15.96 GB available, drove MemAvailable to literally 0 for ~98 s per cycle, then pressure-restored and repeated every ~8.5 min — ~2.8 cores averaged while "idle". During the zero-available window any running container is an OOM candidate.

Change

The target is now the higher of two floors:

  • used + IDLE_MIN_RESERVE (1 GiB) — the guest keeps a working reserve;
  • total - available / IDLE_RECLAIM_DIVISOR (2) — one entry reclaims at most half the slack, so no single descent walks the guest to the edge.

A reclaim below MIN_WORTHWHILE_RECLAIM (512 MB) is not repaid by its guest-side cost, so the balloon is kept instead.

IDLE_BALLOON_FLOOR is removed: the 1 GiB reserve strictly dominates the 384 MB floor, so it was unreachable code. Dropping it also removes a latent clamp(min, max) panic for any configured size below 384 MB.

For the 16 GB idle guest above, the target moves from 384 MB to ~8 GB — the host still gets ~8 GB back, without the cliff.

Notes

  • Pure policy; inert todayBalloonDeps::reclaim_capable is false on every macOS backend (fix(core): disable the idle balloon — no macOS backend reclaims ballooned memory #504). This is the precondition for re-enabling shrinking anywhere (CORE-44 flips HV back only after this lands).
  • The controller's descent test no longer restates the formula: final_target() calls idle_target and the expected step sequence is derived from next_step, so a future policy change cannot silently desync the test.
  • New regressions: idle_guest_is_never_walked_to_the_floor, planned_reclaim_always_leaves_the_reserve (sweeps availability 0–16 GiB), entry_keeps_when_the_reclaim_is_not_worth_it, idle_target_never_exceeds_a_tiny_configured_size.

Validation

cargo test -p arcbox-core --lib — 172 passed. cargo clippy -p arcbox-core --all-targets — zero warnings. cargo fmt --check — clean. No e2e run: the shrink path this governs cannot execute while reclaim_capable is false, so idle_balloon (which pins the never-shrinks contract) is unaffected.

Closes CORE-45.

…floor (CORE-45)

The idle target was `used + 256MB`, which for an idle guest (used ≈ 0)
collapsed onto the 384MB floor. Measured 2026-07-29 on a 16 GB VM, the
descent then asked for 15.8 GB of the guest's 15.96 GB available and held
MemAvailable at literally 0 for ~98 s per cycle — an OOM hazard for any
running container, and ~14 cores of reclaim spin to produce it. The
module's own invariant says the target is never an unconditional
constant; the formula violated it in exactly the idle case it exists for.

The target is now the higher of two floors: `used + 1GiB`, so the guest
keeps a working reserve, and `total - available/2`, so a single entry
reclaims at most half the slack rather than walking the guest to the edge
in one descent. A reclaim smaller than 512MB is not worth its guest-side
cost and keeps the balloon instead.

The 1GiB reserve strictly dominates the old 384MB floor, so the floor is
removed rather than left as unreachable code — which also drops a latent
`clamp(min, max)` panic for a configured size below 384MB.

Policy is inert today (`reclaim_capable` is false on every macOS backend,
arcbox#504); this is the precondition for re-enabling shrinking anywhere.
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

CORE-45

@pullfrog

pullfrog Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ecced88fea

ℹ️ 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".

Comment thread app/arcbox-core/src/vm_lifecycle/balloon/mod.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces the idle balloon floor with bounded reclaim policy and makes failed restores persistently recoverable.

  • Computes reclaim from guest-available memory while preserving a 1 GiB reserve and limiting each idle entry to half the available slack.
  • Skips reclaim below the 512 MiB worthwhile threshold and keeps targets in the configured-memory domain.
  • Consolidates applied balloon state and retries failed restores during active operation, idle re-entry, and idle exit.
  • Treats stopped or missing VMs as terminal restore states and adds regression coverage for prior review findings.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported memory-domain, stale-shrink, test-oracle, and active restore-retry issues are addressed by the current code and regression coverage.

Important Files Changed

Filename Overview
app/arcbox-core/src/vm_lifecycle/actor.rs Restricts configured-memory reporting to running machines so stopped VMs terminate restore bookkeeping cleanly.
app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Unifies shrink state, restores before idle sizing, and retries failed restores across active and idle lifecycle transitions.
app/arcbox-core/src/vm_lifecycle/balloon/mod.rs Replaces the absolute floor policy with reserve- and share-bounded reclaim calculated in the configured-memory domain.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Enter idle] --> B{Shrink already booked?}
    B -- Yes --> C[Attempt restore]
    C -- Fails --> D[IdleUnshrunk retry loop]
    C -- Succeeds --> E[Read fresh guest stats]
    B -- No --> E
    E --> F{Worthwhile reclaim?}
    F -- No --> D
    F -- Yes --> G[Apply staged shrink]
    G --> H[Watch guest pressure]
    H -- Pressure or activity --> I[Attempt restore]
    I -- Succeeds --> J[Active, no shrink owed]
    I -- Fails --> K[Active with booked shrink]
    K --> L[Retry every 30 seconds]
    L --> I
    I -- VM stopped or gone --> J
Loading

Reviews (7): Last reviewed commit: "test(balloon): cover the IdleUnshrunk ex..." | Re-trigger Greptile

Comment thread app/arcbox-core/src/vm_lifecycle/balloon/mod.rs Outdated
Comment thread app/arcbox-core/src/vm_lifecycle/balloon/mod.rs
Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs
…le shrinks

Three review findings, all verified against the code first.

The guest's MemTotal sits below the configured size (kernel reservations:
a 16384 MB VM reports 15.6 GiB, measured today). Deriving the target from
MemTotal and applying it against the configured full size reclaimed that
~400MB gap on top of the intended share, eating into the very reserve the
policy exists to protect. The policy is now expressed as a reclaim
subtracted from full, with available used only as a delta — domain-free —
and clamped to the guest's own total so an inconsistent reading cannot
inflate it.

A restore that exhausts its retries deliberately leaves the shrunk
bookkeeping set, and its doc comment claimed the next idle entry would
notice. Nothing ever read it, so a later entry deciding Keep stranded the
guest at the stale target indefinitely. Entry is now the retrier: it
restores before probing whenever a shrink is still applied, which the
sizing invariant (balloon-empty stats) demanded regardless.

The staged-descent test derives its expectation from the policy helpers,
so it now also pins the sequence shape independently — first move is one
SHRINK_STEP, every move is a full step but the clamping last, and it ends
exactly at the policy target.
Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Outdated
…entry

A fail-open restore that exhausts its attempts lands in Active with the
shrink still applied. Retrying only on the next idle entry is not a retry
for an active VM, which may never go idle again — and that is the worst
case to strand, since fail-open usually follows guest memory pressure: the
guest is working, under pressure, and squeezed.

Active now carries a 30s retry whenever a shrink is still applied, and no
timer at all otherwise.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new stale-shrink retrier only fires on idle re-entry, so a continuously busy VM never reaches it and stays squeezed for the daemon's lifetime.

Reviewed changes

  • Re-expressed idle_target as a reclaim subtracted from full, so the configured and guest-visible memory domains can no longer be mixed — available is used only as a delta and is clamped to min(stats.total, full) so an inconsistent reading cannot inflate the reclaim.
  • Made enter_idle the retrier for a stale shrink: it restores before the stats probe whenever applied is set, which also keeps the sizing invariant (entry stats are only meaningful while the balloon is empty).
  • Pinned the derived descent oracle in staged_descent_steps_on_settled_frames with three shape assertions that hold independently of the helpers that produced the sequence.
  • Added reclaim_ignores_the_memtotal_to_configured_gap and stale_shrink_is_restored_on_next_entry.

The domain rework is the right shape. Both guarantees in the doc comment now hold for every input, not just the ones the tests pass: the reclaim is capped at available - IDLE_MIN_RESERVE, so the guest always keeps its 1 GiB, and target >= full - available + reserve >= used + reserve keeps the target above usage. I swept adversarial inputs (available > total, available > full, full smaller than the reserve, zero available) looking for a violating case and there isn't one. The previous round's two concerns — MIN_WORTHWHILE_RECLAIM having no distinguishing test, and the reserve being short by full - MemTotal — are both closed by this commit.

ℹ️ Coverage sequencing

Nothing above unit tests exercises the descent. tests/e2e/tests/idle_balloon.rs is VZ-pinned and asserts the balloon never moves, which is correct forever for VZ (guest RAM lives in Apple's XPC process, so it can never become reclaim-capable) but means it can never cover a shrink. With reclaim_capable() still false on both backends, CORE-44's HV re-enable would be this formula's first execution against a real guest. Worth landing an HV-gated e2e alongside that flip rather than after it.

ℹ️ Nitpicks

The headroom/floor vocabulary outlives the constants this PR deleted:

  • controller.rs:127PRESSURE_MIN_AVAILABLE is documented as "Safely below the entry headroom (256 MiB)". The margin it sits below is now the 1 GiB reserve, so the claim is more true than before, but it names a constant that no longer exists.
  • controller.rs:384 — the shrink log still says "idle balloon shrunk to guest usage + headroom".
  • mod.rs:163 — the stats.total == 0 guard's comment reasons about "used + headroom" degenerating to "the floor". The guard is also now redundant: available is clamped to stats.total, so a zero total gives a zero reclaim and entry_decision returns Keep on the worthwhile check anyway. Keeping it as a cheap early-out is fine; the justification needs rewording either way.
  • guest/arcbox-agent/src/memory_pressure.rs:11 carries the same stale wording.
  • controller.rs:760 — the assertion message "every move is a full step except the clamping last one" describes a case the test never reaches. final_target() is exactly FULL - 3 * SHRINK_STEP, so the sequence is [14, 12, 10] GiB and every move is a full step. The escape hatch in the condition is unexercised; a non-divisible final_target in the fixture would make it real.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Outdated
fail_open notes activity, so the resulting ExitIdle arrives immediately
after a failed restore. Retrying there turns a transient backend failure
into a same-instant recovery instead of a wait for the 30s timer, which
stays as the backstop for the case where no further edge ever arrives.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new 30s retry closes the stranding hole, but it never terminates when the VM is stopped rather than deleted — the guest stays squeezed and the daemon logs 4 lines every 30s for the rest of its life.

Reviewed changes

  • Added STRANDED_RESTORE_RETRY (30s) and a tokio::select! timer arm to Mode::Active, armed only when applied.is_some() and std::future::pending() otherwise, so an active VM that never goes idle again still retries.
  • Made ExitIdle in Mode::Active retry the restore instead of being a no-op, so the activity edge fail_open itself emits recovers a transient failure immediately rather than after up to 30s.
  • Added stranded_shrink_is_retried_while_active and stranded_shrink_is_retried_on_the_activity_edge.

The topology is now complete in the sense the previous thread asked for: with the enter_idle restore from 4abf2ff, the ExitIdle edge, and the timer, there is no mode that can hold applied without a scheduled retry. I checked the two ways this could have been quietly non-functional and it is neither: Harness::settle() is 50 yield_now().awaits with no clock advance, so the activity-edge test genuinely cannot be rescued by the timer and the while-active test genuinely needs its explicit tokio::time::advance — both new tests are falsifiable. And the let stranded = self.applied.is_some() snapshot cannot go stale, since nothing mutates applied between the snapshot and either arm body within one loop iteration. Repeated EnterIdle/ExitIdle cannot starve the timer either, because both of those paths restore on their own.

What the retry lacks is a stopping condition. restore() clears applied on exactly two paths — the machine record being deleted, or set_balloon_target succeeding — and a stopped VM satisfies neither, so the loop runs forever. Details inline.

ℹ️ Nitpicks

controller.rs:252 — the Mode::IdleUnshrunk ExitIdle arm still comments "Nothing was shrunk; nothing to restore". Since 4abf2ff, enter_idle can return IdleUnshrunk with applied still set (that is exactly the failed-restore path), so the premise no longer holds. The arm is still correct, because the new Active timer picks the retry up, but for a different reason than the comment gives.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Outdated
Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Outdated
Architectural review of the subsystem, addressing the shape that produced
this PR's three P1 findings rather than the findings alone.

The controller held applied/final_target/step_applied_at as three parallel
Options that only ever moved together, and restore() signalled failure by
leaving applied set — an implicit protocol that five call sites had to know
about and three had to re-check by hand. Both P1s were a call site not
knowing. They are now one Option<ShrinkState>, whose Some means exactly
"the guest is owed memory", and restore() returns a #[must_use] bool so the
compiler asks the question at every call site instead of the reviewer.

The ExitIdle arm was also copied across all five modes with subtly
different behaviour, and my own stale-shrink fix had already made one copy
lie: IdleUnshrunk can now hold a failed restore, but its arm still read
"Nothing was shrunk; nothing to restore." All five now route through one
exit_idle() that hands back whatever is owed, which fixes that by
construction and makes IdleUnshrunk recover on the edge rather than waiting
out the Active timer.

Also drops two stale docs: PRESSURE_MIN_AVAILABLE cited the long-gone
256 MiB headroom, and the shrink log still described the old
usage-plus-headroom formula.

No behaviour change beyond the IdleUnshrunk recovery above; all 176 tests
pass untouched, which is itself the evidence that none of this leaked past
the module boundary.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Nothing new blocks this — the delta is a clean refactor that makes the "guest is owed memory" invariant hard to misread. The [!IMPORTANT] finding from the previous round (the stranded-restore retry never terminating for a stopped VM) is untouched by these commits and its thread stays open.

Reviewed changes

  • Collapsed applied / final_target / step_applied_at into one ShrinkState, so shrink.is_some() is the single reading of "the guest is owed memory" — the old shape let a caller consult applied alone and miss that a restore had failed.
  • Made restore() return bool under #[must_use], and rewrote enter_idle's stale-shrink block to branch on it rather than re-probing applied afterwards.
  • Extracted exit_idle() and routed every mode's ExitIdle through it — which also gives Mode::IdleUnshrunk a restore it previously skipped.
  • Refreshed the stale headroom vocabulary the last round flagged: PRESSURE_MIN_AVAILABLE's doc now names IDLE_MIN_RESERVE, the shrink log reads "idle balloon shrinking", and restore()'s doc plus its terminal error! no longer claim recovery waits for the next idle cycle.

I traced the consolidation against every reachable state and it is behaviour-preserving apart from the one IdleUnshrunk change noted inline. Watching/Dwelling/Polling are only entered downstream of self.shrink = Some(..) (via open_watch / continue_descent), so exit_idle's new shrink.is_some() guard is equivalent to unconditional there. apply_step's new let Some(state) = self.shrink.as_mut() else { return } is unreachable for the same reason — its only caller comes through next_pending_step(), which requires shrink to be Some. And all four restore() call sites land in a mode that has a scheduled retry, so no false is silently dropped: line 374 checks it explicitly, and the three let _ sites (the Active timer, exit_idle, fail_open) all return into Active, which re-arms the 30s timer.

The old three-Option shape also never produced a partially-populated state, so nothing depended on reading one field without the others — the consolidation loses no expressiveness.

ℹ️ Nitpicks

controller.rs:238 / controller.rs:446 — unifying the ExitIdle paths means every restore now logs reason = "idle exit", including the Mode::Active stranded retry that previously logged "stale shrink on activity". In Active the controller is not exiting idle at all, so the one log line that distinguished the activity-edge retrier from an ordinary idle exit is gone.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread app/arcbox-core/src/vm_lifecycle/balloon/controller.rs
…addressable

The 30s retry I added had no terminal condition for a *stopped* VM, which
is the ordinary case rather than an edge one: MachineManager::stop keeps
the machine record, so full_memory_bytes kept returning a size, while
VmManager::set_balloon_target rejects any VM that is not Running. Every
attempt failed, forever — three warns and an error every 30s for the
daemon's remaining lifetime. That is a worse shape than the silent
one-shot failure the retry replaced.

full_memory_bytes now answers the question the controller is actually
asking — is there a balloon I can address — by gating on the machine
running, and the trait documents None as a terminal answer rather than a
transient one. restore() already treated None as settled; that is now
correct for a stop as well as a removal, and it is the honest invariant: a
stopped guest is unsqueezed by the stop itself and a fresh boot starts
with an empty balloon, so nothing is owed.

Regression asserts both halves: no further set attempts once the VM stops,
and none after it comes back either, so a new guest does not inherit a
shrink it never had.
Routing every ExitIdle through exit_idle() also made IdleUnshrunk recover
on the edge instead of waiting out the Active timer — a behaviour change
that no test reached. IdleUnshrunk can hold a booked shrink because
enter_idle parks there when the stale restore fails.

The new case gets there by that exact route and leaves idle without
advancing the clock, so neither 30s timer can be what rescues the guest.
It also pins the precondition rather than assuming it: exactly one failed
restore and no stats probe during the re-entry is the enter_idle early
return, which is the only way into IdleUnshrunk holding a shrink.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Both remaining findings are closed. 1c7633d4 gives the stranded retry a real terminal condition and e73292cc covers the one mode no test reached — the only thing left is that one of the new test's two precondition assertions can't fail.

Reviewed changes

  • RealBalloonDeps::full_memory_bytes now .filter(|info| info.state == MachineState::Running), so a stopped VM answers None exactly like a deleted one. restore() already booked None as settled, so that single line is the whole terminal condition. The trait doc now states None is terminal and why, which is the right place for it — the fake has to honour the same contract.
  • FakeDeps.full became Mutex<Option<u64>> so a test can stop the VM mid-flight, plus stranded_shrink_stops_retrying_once_the_vm_stops.
  • stranded_shrink_is_restored_on_exit_from_idle_unshrunk — the IdleUnshrunk gap from the last round.

I chased the two ways full_memory_bytes could now answer None for a guest that is still alive, and neither is reachable. graceful_stop sets Stopping and rollback_stopping (machine.rs:1156-1164) reverts to Running on Ok(false)/Err, which would clear a booking for a guest that never went down — but run_stop (boot.rs:40-110) always falls back to the force mm.stop(&name) after a failed or timed-out graceful stop, so the VM genuinely stops. The Starting window (machine.rs:599 sets Running after VmManager already flipped) is harmless in the other direction: a fresh boot has an empty balloon, so there is nothing booked to lose. Both new tests are falsifiable — settle() advances no clock, so ..._on_exit_from_idle_unshrunk's final restore cannot be the 30 s timer, and the stop test's advance(STRANDED_RESTORE_RETRY * 4) would fire four retries if the filter were absent.

The .filter(..) itself has no direct test — the new controller test drives the pre-existing restore() None branch through the fake. For a one-line field filter on a thin adapter that seems like the right trade, so I'm noting it rather than asking for it.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// Exactly one failed restore (3 attempts) and no stats probe: that is
// the `enter_idle` early return, i.e. we are parked in IdleUnshrunk.
assert_eq!(h.deps.set_attempts.load(Ordering::SeqCst) - before_entry, 3);
assert_eq!(h.deps.stats.lock().unwrap().len(), 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion cannot fail, so only half of the stated precondition is actually pinned.

The test pushes exactly one stats entry (line 1146) and the first EnterIdle consumes it — FakeDeps::guest_stats pops from the front (controller.rs:631-641) and returns None once empty. So the queue is already empty before the second EnterIdle, and len() == 0 holds whichever branch that entry takes. "Zero stats probes consumed" is what you want to assert, but with an empty queue a probe and no probe are indistinguishable.

The attempt-count assertion on the line above does carry weight, so the test still fails if the enter_idle early return goes away and entry falls through to a probe-then-descend — it just fails on targets() at the end rather than here.

Making it falsifiable is two lines: push a second entry so a probe would be observable as a decrease.

deps.push_stats(Some(idle_stats()));
deps.push_stats(Some(idle_stats()));

and then

assert_eq!(h.deps.stats.lock().unwrap().len(), 1);

The first EnterIdle still consumes exactly one (the descent parks in Watching without advancing the clock, which assert_eq!(h.targets(), vec![FIRST_STEP]) already pins), so a re-entry that probed would drain it to 0 and trip the assertion.

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