Repository navigation
harbor: supervise sandbox starts and clean up only our own sandboxes on shutdown - #1313
jayzuccarelli wants to merge 9 commits into
Conversation
…xcept Failed subscripts and `del x[k]` were re-raised as InterpreterError, so `except KeyError:` / `except IndexError:` in agent code never matched. Keep the "Could not index ..." hint but raise the original exception type. Prepared with AI assistance (Claude Code); I reviewed the change and ran the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aclose took one snapshot of owned sandboxes, so a rollout queued for a creation permit could open a sandbox during cleanup that the snapshot never saw. aclose now sets a closing flag under the same lock that start uses to register a sandbox, so every sandbox is either in the snapshot or its start is refused (RuntimeError) and its permit released. New test: test_no_new_sandbox_starts_once_cleanup_begins (fails before, passes after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@sergiopaniego saw the Harbor docs going in today. When you have time, could you take a look at this one and #1312 (a small |
A handler registered with loop.add_signal_handler (uvicorn before 0.29) is delivered by the loop through the wakeup fd, so the signal.signal wrapper ran alongside it instead of before it: the server began shutting down while cleanup was still running, and chaining then delivered the signal a second time, which uvicorn reads as a forced exit on SIGINT. On an asyncio loop the supervisor now takes the loop's slot and puts the callback back when cleanup is done. A loop that does not expose its callbacks (uvloop) cannot be wrapped; there the signal is no longer delivered again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed a scoped cleanup fix in 42950b8. A failed Four regression cases failed on 81a1228 and pass with the fix: direct retry, retry through One separate lifecycle gap remains: |
…rvisor # Conflicts: # rfcs/012-harbor-capture-providers.md # src/openenv/harbor/rollout.py
|
Thanks @burtenshaw for the cleanup fix and the regression cases. Merged main to clear the conflict; ready from my side. |
UI rollouts start sandboxes under asyncio.run in worker threads, where install_signal_handlers() is a no-op, and nothing else installed them, so SIGINT/SIGTERM left those sandboxes behind. build_app now installs them in a startup hook, which uvicorn runs on the main thread inside its own signal capture; aclose already stops each sandbox on the loop that owns it. A signal-driven cleanup gave up after 60 s while stop() may wait 120 s for an in-flight start, so a signal during creation chained before anything was deleted. The cleanup budget is now the start grace plus 60 s, read at call time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for merging 1. A sandbox created while its start is being cancelled is never deleted. Regression testasync def test_stop_deletes_a_sandbox_created_while_its_start_is_cancelled(monkeypatch):
# The backend finishes creating the sandbox while its start unwinds.
monkeypatch.setattr(supervisor_mod, "_START_GRACE_S", 0)
release = asyncio.Event()
class SlowCancelEnv(FakeEnv):
async def start(self, force_build: bool) -> None:
self.log.append((self.name, "start"))
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
await release.wait()
self.log.append((self.name, "created"))
raise
log: list = []
sup = supervisor_mod.SandboxSupervisor()
env = SlowCancelEnv(log)
sup.adopt(make_trial(env))
starting = asyncio.ensure_future(env.start(force_build=False))
await asyncio.sleep(0.01)
stopping = asyncio.ensure_future(env.stop(delete=True))
await asyncio.sleep(0.01)
release.set()
await asyncio.wait_for(stopping, 1)
with pytest.raises(asyncio.CancelledError):
await starting
# The stop must come after the creation, or the sandbox outlives cleanup.
assert log == [("env", "start"), ("env", "created"), ("env", "stop", True)]
assert sup.owned == 0One way to fix it is to wait for the cancelled start to finish before stopping. With this change the test above and the 17 existing supervisor tests pass: if not box.start_task.done():
box.start_task.cancel()
- elif not box.start_task.cancelled():
+ # The backend can finish creating the sandbox while the start unwinds.
+ await asyncio.wait({box.start_task})
+ if not box.start_task.cancelled():The trade-off is that a start which ignores cancellation now delays its own trial's stop instead of leaking. 2. On a signal, the cleanup deadline is shorter than the start grace. Signal cleanup waits |
_stop_once cancelled a start that outlasted _START_GRACE_S and called real_stop at once, so a sandbox the backend finished creating while the start unwound was never deleted. Wait for the cancelled start first. Regression test and fix from burtenshaw's review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, both fixed. Item 1 uses your regression test verbatim and your fix: stop now waits for a cancelled start to unwind, so the sandbox is deleted after it is created. For item 2, the signal deadline now covers the grace: 120 s for the start plus 60 s to stop. A second signal still skips the wait. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1097a60. Configure here.
| if not box.start_task.done(): | ||
| box.start_task.cancel() | ||
| # The backend can finish creating the sandbox while the start unwinds. | ||
| await asyncio.wait({box.start_task}) |
There was a problem hiding this comment.
Hung start cancel blocks sandbox teardown
Medium Severity
After _START_GRACE_S, _stop_once cancels an in-flight start and then asyncio.waits for it with no timeout. A start that ignores cancellation, or whose CancelledError handler keeps creating, now blocks real_stop indefinitely, which is the hang _START_GRACE_S was meant to prevent. Signal cleanup still gives up at _CLEANUP_TIMEOUT_S, so the process can exit while that wait is running and the sandbox is never deleted.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1097a60. Configure here.
|
Verified at 1097a60, thanks for the quick fixes. The regression test from my earlier comment passes now, and so do the supervisor and hosted-serving suites (31 tests). Signal cleanup now outlasts the start grace, and the new test covers the startup hook that installs the handlers for UI rollouts. CI is green. |


Adds a process-wide Harbor sandbox supervisor.
run_rolloutadopts each trial afterTrial.create, so the process can cap concurrent sandbox creation (OPENENV_HARBOR_MAX_SANDBOX_STARTS), tear down only the sandboxes it started on SIGINT/SIGTERM, and avoid leaking a sandbox when a rollout is cancelled mid-start. Once cleanup begins, new starts are refused instead of slipping past the cleanup snapshot. Docs (harbor.md, the harbor env README, RFC 012) describe the env var and shutdown behavior; unit tests cover ownership, idempotent stop, limits, cancellation and signal chaining, with optional Docker integration tests.Prepared with AI assistance (Claude Code); I reviewed the change and ran the tests.
Known gap: the docker test for cancelling during sandbox creation passes with or without the supervisor, so that leak path is covered by a unit test of the stop-after-start ordering rather than reproduced end to end. Happy to tighten it if you have a reliable way to trigger it.
Note
Medium Risk
Touches process-wide signal handling and async sandbox lifecycle across threads and event loops; bugs could affect shutdown behavior or leak sandboxes, though scope is limited to adopted trials.
Overview
Adds a process-wide
SandboxSupervisorthatrun_rolloutadopts into after eachTrial.create, wrappingagent_environment.start/stopwithout replacing Harbor's normal teardown.Concurrent creation is bounded separately from rollout concurrency via
OPENENV_HARBOR_MAX_SANDBOX_STARTS(unset/0= unlimited). Starts shield through cancellation sostop()can wait for in-flight creation and avoid orphaned sandboxes.On SIGINT/SIGTERM, the supervisor stops only sandboxes this process started, then chains to the previous handler (including uvicorn loop handlers). The Harbor server registers handlers at app startup because UI rollouts start sandboxes from worker threads.
Docs (harbor guide, env README, RFC 012) and tests (unit, serving startup, optional Docker) cover ownership, limits, signal chaining, and retry/idempotent cleanup.
Reviewed by Cursor Bugbot for commit 1097a60. Bugbot is set up for automated code reviews on this repo. Configure here.