mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #165] Auto-eviction can delete a sandbox after a keep-alive was accepted (expiry never re-validated) #56
Labels
No labels
bug
documentation
enhancement
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/AgentENV#56
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @sunkencity999 on GitHub (Aug 12, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/165
First — thank you for the last few days. You corrected me twice with citations to your spec and your test suite, and both times you were right and I wasn't. That's a better outcome than if you'd just merged my patch, and it's not the response most projects give a drive-by contributor. It also changed how I read this codebase, which is why I went back through it properly before filing anything else.
While doing that I found something I'm fairly confident is a genuine bug, so I wanted to bring it to you on its own rather than bundled with anything else.
Summary
A
POST /sandboxes/{id}/timeout(or/refreshes, or/connect) can be acknowledged successfully and the sandbox then destroyed anyway by the auto-eviction pass that is already in flight. Withtimeout_action = Deletethe sandbox is gone after the control plane told the caller its lifetime was extended.The race
evict_expired_sandboxes(src/orchestrator/service.rs:1795) takes a point-in-time snapshot and then acts on each entry serially:The only re-check before acting is
state, read from the snapshot. Expiry is never re-validated, and the CAS inside the inner functions can't help:update_if_state(&sandbox_id, &[SandboxState::Running], …)(store/in_memory.rs:172) matches on state only — its signature isexpected_states: &[SandboxState], so it has no way to express "…and still expired."Meanwhile
keep_alive_for(service.rs:735) validates state, then updates only the expiry through that same state-only CAS (service.rs:779) and reports success. It never changesstate, so a subsequentRunning → KillingCAS indelete_sandbox_innersucceeds normally.Sequence
timeout_action = Delete.list_expiredreturns[A, B].POST /sandboxes/B/timeout.keep_alive_forCASes onRunning, succeeds, setsexpires_at = now + 1h, returns success. The client now believes B is safe.metadata, seesRunning, and deletes it.The window is as long as it takes to process earlier entries, so it widens with the number of simultaneously-expiring sandboxes. With
timeout_action = Pausethe outcome is milder — the sandbox survives but is paused against an explicit extension.Why this reads as an omission rather than a decision
SandboxMetadata::is_expiredexists atsrc/orchestrator/store/metadata.rs:121and has its own unit test at:141-142. Its only other caller in the whole tree is a benchmark (crates/benchmarks/benches/orchestrator_store_benchmark.rs:66). It is called from no production code path — I checked withgrep -rn is_expiredacross the repo.That looks like the predicate was written for exactly this purpose and never wired into the eviction path.
Existing coverage
The eviction tests are good, they just don't race this.
auto_evict_expired_sandbox(src/orchestrator/tests.rs:3608) andauto_evict_sandboxes_skips_non_running_and_non_expired_and_continues_on_error(:2762) both drive eviction with no concurrent keep-alive.keep_alive_uses_latest_metadata_when_deciding_whether_to_shorten(:2680) does race keep-alive against a concurrent store write, but not against an eviction pass — so the interaction falls between two well-tested behaviours.Possible fix shapes
You'll know better than me which fits the architecture, but the options I can see:
update_if_state(or add a variant) to take a predicate over the wholeSandboxMetadatarather than just state, and have the inner eviction functions require "still expired." This is whereis_expiredwould naturally be used.expires_atdown from the loop and have the eviction path abort if it no longer matches — a compare-and-swap on expiry rather than state.Option 1 seems most robust and also fixes the
Pausevariant; option 3 is a much smaller change if you'd rather not touch the store trait.Caveats
I have not reproduced this at runtime — I can't run the suite locally (no KVM on this machine), so this is from reading the code rather than from a failing test. If there's a serialisation guarantee I've missed that prevents the interleaving, I'd genuinely like to know, both because it means there's no bug and because it means I'm still misreading something about the lifecycle model.
Happy to write a regression test for it if that's useful — the mock backend in
tests.rslooks like it could drive the interleaving deterministically, and I'd rather contribute a failing test than a fix, given I've been wrong about your intent twice already.Thanks again for the care you've been putting into these threads. Genuinely nice to work with a project where the maintainers check things.