Skip to content

fix(pool): discard warm firecracker entries whose process died while parked - #181

Open
epicvinny wants to merge 3 commits into
kvcache-ai:mainfrom
ResultadosDigitais:upstream/pool-dead-warm-entries
Open

fix(pool): discard warm firecracker entries whose process died while parked#181
epicvinny wants to merge 3 commits into
kvcache-ai:mainfrom
ResultadosDigitais:upstream/pool-dead-warm-entries

Conversation

@epicvinny

@epicvinny epicvinny commented Aug 18, 2026

Copy link
Copy Markdown

What

FirecrackerPool::try_acquire now skips warm entries whose Firecracker process has already exited, discarding them (and releasing their network slot) instead of handing them to snapshot resume.

Why

Parked warm processes run with oom_score_adj=1000, so they are the first OOM-kill candidates and can die while idle in the pool. The acquire path popped whatever was parked and passed it straight to snapshot resume, which then failed on the dead process. Under host memory pressure this shows up as spurious sandbox-creation failures.

Related issue

N/A. Small focused fix submitted directly per the contributing guide.

Scope and non-goals

  • Included: liveness check on acquire, discard of dead entries with network slot cleanup.
  • Excluded: any change to pool sizing, refill, or decay policy. No changes to the graceful-stop path for live entries.

Design and behavior changes

  • New FirecrackerInstance::is_process_running() based on try_wait.
  • try_acquire pops until it finds a live entry. Dead entries are logged and discarded.
  • The discard path skips graceful stop (the process is known dead) and avoids runtime block_on, so try_acquire stays safe to call from async context.

Compatibility and operations

  • Public API or generated protocol: N/A, no API change.
  • Configuration or defaults: N/A, no config change.
  • Snapshot manifest, artifact layout, or storage format: N/A.
  • Upgrade and rollback: N/A, behavior-only hardening of the acquire path.
  • Host requirements, permissions, ports, or dependencies: N/A.

Validation

  • make fmt (via cargo fmt --check)
  • make clippy (via cargo clippy -p warm-pool; this change touches src/sandbox/firecracker/)
  • make test-unit (scoped: cargo test -p warm-pool, 16 passed)
  • Relevant Rust integration tests
  • make -C services test (required when services/ changes)
  • Generated clients/server regenerated with the documented make target
  • Documentation updated (rustdoc on the new/changed methods)
  • Benchmarks or performance comparison completed

Commands and results:

cargo fmt --check         # clean
cargo clippy -p warm-pool # clean
cargo test -p warm-pool   # test result: ok. 16 passed; 0 failed

Skipped checks and reasons: integration tests require root and /dev/kvm, not available on the Windows dev box where this was written. Happy to run them on a Linux host if reviewers want.

Risks and reviewer notes

  • Main risk is the dead-entry discard releasing the network slot; it mirrors the existing cleanup path and only runs for entries whose process is confirmed dead.
  • Review focus: src/sandbox/firecracker/pool.rs (acquire_live_warm, discard_dead_warm) and instance.rs (is_process_running).

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

…parked

Parked warm processes run with oom_score_adj=1000, so they are the
first OOM-kill candidates and can exit while idle in the pool. The
acquire path handed whatever it popped straight to snapshot resume,
which then failed on the dead process.

Add FirecrackerInstance::is_process_running() and make try_acquire
pop until a live entry is found. Dead entries skip the graceful-stop
path (the process is already gone): the instance is dropped and the
network slot is released synchronously, keeping the acquire path free
of runtime block_on calls so it stays safe in async context.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 3 comment(s)

Comment thread src/sandbox/firecracker/instance.rs Outdated
Comment thread src/sandbox/firecracker/pool.rs Outdated
FirecrackerInstance::is_process_running collapsed every try_wait() I/O
error into "process not running", so the pool would tear down an entry
(and release its network slot) on an inconclusive probe. Return
std::io::Result<bool> instead; on Err the pool logs the failure,
returns the entry to the pool keeping its slot, and reports a regular
miss.

try_acquire() also ran full network teardown for dead entries inline,
from the async snapshot-resume path; cleanup_allocated_slot does
blocking netlink/ip work that can stall a runtime worker thread. Dead
entries now go onto a dead_entries queue drained by the maintenance
worker at the start of each cycle, next to the teardown it already
owns. Shutdown paths drain the queue too, and with maintenance
disabled the cleanup falls back to inline execution since no worker
exists.
Comment thread src/sandbox/firecracker/pool.rs Outdated
Comment thread src/sandbox/firecracker/pool.rs Outdated
Comment thread src/sandbox/firecracker/pool.rs Outdated
…failures

Address follow-up review on the deferred dead-entry cleanup:

- The maintenance-disabled fallback ran blocking teardown inline on the
  async acquire path; it now spawns a detached cleanup thread instead.
- Enqueue was not coordinated with shutdown: an acquire racing drain_all
  could queue an entry after shutdown had already drained the queue,
  leaving it without a consumer. The queue now carries a closed flag
  checked under the same lock; shutdown closes it right after drain_all
  and late enqueues clean up inline.
- cleanup_dead_warm_entries took every entry out of the queue before
  cleanup and only logged failures, losing track of stale host network
  state while the slot index was already back in the allocation bitmap.
  Failed entries are now retained in the queue and retried on later
  cycles. Retries go through the new NetworkManager::cleanup_slot_resources
  which redoes only the resource teardown: the allocation bit is released
  on the first attempt and must never be released twice, since the index
  may have been reallocated to a live sandbox.

cleanup_allocated_slot now borrows the Slot so failed teardown can keep
the entry alive for retry; Slot::drop remains the last-resort cleanup.
Comment on lines +96 to +100
let result = if is_retry {
network.cleanup_slot_resources(&slot, false)
} else {
network.cleanup_allocated_slot(&slot, false)
};

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.

bug · high
A failed first cleanup releases the allocation bit, but this retained SlotOnly can later retry teardown after the same index has been reallocated. Slot::cleanup operates on index-derived resources (veth-<idx> and the same namespace path), so the retry can delete networking that now belongs to a live sandbox. Not touching the bitmap does not make the resource teardown safe. Keep the slot allocated until teardown completes, or add an ownership/generation mechanism that proves the resources still belong to this entry before retrying.

Comment on lines +305 to +308
if let Err(err) = std::thread::Builder::new()
.name("firecracker-pool-dead-cleanup".to_string())
.spawn(move || {
if let Err(err) = entry.attempt_cleanup() {

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.

other · medium
This detached thread is not tracked or joined by shutdown, so shutdown can report completion while slot teardown is still running and any cleanup failure cannot be included in its result. Also, if spawning fails, the captured closure (and entry) is dropped on the acquire thread; Slot::drop then performs synchronous cleanup, contradicting the requirement not to block the async path. Use a managed cleanup worker/queue with bounded concurrency whose handle participates in shutdown, and define a non-blocking fallback for spawn failure.

Comment on lines +484 to 486
self.cleanup_dead_warm_entries()?;

match self.pool.compute_maintenance_action(self.pool.len()) {

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.

bug · high
Because failed entries are deliberately retained, propagating this error prevents every later maintenance action from running. When the dead entry has depleted the pool, WarmPool sees an outstanding Fill action and immediately loops, repeatedly retrying the same failing cleanup without delay while never refilling the pool. Treat dead-entry cleanup and fill/drain as independent work (aggregate/report both errors after attempting maintenance), and consider backoff for retained cleanup failures.

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