fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes#4221
fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes#4221AnnaSuSu wants to merge 8 commits into
Conversation
…ndboxes Docker sandboxes are shared across gateway workers, but each worker kept its own in-memory warm pool. Startup reconciliation adopted every running container, so a peer idle reaper could destroy sandboxes another worker still owned and tool calls hit 502 / Connection refused. Add file-based ownership leases under sandbox-leases/, only adopt true orphans, refuse idle/replica/shutdown destroy while a foreign lease is live, and renew the lease on create/get/release/reclaim. Fixes bytedance#4206
fancyboi999
left a comment
There was a problem hiding this comment.
The lease direction matches #4206, but three runtime correctness gaps remain. The check/destroy race can still kill a peer-owned container, unreadable lease state currently fails open, and lease renewal adds synchronous filesystem I/O to async tool execution. I reproduced all three against this head; the focused 57-test suite does not cover these paths.
|
|
||
| def _destroy_warm_entry(self, sandbox_id: str, entry: SandboxInfo, *, reason: str) -> None: | ||
| """Destroy a warm-pool sandbox using AIO-specific backend logging.""" | ||
| if not self._can_destroy_shared_container(sandbox_id): |
There was a problem hiding this comment.
[P1] Make the ownership check and container destruction one cross-process atomic operation. touch_lease() can replace the lease after this predicate returns but before _backend.destroy() runs. I forced exactly that interleaving and destroy() was still called while the lease owner was already worker-b, so the original cross-worker kill remains possible. A per-sandbox lock/CAS needs to cover check-through-destroy, and lease acquisition/renewal must participate in the same protocol.
| return None | ||
| except OSError as e: | ||
| logger.warning("Failed to read sandbox lease %s: %s", path, e) | ||
| return None |
There was a problem hiding this comment.
[P2] Preserve the distinction between a missing lease and an unreadable lease. Returning None here makes foreign_lease_blocks() return false, so the provider's intended fail-closed except OSError path is unreachable. With Path.read_text() raising PermissionError, _destroy_warm_entry() still called the backend destroy. Propagate the error or return a tri-state so reconcile/destroy skips when ownership cannot be proved; an existing corrupt lease should receive the same conservative treatment.
| self._last_activity[sandbox_id] = time.time() | ||
| return sandbox | ||
| if sandbox is not None: | ||
| self._touch_sandbox_lease(sandbox_id) |
There was a problem hiding this comment.
[P2] Do not renew the file lease synchronously from get(). ensure_sandbox_initialized_async() calls provider.get() directly (sandbox/tools.py:1417 and :1434), and this path now performs mkdir + temp-file write + fsync + os.replace on the event loop for every sandbox tool lookup. The strict Blockbuster probe fails with Blocking call to os.mkdir. Move renewal to an already-offloaded path or provide an async/offloaded lease touch.
|
@AnnaSuSu, please run 'make install' from your repo root to avoid the lint error when you commit the code. |
Address review of the multi-worker orphan lease (bytedance#4206): - read_lease returns None only for a genuinely-absent lease and raises (CorruptLeaseError/OSError) when a lease is unreadable or corrupt, so the ownership check fails closed instead of mistaking an unprovable peer lease for a free container. clear_lease still removes a stuck/corrupt file. - get() no longer renews the lease (blocking mkdir/fsync/os.replace on the event loop path used by ensure_sandbox_initialized_async); active leases are renewed off the event loop from the idle checker (_renew_active_leases). - The ownership check and container stop run under a per-sandbox flock guard (lease_ownership_guard); every lease write takes the same guard so a peer's touch cannot interleave with a destroy. Same-host multi-worker scope, not a multi-pod distributed lock. Also fixes the ruff format lint on the branch. Adds regression tests: corrupt and unreadable lease fail closed, a tests/blocking_io anchor keeping get() non-blocking on the event loop, and a peer-touch/destroy interleave test.
|
Thanks @fancyboi999 — all three were real, fixed in 6020cbf:
Appreciate the reproductions — they pointed straight at the gaps. Full suite (aio sandbox + provider + orphan reconciliation + the new anchor + gate smoke) is green and ruff is clean. |
|
@WillemJiang while you're here — a quick gut-check on the direction of this #4206 fix? I went with file-based per-sandbox ownership leases + a same-host flock guard around the check→destroy window (rather than Docker labels or a heavier coordinator). Is that the direction you'd want for multi-worker sandbox ownership, or would you prefer a different mechanism before I invest further? |
@AnnaSuSu Since we need to support multiple gateway instances for load balancing, it's better to share sandbox states via a third-party service; otherwise, we would have to maintain sandbox state across multiple instances. |
|
@WillemJiang Agreed, that makes sense — the file lease was scoped to the same-host case, and multi-instance gateways need the state shared. Concrete plan I'd like to confirm before reworking: Follow the existing
No new deployment dependency: If that direction works for you, I'll rework this PR accordingly. |
fancyboi999
left a comment
There was a problem hiding this comment.
Thanks @AnnaSuSu. I found two remaining ownership failures that should be addressed before this is ready.
[P1] Keep leases alive when idle cleanup is disabled
- Location:
backend/packages/harness/deerflow/community/aio_sandbox/sandbox_lease.py:77-87 - Problem:
idle_timeout: 0is the documented way to disable idle cleanup, butcompute_lease_ttl()gives that lease a fixed one-hour lifetime. The provider only renews active leases from the idle-checker loop, and that loop is not started whenidle_timeout <= 0. After one hour a live worker's active or warm sandbox therefore looks orphaned to another worker, which can adopt and later destroy it, recreating #4206 under a supported configuration. - Evidence: the added helper returns
max(3600.0, grace)for the disabled-cleanup case;AioSandboxProvider.__init__()starts_start_idle_checker()only when the timeout is positive, and_renew_active_leases()is called only by that checker.get()intentionally performs no renewal. - Suggested fix: decouple lease renewal/liveness from idle cleanup. Keep a renewal loop running while leases exist even when reaping is disabled, and derive the lease TTL from a bounded renewal interval rather than from
idle_timeoutalone. - Test: configure two providers with
idle_timeout=0, keep worker A's sandbox active past the lease horizon, run worker B's reconcile/reap path, and assert B never adopts or destroys A's container.
[P1] Fail acquisition when ownership cannot be published
- Location:
backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py:260-290 - Problem: both lease writes and lease-lock acquisition catch
OSError, log, and return success to callers._register_created_sandbox(),_register_discovered_sandbox(), reclaim, and reuse then return the sandbox id as usable even though no ownership record protects it. A read-only lease directory, full disk, or failed atomic replace silently disables the only cross-worker exclusion and lets a peer reconcile/destroy an actively used container. - Evidence:
_touch_sandbox_lease_locked()swallowstouch_lease()failure and_touch_sandbox_lease()has no success result; callers continue directly to their normal success return. The read/destroy side fails closed, but ownership establishment currently fails open. - Suggested fix: make initial claim/renewal return a result or raise. Registration and reclaim must not expose the sandbox unless the lease is durably published; clean up a just-created container on failure. A later renewal failure should remove the sandbox from service or otherwise prevent peer destruction until ownership is re-established.
- Test: force the lease lock and
write_lease()to raise during create/discover/reclaim, and assert the operation fails without returning a usable sandbox id or leaving an unowned container active.
Reviewed base SHA: 693507870cae26dadf3af487f811eb2bcfe18f87
Reviewed head SHA: 6020cbf01735d289a7ab48bd00956e30b031463d
|
@fancyboi999 Both confirmed against this head — and both are protocol-level rather than file-lock-level, which means they outlive the mechanism and land on the rework plan rather than on this diff. [P1] Renewal dies with idle cleanup. Verified: [P1] Ownership publish fails open. Verified: Where the fix goes. @WillemJiang asked above to share sandbox state through a third-party service instead of maintaining it per gateway instance, and I proposed a pluggable lease store (
Both of your suggested tests carry over unchanged (two providers at @WillemJiang — amended plan, same shape as before plus the two above:
If that works for you I will rework this PR onto it. Happy to close this one and open a fresh PR instead if you would rather review the store as its own change — the diff will not have much in common with what is here now. |
|
@AnnaSuSu +1 to follow the steam_bridge style implementation, we can have memory and Redis implementations to share this status information. |
Rework of the bytedance#4206 fix per review: ownership state is shared through a third-party service instead of being maintained per gateway instance, following the stream_bridge precedent (sandbox.ownership.type: memory | redis). The file lease and its same-host flock guard are deleted, not ported — they only covered workers on one host, while the deployment that hits bytedance#4206 is a load-balanced multi-instance gateway. A lease answers "who reaps this container", not "who may use it". Containers are deterministic per (user, thread), so consecutive turns legitimately land on different instances: take() transfers ownership on acquire, while claim() gates every adopt/reap path. Leases carry a state — own: or del: — so a takeover is refused against a teardown in progress. Without it an unconditional take() would overwrite a destroyer's claim and the peer's container stop would land on a sandbox the new owner had already handed to an agent. renew() distinguishes a lapsed lease from one a peer took; only the latter drops the sandbox. Collapsing them meant a Redis restart evicted every in-flight sandbox on every instance at once. Renewal runs on its own thread with a TTL derived from its interval, never from idle_timeout: renewal used to ride the idle checker, which does not start at idle_timeout: 0, so leases silently lapsed on a supported config. Ownership establishment is fail-closed: a sandbox whose ownership cannot be published is never handed out, and a just-created container is destroyed rather than leaked as an adoptable orphan. Every destroy path claims before untracking. The memory store is single-instance only and says so; the resolver reads app_config.stream_bridge and the env var in the bridge's own order, so deployments already using Redis get a redis ownership store without extra config.
|
@WillemJiang Reworked onto the One deliberate divergence from Multi-instance deployments need no new config. @fancyboi999 both of your P1s are fixed, and they survived the mechanism swap exactly as you'd expect:
Two of my own mistakes worth surfacing, since one of them was load-bearing:
Also fixed in the same pass: One coverage gap I want to flag rather than paper over: the redis tier is Out of scope, pre-existing: |
fancyboi999
left a comment
There was a problem hiding this comment.
Thanks @AnnaSuSu. I found one remaining ownership race that should be addressed before this is ready.
[P1] Do not treat a lost Redis key as proof that a running container is orphaned
- Location:
backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py:367-409 - Problem: after Redis loses its lease keys, a newly starting gateway instance immediately claims every running container during startup reconciliation. The live instance that is still using a container then sees
RenewOutcome.LOST, drops its handle, and the new instance can later idle-destroy that container. This recreates #4206 during the Redis-reset case the newLAPSEDhandling claims to make safe. - Evidence:
__init__runs_reconcile_orphans()before starting its renewal thread; reconciliation callsclaim()for every running container, andclaim()succeeds whenever the Redis key is absent. Existing owners only republish absent leases on their next renewal tick. A fresh instance started in that window wins the key first, so the old owner's later renewal reportsLOST. The added lost-state test exercises the original owner renewing first, not a peer reconciling first. - Suggested fix: add a recovery grace before an instance may adopt a keyless running container, long enough for surviving owners to republish, or persist enough owner/liveness metadata to distinguish a crashed owner from backend state loss. Do not immediately claim an absent key from startup reconciliation.
- Test: with provider A actively tracking a container, delete the Redis ownership key, start/reconcile provider B before A's renewal tick, and assert B does not adopt or reap the container; after A renews, it must remain the owner.
Reviewed base SHA: 8da7cbf0285db4781054bddac765947626c07748
Reviewed head SHA: a91cd789b60c6932045307f6552630c6f0f13a4c
…tainer An absent ownership lease meant two opposite things on two paths. Renewal reads it as LAPSED and re-establishes it: nobody took the lease, so the container is still ours. Reconciliation read the same absent key as "orphan" and adopted on sight. After the store loses its keys (a Redis restart without persistence, or eviction under maxmemory) every owner is alive and merely pre-renewal-tick. Whichever instance reconciled first therefore adopted every live container; each real owner's next renewal reported LOST and dropped a sandbox it was serving mid-turn, leaving it for the adopter to idle-destroy — bytedance#4206 through the back door, in the very case the LAPSED handling was added to make safe. Not limited to startup: an already-running instance hits the same window from the idle checker's periodic reconcile. _adoptable_after_grace requires an untracked container to be seen unowned across a full lease TTL before it can be adopted. That rebuilds the delay the state loss erased: a live owner republishes within one renewal interval, shorter than the TTL by construction, while a crashed owner never does, so its containers are still adopted one grace later rather than leaking. A republished lease resets the grace; a pausing-only timer would still expire over a live owner's lease. The peek is read-only — the atomic claim still gates adoption. The grace is skipped when the store cannot coordinate across processes: no peer can hold a lease such a store would show us, so single-instance deployments keep instant orphan cleanup, and a grace could not help a multi-worker gateway on memory anyway.
|
Thanks @fancyboi999 — real, and fixed in 94a12a8. Reproduced against a real Redis before touching anything: The shape of it. An absent lease meant two opposite things on two paths this PR introduced together: Two corrections to the framing, both of which make it worse rather than better:
Also worth noting for the trigger surface: this does not need a Redis restart. A shared Redis with The fix. Three design points worth flagging:
Cost: a genuinely crashed owner's containers are adopted one TTL (120s by default) later than before. They are idle containers; that buys not killing live ones. Your suggested test carries over as written. I checked each of them goes red in both directions — grace always-permissive (4 red) and grace never-expiring (2 red) — so neither half of the predicate is unguarded. Focused suite green (87 passed, Unchanged from my last comment: the redis tier is still |
fancyboi999
left a comment
There was a problem hiding this comment.
Thanks @AnnaSuSu. I found one remaining ownership race that should be addressed before this is ready.
[P1] Keep the teardown exclusion alive until the container stop completes
- Location:
backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py:1104-1122and:1433-1454 - Problem:
claim(..., for_destroy=True)writes adel:marker with the ordinary lease TTL, but nothing refreshes that marker while_backend.destroy()is running. If the stop lasts past the TTL, a peer'stake()succeeds and hands the still-running container to a new turn; the original destroy then completes underneath that turn, recreating the mid-turn 502 that this protocol is meant to prevent. - Evidence: both destroy paths claim once and call the blocking backend stop before releasing.
RedisOwnershipStore._CLAIM_SCRIPTapplies the normalPXTTL todel:, while_RENEW_SCRIPTrenews onlyown:and deliberately reports a teardown asLOST. The provider renewal loop also stops tracking an id once it sees that outcome, so it cannot extend the destroy exclusion. This is reachable without an abnormal backend:renewal_interval_secondsaccepts any value greater than zero andttl_multiplieronly has a minimum of 2, so a valid configuration can make the teardown TTL shorter than a normal container stop;LocalContainerBackend._stop_container()itself has no subprocess timeout. - Suggested fix: keep a destroy-specific lease heartbeat alive until the backend stop returns, while retaining a finite TTL so a destroyer crash eventually releases the container. A separate teardown TTL is only sufficient if it is bounded above every backend's real stop deadline; refreshing the
del:state for the duration is the safer invariant. - Test: block provider A's fake backend destroy longer than the configured lease TTL, assert provider B's
take()remains refused for the full in-flight stop, then unblock A and assert the marker is released so B can acquire or recreate cleanly.
Reviewed base SHA: 693507870cae26dadf3af487f811eb2bcfe18f87
Reviewed head SHA: 94a12a817c1e8bfc9867f83051ac9ada64f88cb9
…op runs claim(..., for_destroy=True) wrote the del: marker with the ordinary lease TTL and nothing refreshed it. renew() extends only own: and deliberately reports a teardown as LOST, and the destroy paths drop the sandbox from the maps the renewal loop iterates — so a container stop that outlived the TTL let the marker lapse, a peer's take() succeeded against the still-running container, and the stop then landed on the turn that had just been handed it. That is the exact window the del: state exists to close, reopened by its own expiry. The two lease states alone never made the per-sandbox flock redundant, as I claimed when deleting it: a held lock cannot expire, a lease can. The exclusion has to be held deliberately rather than assumed to outlast the work it guards. _held_teardown_lease wraps both _backend.destroy() call sites and re-claims the marker every renewal_interval_seconds until the stop returns. No store change is needed: claim(for_destroy=True) already refreshes an existing del: marker on both backends. Reachable without an abnormal backend. The schema bounds only renewal_interval_seconds (> 0) and ttl_multiplier (>= 2), so a legal config puts the TTL below a normal container stop; and LocalContainerBackend._stop_container passes no timeout to subprocess.run, so a wedged daemon blocks unbounded even at the default 120s TTL. The TTL stays finite on purpose: the heartbeat dies with the process, so a destroyer that crashes mid-stop still releases the container one TTL later instead of marking it undestroyable forever.
|
Thanks @fancyboi999 — real, and fixed in 90936b4. Reproduced against a real Redis first, with a legal config ( The marker evaporates exactly at the TTL, mid-stop, and B walks off with a container that is still being stopped. What I got wrong, and it was load-bearing. When I deleted the per-sandbox Your evidence chain checks out end to end: One addition to the trigger surface, which I think is worse than the config case you cited. You're right that the schema permits a TTL below a normal container stop ( The fix. If the heartbeat's own claim ever returns false, the exclusion is already gone (store lost the key and a peer took it) and the stop cannot be recalled, so it logs an error rather than pretending to recover. That residue — a store flush landing inside a container stop — I'm leaving as a logged condition rather than adding more machinery for. Same run after the fix: Your test carries over as written — Two process notes, since both changed the result:
Focused suite green (89 passed: reconciliation + ownership store + the |
fancyboi999
left a comment
There was a problem hiding this comment.
Thanks @AnnaSuSu. The new teardown heartbeat fixes the warm-pool and explicit destroy paths, but one remaining stop path can still reopen the same cross-worker race.
[P1] Hold the teardown lease through unhealthy-container stops
- Location:
backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py:1131-1136 - Problem:
_drop_unhealthy_sandbox()writes thedel:marker once and then calls_backend.destroy()without_held_teardown_lease(). The sandbox has already been removed from the maps that the normal renewal loop scans, so if this stop outlives the lease TTL, a peer'stake()can succeed and hand the still-running container to a new turn; the old stop then completes underneath that turn and recreates the mid-turn 502 from #4206. - Evidence: this head wraps
_backend.destroy()with_held_teardown_lease()in_destroy_warm_entry()anddestroy(), but the third ownership-gated stop above still calls the backend directly.LocalContainerBackend._stop_container()has no subprocess timeout, and validrenewal_interval_seconds/ttl_multipliervalues can make the TTL shorter than that stop. The added unhealthy-sandbox test proves only that a peer-owned claim is refused; it never lets this path's own teardown marker expire during the stop. - Suggested fix: run the unhealthy-container backend stop inside
_held_teardown_lease()as well, with ownership release handled for both success and failure consistently with the other destroy paths. - Test: block
_drop_unhealthy_sandbox()'s fake backend stop beyond the configured TTL, assert a peer'stake()remains refused for the entire stop, then unblock it and assert the marker is released.
Reviewed base SHA: 693507870cae26dadf3af487f811eb2bcfe18f87
Reviewed head SHA: 90936b49aa24668e0de7bd59c40bb47e5bbb2e15
… claims that had no test 90936b4 said `_held_teardown_lease` wrapped "both" `_backend.destroy()` call sites. There are three. `_drop_unhealthy_sandbox` marks `del:` and then blocks on the same unbounded stop, and it untracks *before* claiming, so `_renew_owned_leases` cannot see the id either — nothing refreshed the marker. Reproduced against a real redis: the peer's `take()` succeeds 1.0s into a 2.5s stop. Same window, third path. That miss came from the habit the rest of this commit addresses: a property asserted in prose, with no test that could falsify it. Auditing every load-bearing claim in this feature — AGENTS.md, the store docstrings, the provider's design comments — against the test that would go red turned up several more, each verified by mutating the code and watching the suite stay green. Tests that could not fail: - `test_reconcile_fails_closed_when_ownership_unknown` reached the grace gate, not the claim. A bare MagicMock answers `owner()` with a truthy mock, so the container read as peer-owned and deferred; `claim()` was never called. It stayed green with `_claim_ownership` failing open. Adding the grace ahead of the claim is what hollowed it out — inserting a gate can silently disarm the tests for the gate behind it. - `test_adoption_grace_restarts_when_a_live_owner_republishes` never distinguished reset from pause. Those diverge only on a *second* lapse, which it never drove, so it passed with the reset deleted. Claims with no test at all, each now pinned (mutation → red, per test): - `destroy()`, `_evict_oldest_warm`, `_reclaim_warm_pool_sandbox`, `_register_created_sandbox` and `shutdown()`'s warm loop were each the one untested sibling of an "every path does X" enumeration. `shutdown()` was never driven with a non-empty warm pool, so a loop bypassing the ownership claim — stopping a live peer's container on our exit — went unnoticed. - Renewal's unknown-is-not-lost rule, the single deliberate exception to fail-closed. Inverting it drops every active and warm sandbox on every instance the moment the store blinks. - Both hops of the stream-bridge redis inference. Deleting either left the suite green while every config.yaml-native multi-instance deployment silently fell back to memory — bytedance#4206 reopened on exactly the deployments the inference exists for. Claims narrowed instead, because they promised more than the code delivers: - "run against both backends ... cannot drift" — CI provisions no redis, so the merge gate runs the memory tier only and the Lua never executes there. - "Every destroy path claims before untracking" — `_drop_unhealthy_sandbox` untracks first, deliberately, under its `expected_info` TOCTOU guard. - "Atomic: concurrent claims from different instances cannot both succeed" — true via Lua on redis, vacuous on the single-instance memory store, and pinned by neither, since the contract suite drives sequential calls. A concurrency test against the memory store would make the claim look covered while the mechanism that carries it still never runs in CI.
|
Self-audit follow-up, pushed as 0d2377b. Nobody asked for this one — I went looking after your last review, and the first thing I found was that my own fix in 90936b4 was incomplete. The fix I got wrong90936b4 says
Reproduced against a real redis, same harness as last time — legal config ( Same window, third path — and this one is worse: Why I went lookingFour rounds, four findings, and they were all the same shape — not "the newest code is weakest", which is what I'd have guessed, but a property asserted in prose with no test that could falsify it:
Round 1's is the purest: the words "Fail closed" were sitting directly above the handler that could never fire. The documentation being good is what makes this dangerous — a confident comment on a special case reads as "considered and handled", which stops the next reader, and the author, from checking. You had it in round 2 already: "My TTL helper made the disabled case look handled while the renewal path it depends on was never started — worse than not special-casing it." So I stopped asking "is this claim true" (I'm the worst-placed reader of prose I wrote — I found it convincing once already) and asked the mechanical question instead: which test goes red if this is false? That one I can answer by grepping, and it catches all four. What that foundEvery item below was verified by mutating the code and watching the suite stay green — not by reading. Two tests that could not fail:
Claims with no test at all, now pinned (mutation → red, per test):
Three claims narrowed instead, because they promised more than the code delivers:
One left, and I don't think it belongs here
But it isn't a regression — Verification: |
The three `del:`-marked stop paths disagreed on failure. `_destroy_warm_entry` releases on both outcomes and says why: the stop failed, so the container is probably still up, and a marker left behind refuses its own thread's `take()` until the TTL lapses. `_drop_unhealthy_sandbox` does the same. `destroy()` had no such guard — a raising backend propagated straight past `_release_ownership`, and the thread could not re-acquire for a full TTL. Fails safe rather than fatal: a stuck marker stops peers from touching the container, it is not the cross-instance kill. But the paths must agree, and this one is the odd one out. Release, then re-raise. Swallowing would be the easier symmetry with `_destroy_warm_entry`'s `return False`, but `destroy()` has no failure return and `shutdown()` logs per sandbox off the exception, so swallowing would silently narrow what callers can see. Found by comparing the three paths after @fancyboi999 asked for release to be handled "consistently with the other destroy paths" on the unhealthy path — which 0d2377b already does. This is the sibling that wasn't.
|
@fancyboi999 We crossed — your review landed against Your three asks against the current head (
Your diagnosis matches mine line for line, including the part that makes this path worse than the other two: And you're right that Your second ask found one more, in
|
| path | releases on a failed stop |
|---|---|
_destroy_warm_entry |
✅ — explicitly, with a comment saying why |
_drop_unhealthy_sandbox |
✅ (0d2377b2) |
destroy() |
❌ — a raising backend propagated straight past _release_ownership |
_destroy_warm_entry already spells out the reason: the stop failed, so the container is probably still up, and a marker left behind blocks its own thread from re-acquiring it. destroy() left the id stranded under del: for a full TTL. Fails safe rather than fatal — a stuck marker stops peers from touching the container, it is not the cross-instance kill — but the three paths must agree, and they didn't.
Fixed in e3146075: release, then re-raise. Swallowing would have been the easier symmetry with _destroy_warm_entry's return False, but destroy() has no failure return and shutdown() logs per sandbox off the exception, so swallowing would have silently narrowed what callers can see. Red-checked both directions — no release → red; release but swallow → red.
Still open, and I'd rather not grow this PR for it
_create_sandbox / _create_sandbox_async destroy on a readiness timeout with no claim and no del: marker. The container is running and unowned for up to 60s, so a peer can discover and take it before our stop lands. That contradicts "claim() gates every adopt/reap path", which is my sentence to answer for.
It isn't a regression — 693507870 gated nothing at all — and the fix has design questions worth their own review (claim before the readiness wait, or after? does a readiness failure roll back through the same path as an ownership failure?). I'd rather file it than bolt it onto a five-round PR, but say the word if you'd prefer it here.
134 passed focused, 7602 passed full backend suite, ruff clean.
|
Correction to my last comment, @fancyboi999 — the readiness-timeout item I raised is real but narrower than I stated, and I stated it without checking. What I wrote:
The second half is false on the local backend. sandbox_url = f"http://{sandbox_host}:{port}"
if not wait_for_sandbox_ready(sandbox_url, timeout=5):
return NoneA container that has not become ready is exactly the container that fails that check, so a peer cannot discover it, and the deterministic-id path never reaches
if resp.status_code == 404:
return None
resp.raise_for_status()
data = resp.json()
return SandboxInfo(sandbox_id=sandbox_id, sandbox_url=data["sandbox_url"])So in provisioner/K8s mode: A creates the Pod and starts its 60s readiness wait; a turn for the same thread routes to B on another host, where the file lock excludes nothing; B's discover returns the not-yet-ready Pod; So: real, provisioner-mode only, and the sentence " Worth saying plainly, because it is the same defect I spent that comment describing: I took that finding from a source I did not verify, and repeated it as fact in a comment about not doing exactly that. Reading |
|
Filed the readiness-timeout window as #4248 with the corrected scope (provisioner mode only; local is excluded three ways over, all recorded there). I'll take it myself once this lands — it predates this PR ( Say the word if you'd rather it land in this PR instead. |
config.example.yaml went to 27 with the new sandbox.ownership section, but the chart embeds its own copy and stayed at 26, so validate-chart failed. A bare bump: the chart already sets stream_bridge.type=redis, which is what resolve_ownership_config infers a redis ownership store from, so no field change is needed.
Summary
Fixes #4206.
Reworked per @WillemJiang's review: ownership state is now shared through a third-party service instead of being maintained per gateway instance, following the
stream_bridgeprecedent (type: memory | redis).The previous revision of this PR coordinated ownership through file leases + a same-host
flockguard. That only covers multiple worker processes on one host, but the deployment that actually hits #4206 is a load-balanced multi-instance gateway. The file lease and the flock guard are deleted, not ported.It also fixes the two protocol-level bugs @fancyboi999 found on the previous revision. Both were store-agnostic — they survive the mechanism swap verbatim — so they are fixed here rather than left behind with the code that carried them.
Approach
New pluggable ownership store at
community/aio_sandbox/ownership/, selected bysandbox.ownership.typeand resolved exactly likestream_bridge: factory dispatch onconfig.type, lazy per-branch import (redisstays out of memory-only installs), the existing optionalredisextra (plusscripts/detect_uv_extras.pyauto-detection), an env escape hatch, and an injectable client seam for tests.memoryis the default and, likestream_bridge's memory backend, is single-instance only — it declaressupports_cross_process = Falseand the provider logs a warning at startup when the configured store cannot see peers. Multi-instance deployments do not have to notice that: the resolver readsapp_config.stream_bridgeandDEER_FLOW_STREAM_BRIDGE_REDIS_URL, in the same order the bridge's own resolver uses, so any deployment already pointing the stream bridge at Redis — i.e. every load-balanced one, since the bridge requires it — gets a redis ownership store with no extra config.One deliberate divergence from
stream_bridge: the store interface is sync, not async.StreamBridgeis async because it is driven from the event loop. Ownership is the opposite — its callers areAioSandboxProvider.__init__/_reconcile_orphans, the background idle and renewal threads, andrelease()from the syncafter_agenthook. An async ABC would force every one of those sync paths to marshal onto a loop for no benefit. So this copies the pluggability shape —type:discriminator, config model, factory with lazy imports, optional extra, injectable client,supports_cross_processcapability flag, redis not re-exported from__init__— rather than the async-ness. The one path that does run on the event loop (get()) deliberately never touches the store at all.A lease answers "who is responsible for reaping this container", not "who may use it". That distinction drives the interface, and getting it wrong strands threads:
take()— acquire path, takes over from whoever held it. A container is deterministic per (user, thread), so consecutive turns of one thread legitimately land on different instances. A conditional claim here would refuse while the previous instance's lease was still live and break every load-balanced follow-up turn.claim()— succeeds only if unowned or already ours; gates every adopt/reap path. This is 多 Worker 下 AioSandboxProvider 的 orphan reconcile 会 Adopt 并 idle 销毁其他 Worker 的沙箱,导致工具调用 502 / Connection refused #4206.release()never clears a peer's lease.A lease carries a state, and that is what closes the destroy window without a mutex.
own:<owner>means "responsible for this container";del:<owner>(claim(..., for_destroy=True)) means "tearing it down".take()is refused against adel:lease. Without that, an unconditional takeover would silently overwrite a destroyer's claim: B claims container X for destroy and starts the (multi-second) container stop, a turn for X's thread routes to A, A takes the lease and hands X to an agent, B's stop lands, and the agent gets 502 mid-turn — #4206 with extra steps. The previous revision covered this window with a per-sandbox flock; the two-state lease covers it atomically instead, which is what lets the flock go rather than be ported. A destroyer that dies mid-stop leaves a marker that lapses with the TTL.Each mutation is one Lua script:
SET NXalone is not enough (it fails on a key we already own), and a GET-then-SET fallback in Python reopens the race the script closes.renew()distinguishes a lapsed lease from a lost one (RenewOutcome.LAPSED/LOST), and the caller decides. A lapsed lease is absent — nobody took it — so it is re-established; a lease a peer holds is never re-taken. Collapsing the two into one falsy value meant a Redis restart without persistence (every key gone) would drop every in-flight sandbox on every instance at once, even though the containers were fine and unclaimed.[P1, @fancyboi999] Renewal no longer rides the idle checker.
_renew_active_leaseshad exactly one caller (_cleanup_idle_resources), and__init__only starts the idle checker whenidle_timeout > 0.config.example.yamldocuments0as "keep warm VMs until shutdown", so on that supported config nothing ever refreshed a lease and #4206 returned one TTL later. Renewal is now its own bounded-interval thread, and the TTL derives from that interval (renewal_interval_seconds × ttl_multiplier, multiplier ≥ 2 so one missed renewal cannot expire a live owner) — never fromidle_timeout. Liveness and reaping no longer share a switch. Renewal covers warm entries too, and losing a lease drops the sandbox from this instance's maps without touching the container — destroying it there would be the very cross-instance kill this store prevents.[P1, @fancyboi999] Ownership establishment is fail-closed. Every publish helper used to swallow
OSErrorinto a warning while the caller returned the sandbox id on the next line, so a read-only lease dir or a failed write silently disabled the only cross-instance exclusion while the sandbox was handed out as usable. Now a sandbox whose ownership cannot be published is never handed out: acquiring raises, and a just-created container is destroyed rather than leaked as an adoptable orphan. Reaping fails closed symmetrically — a store that cannot answer counts as peer-owned. This matches the stream bridge's documented fail-hard v1 policy.Test plan
tests/test_sandbox_ownership_store.py) — one parametrized suite run against both backends, so memory and redis cannot drift on the semantics the provider depends on: claim exclusivity, failed claim does not steal,taketransfers from a live peer and makes the old owner's renew reportLOST,takerefused during a teardown and allowed once released, a stale teardown marker expires, renew reportsLAPSED(notLOST) for an absent lease and never re-acquires on its own, release only drops our own, TTL expiry frees a crashed owner's containertests/test_sandbox_orphan_reconciliation.py) — two providers sharing one store (models two instances on one Redis): peer-owned container not adopted / not reaped, the 多 Worker 下 AioSandboxProvider 的 orphan reconcile 会 Adopt 并 idle 销毁其他 Worker 的沙箱,导致工具调用 502 / Connection refused #4206 issue-log path, crashed-owner container adoptable after TTL, fail-closed on publish + destroy + reconcile,__init__starts renewal atidle_timeout: 0and600, lost lease drops tracking without destroying, acquire takes over so a thread can move instances, acquire refuses a container a peer is destroying, a store losing all state does not evict live sandboxes, destroy claims before untracking, a refused idle destroy keeps its warm entry, an unhealthy peer-owned sandbox is not destroyedtests/blocking_io/test_aio_sandbox_get.py) —get()stays a pure in-memory lookup. Rewritten to inject a deliberately-blocking probe store, so the anchor keeps its teeth regardless of which backend is configured; plus a meta-test proving the probe actually trips the gate (otherwise the anchor could pass vacuously)test_init_always_starts_lease_renewal[0]and[600]; destroy skipping the ownership check → 3 tests; unconditional reconcile adopt (the original 多 Worker 下 AioSandboxProvider 的 orphan reconcile 会 Adopt 并 idle 销毁其他 Worker 的沙箱,导致工具调用 502 / Connection refused #4206) → 4 tests;take()made conditional → 4 tests;take()ignoring the teardown state → 4 tests;renew()collapsing lapsed into lost → 3 tests; destroy untracking before its claim → 1 testtest_browserless_client/test_crawl4ai_tools/test_fastcrw_toolsare environmental on my machine (local DNS resolvesexample.comto198.18.4.13, inside the reserved198.18.0.0/15, so the SSRF guard correctly refuses); those three modules reference nothing from this diffcd backend && make lint/make formatcleanKnown coverage gap (flagging deliberately): the redis tier is
@pytest.mark.integration+ self-skipping, per this repo's documented policy (backend/pyproject.toml: "integration: tests that require an external service (e.g. Redis); skipped when unavailable"), so CI does not exercise the Lua scripts — same status as the existing stream-bridge integration tier. I ran that tier locally against a real Redis 7.2.3 (40 passed,DEER_FLOW_TEST_REDIS_URL=redis://localhost:6399/15). I deliberately did not addfakeredis[lua]: its Lua is a reimplementation of Redis semantics, so it would give false confidence on precisely the atomicity this depends on, and it drags a C extension into the dev group for every contributor. If you want this covered in CI, the honest fix is a redis service container inbackend-unit-tests.yml— happy to do it, but it also activates the currently-skipped stream-bridge integration tests, so it felt like it belonged outside a bug-fix PR rather than bundled in silently.Out of scope (pre-existing, not introduced here):
SandboxMiddleware._acquire_sandbox_asynccallsget_sandbox_provider()directly on the event loop, so the first acquire constructs the provider and runs_reconcile_orphans()there. That path already did blocking container-backend IO; withtype: redisit becomes network IO. Same shape, worth a separate look.AI assistance
How you used it: Used Claude Code to map the
stream_bridgefactory/config/extra wiring onto sandbox ownership, work out which provider call sites are sync-only, draft the store plus tests, and then to review the result adversarially — that review is where several of the bugs below were caught. I verified everything myself against a real Redis, driving twoAioSandboxProviderinstances through the real provider methods over a shared container backend: a peer neither adopts nor destroys a live owner's container; a thread whose next turn moves instance still acquires (the previous owner then drops tracking without touching the container); a crashed owner's container becomes adoptable once its lease expires. I reproduced each bug end-to-end before fixing it rather than trusting the tests — atidle_timeout: 0the old renewal wiring leftowner = Noneafter the TTL and the peer destroyed the live container, and for the destroy race I gave the fake container stop a real delay so the interleaving was genuine: without the teardown state the acquiring instance was handed a container the reaper then killed (502 mid-turn), with it the acquire is refused and cold-starts. I also ran the per-test red check described above, the full suite, and ruff.Two things worth surfacing for review, since both were my own mistakes:
take(acquire) andclaim(adopt/reap).test_acquire_takes_over_ownership_so_a_thread_can_move_instancespins it.take()ignored it. The destroy → re-acquire window was still open, i.e. 多 Worker 下 AioSandboxProvider 的 orphan reconcile 会 Adopt 并 idle 销毁其他 Worker 的沙箱,导致工具调用 502 / Connection refused #4206 was not actually fixed. The two-state lease is what makes the original assertion true;test_acquire_refuses_a_container_a_peer_is_destroyingand the store-level teardown tests pin it. I mention it because it was the stated justification for deleting the flock guard, and it did not hold until this revision.I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.