Skip to content

fix(server): reap orphaned runtime processes and retry RocksDB lock on startup - #189

Open
epicvinny wants to merge 1 commit into
kvcache-ai:mainfrom
ResultadosDigitais:upstream/orphan-process-reap
Open

fix(server): reap orphaned runtime processes and retry RocksDB lock on startup#189
epicvinny wants to merge 1 commit into
kvcache-ai:mainfrom
ResultadosDigitais:upstream/orphan-process-reap

Conversation

@epicvinny

Copy link
Copy Markdown

What

Makes node-agent startup resilient to leftover runtime processes and held RocksDB locks from a previous server incarnation:

  1. Orphan reaper (src/setup/orphans.rs, wired in src/bin/server.rs): early in startup — before the ublk daemon is spawned and before any store is opened — the server scans /proc for processes whose executable is the configured Firecracker binary or uvm-ublk-daemon binary. Any match necessarily belongs to a previous incarnation (nothing has been spawned yet), so they are terminated: SIGTERM, 5s grace, then SIGKILL. Zombie entries are ignored (they hold no resources).
  2. RocksDB lock retry (src/local_store.rs): LocalKvStore::open now retries lock-contention errors (No locks available / Resource temporarily unavailable on the LOCK file) with exponential backoff for up to 120s instead of failing immediately. This covers the case where the lock holder lives in a different PID namespace (e.g. a previous container still terminating on the same hostPath) and therefore cannot be seen or killed by the reaper.

Why

