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 4 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 4 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 revises idle balloon sizing and strengthens failed-restore recovery.

  • Computes reclaim as a bounded delta from configured memory, preserving the guest reserve across MemTotal differences.
  • Skips reclaim below the worthwhile threshold and expands policy regression coverage.
  • Restores stale balloon state before idle sizing and retries stranded restores while active.
  • Strengthens staged-descent assertions without duplicating policy arithmetic.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current code addresses the previously reported sizing-domain, stale-restore, active-retry, and test-oracle concerns.

Important Files Changed

Filename Overview
app/arcbox-core/src/vm_lifecycle/balloon/controller.rs Adds sound active and idle retry paths for failed restores and strengthens controller regression tests.
app/arcbox-core/src/vm_lifecycle/balloon/mod.rs Replaces the fixed floor policy with reserve- and share-bounded reclaim arithmetic in the configured-memory domain.

Reviews (4): Last reviewed commit: "fix(balloon): also retry a stranded shri..." | 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
…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
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𝕏

std::future::pending::<()>().await;
}
} => {
self.restore("retrying a stranded shrink");

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 retry has no terminal condition when the VM is stopped rather than removed, and stop is the ordinary case.

restore() clears applied only when full_memory_bytes() returns None — i.e. the machine record was deleted — or when set_balloon_target succeeds. A stopped VM keeps its record, and set_balloon_target rejects a non-Running VM, so neither happens. The controller also never finds out about the stop: balloon commands fire only on idle edges, and a stranded controller is already in Mode::Active, so no EnterIdle/ExitIdle arrives to break the cycle.

The result is 3 warn! plus 1 error! every 30s — roughly 11.5k lines a day — for the remaining lifetime of the daemon, with applied permanently stuck. That is a regression in shape from the pre-commit behaviour, which was silent and one-shot. It also makes the reply on the previous thread only conditionally true: the retry re-applies full and clears applied once a VM is running again, but until then it cannot.

The fix wants to be "terminate when no running VM can accept the target", not "retry less often". Treating a not-running VM the same way restore() already treats a gone VM would do it, since a fresh boot starts with an empty balloon anyway — the guest is unsqueezed by the stop itself, so there is nothing left to give back. Clearing applied on the stop transition would work equally well and is arguably the more honest statement of the invariant.

Technical details
  • restore() (controller.rs:479-508): the let Some(full) = self.deps.full_memory_bytes() else arm is the only place applied is cleared without a successful set_balloon_target, and it triggers on a missing machine record.
  • full_memory_bytes() (actor.rs:197-202) is machine_manager.get(&name).map(|info| info.memory_mb * MiB).
  • MachineManager::get (machine.rs:1168) reads the map; stop (machine.rs:1118-1124) sets state = Stopped and cid = None but keeps the entry; only remove() (machine.rs:1228) deletes it. So full_memory_bytes() stays Some across a stop.
  • set_balloon_target (actor.rs:204-214vm.rs:921-938) returns Err(invalid_state) at vm.rs:928 whenever entry.info.state != MachineState::Running. Every attempt fails, all three times, forever.
  • Balloon commands are idle-edge-only (actor.rs:431-448): EnterIdle when after == Idle, ExitIdle when before == Idle. A stranded controller sits in Mode::Active because fail_open put it there, so a stop emits neither.
  • The controller is per-actor-lifetime (actor.rs:361-362, spawned once at actor.rs:373), not per-VM-boot, so "the rest of the daemon's lifetime" is the real bound.

Mitigating, as with the rest of this subsystem: reclaim_capable() is false on every backend today (actor.rs:193), so none of this executes until CORE-44 flips HV on.

/// given back (a restore that silently "succeeds" in bookkeeping only
/// would leave the guest squeezed with nobody retrying).
/// would leave the guest squeezed with nobody retrying). `enter_idle`
/// is the retrier: it restores before probing whenever `applied` is set.

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.

Two things in this doc block are now stale, both in ways that would mislead the next reader about where recovery happens.

The comment names enter_idle as "the retrier", but as of this commit there are three: enter_idle, the ExitIdle edge, and the Mode::Active timer. Worth saying that any mode holding applied has a scheduled retry, rather than naming one site that will drift again.

And the terminal tracing::error! a few lines down still says the guest "remains shrunk until the next idle cycle". The timer this commit adds is precisely what makes that false — an operator reading that line would conclude no recovery is scheduled and go looking for an idle transition that is no longer required.

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