Observed in staging: after a pod was terminated (force-deleted after the preStop hang addressed in #187), firecracker and uvm-ublk-daemon processes from the old container were still alive on the host, and the replacement pod CrashLoopBackOffed trying to open the persisted-sandbox store at /var/lib/aenv/env/persisted-sandboxes/records.db — the RocksDB LOCK was still held. Recovery required kubectl debug node/... -- chroot /host and manually killing the leaked PIDs.

Two contributing gaps in the current code:

  • Firecracker (src/sandbox/firecracker/instance.rs) and the ublk daemon (storage/ublk-daemon/src/client.rs) are spawned in their own process groups (process_group(0)). Graceful teardown (orchestrator pause-all, pool shutdown, daemon shutdown) only runs on SIGTERM/Ctrl+C; on SIGKILL/OOM/force-delete nothing runs and the children persist with their resources (ublk devices, open image files, inherited fds).
  • LocalKvStore::open made a single DB::open attempt, so a still-terminating previous holder turned startup into an immediate fatal error → CrashLoopBackOff.

Scope and non-goals

Compatibility and operations

  • No API, config, or storage-format changes. The reaper only signals processes whose executable matches the binaries this server is configured to use, and only within its own PID namespace. The lock retry is a no-op when the database opens cleanly on the first attempt.
  • Rollback: safe both ways; no persisted state involved.

Validation

  • cargo test -p agentenv --lib local_store (3 passed, incl. retry-until-release and budget-exhaustion cases with a real held RocksDB lock)
  • cargo test -p agentenv --lib setup::orphans (4 passed: exe matching, (deleted) binary suffix, non-numeric/missing exe entries, zombie state parsing)
  • cargo clippy -p agentenv --lib --bins -- -D warnings
  • cargo fmt --check on touched files
  • Relevant Rust integration tests (require root + KVM host)

…n startup

After a hard kill (SIGKILL, OOM, force-deleted pod), firecracker and
uvm-ublk-daemon children spawned with process_group(0) can outlive the
server while still holding ublk devices, open image files, and inherited
fds that pin the RocksDB LOCK of the persisted-sandbox store. The next
server then fails to open records.db and crash-loops. Observed in
staging: recovery required kubectl debug node + manually killing the
leaked PIDs.

Two changes make startup resilient:

- src/setup/orphans.rs (wired in src/bin/server.rs before any subsystem
  spawns): scan /proc for processes whose executable is the configured
  firecracker or uvm-ublk-daemon binary - necessarily leftovers from a
  previous incarnation - and terminate them (SIGTERM, 5s grace, SIGKILL).
  Zombies are ignored.

- src/local_store.rs: LocalKvStore::open now retries lock-contention
  errors with exponential backoff for up to 120s, covering lock holders
  in a different PID namespace (e.g. a previous container still
  terminating on the same hostPath) that the reaper cannot see.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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

  • ✅ Successfully posted inline: 5 comment(s)

⚠️ 1 warning(s) occurred during review.


⚠️ Warnings:

  • src/setup/orphans.rs (comment_refiled): comment filed against src/bin/server.rs describes code in src/setup/orphans.rs; re-filed

Comment thread src/local_store.rs
Comment on lines +118 to +127
let db = loop {
let attempt_path = path.clone();
let result = tokio::task::spawn_blocking(move || {
let mut options = Options::default();
options.create_if_missing(true);
DB::open(&options, &attempt_path)
.with_context(|| format!("open RocksDB {}", attempt_path.display()))
})
.await
.context("join RocksDB open task")?;

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
The retry budget is not a strict upper bound: when the sleep consumes the remaining budget, the loop unconditionally starts another DB::open attempt because the deadline is checked only after that attempt fails. A final attempt can therefore begin (and potentially succeed) after LOCK_RETRY_BUDGET, and a slow blocking open can extend startup even further. Check the deadline before starting each attempt (and avoid sleeping/attempting once no time remains); if the bound must include DB::open, also account for or bound the blocking operation.

Comment thread src/setup/orphans.rs
Comment on lines +54 to +57
let mut orphans = find_processes_by_exe(Path::new("/proc"), &targets);
if orphans.is_empty() {
return Ok(());
}

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.

security · high
This scan treats every process in the PID namespace running either configured executable as an orphan, but it does not verify that the process was spawned by this agent or belongs to a prior incarnation. The startup path has no ownership marker/instance lock in this module, so a concurrently started server, another agent instance, or an independently managed Firecracker using the same binary can be terminated here, disrupting active sandboxes. Scope discovery to agent-owned process groups/metadata (and coordinate concurrent startups) before signaling.

Comment thread src/setup/orphans.rs
Comment on lines +54 to +58
let mut orphans = find_processes_by_exe(Path::new("/proc"), &targets);
if orphans.is_empty() {
return Ok(());
}
orphans.sort_unstable();

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.

security · high
Matching processes solely by executable path is not sufficient to establish that they are orphaned children of a previous server. This scans the whole PID namespace and will terminate every process running the configured firecracker/uvm-ublk-daemon, including workloads belonging to another live server, a concurrently starting instance, or an operator-launched process. It also leaves a PID-reuse race between discovery and kill. Record and validate an ownership marker (for example, the expected process-group/session plus parent/start-time identity, or a per-incarnation pid/state file) before signaling; otherwise this startup cleanup can cause cross-instance workload termination.

Comment thread src/setup/orphans.rs
Comment on lines +65 to +68
for pid in &orphans {
signal(*pid, Signal::SIGTERM);
}
let survivors = wait_for_exit(&orphans, TERM_GRACE).await;

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
Firecracker is explicitly launched with process_group(0), and this module's contract says runtimes may have descendants in that group, but this sends signals only to the executable PID. If the leader has spawned helpers/children, they can retain inherited file descriptors, namespaces, or ublk resources after the leader exits, so startup can still be wedged despite reporting the orphan as reaped. Capture/use the process-group ID and signal the group (with an ownership check) rather than only the leader.

Comment thread src/setup/orphans.rs
Comment on lines +74 to +77
for pid in &survivors {
signal(*pid, Signal::SIGKILL);
}
let stuck = wait_for_exit(&survivors, KILL_GRACE).await;

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.

security · medium
After the initial /proc scan, the code keeps only numeric PIDs and later signals those PIDs without rechecking /proc/<pid>/exe (or a process start-time/ownership identity) immediately before each signal. A matching process can exit and its PID be reused during the 5-second wait, causing SIGKILL to reach an unrelated process; a replacement runtime can likewise be mistaken for the original. Revalidate identity before escalation and while polling, or retain a stable process identity such as PID plus /proc/<pid>/stat start time.

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