Skip to content

feat: report interrupted teardown drains; never subordinate a user abort#1925

Open
rasdani wants to merge 29 commits into
mainfrom
feat/v1-teardown-drain-report
Open

feat: report interrupted teardown drains; never subordinate a user abort#1925
rasdani wants to merge 29 commits into
mainfrom
feat/v1-teardown-drain-report

Conversation

@rasdani

@rasdani rasdani commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1920 (merged into this base). Two changes, both about what happens when a user interrupts a run whose shielded teardowns are still draining.

1. Report the drain, in aggregate

The shield keeps teardowns running through Ctrl-C — but silently, so the wait reads as a hang; a user whose interrupt "does nothing" escalates to kill -9, which skips the atexit backstop and guarantees the leak. Now stop() tracks in-flight teardowns in a registry (_STOPPING) and the first teardown to absorb the interrupt logs one aggregate line; one closing line marks the drain finished. One line for 512 in-flight teardowns, not 512 — dedup via a flag that resets when the registry empties, so a later drain in a long-lived process reports again. Per-runtime detail (name + provider descriptor) logs at DEBUG for --verbose.

How the terminal reads

A Ctrl-C'd validate run (non-rich mode; WARNING lines are this PR, the rest is existing behavior):

01:44:03    INFO idx=0 valid=True reason=valid (3.2s)
01:44:04    INFO idx=1 valid=True reason=valid (3.4s)
^C
01:44:10 WARNING finishing 3 in-flight teardown(s) (oldest 2s) — Ctrl-C again to abort; leftover resources are freed best-effort at exit
          (…silence while the deletes finish…)
01:44:11 WARNING all in-flight teardowns finished
Traceback (most recent call last):
  ...
KeyboardInterrupt

The closing KeyboardInterrupt traceback is the pre-existing exit path (asyncio.run re-raises it), not something this PR adds. Pressing Ctrl-C again mid-drain aborts immediately: the loop dies, the atexit backstop frees what's left silently, then the same traceback. With --verbose, each teardown additionally identifies itself:

01:44:10   DEBUG finishing teardown of validate-noop-2-9c7279e1 (qj0gk9ra8h341h6n5e2zwcq3)

("freed best-effort at exit" = the sync atexit backstop, cleanup_at_exit.)

Known gap: under the --rich dashboard the report goes to the log file only (console=False — the dashboard owns the screen). Surfacing drain state in the dashboard is its own piece of work.

2. run_shielded: narrow except BaseException to except Exception

Raised by Cursor Bugbot on #1920 (partially valid). The headline mechanism there was off — a signal-handler KeyboardInterrupt normally lands in the event loop's frame (a suspended coroutine isn't executing) and kills the loop as designed — but the broad catch was still wrong in three narrow windows: a signal delivered while the interpreter executes run_shielded's own frame was swallowed (the abort silently ignored); a KeyboardInterrupt/SystemExit raised out of the teardown itself got chained under the pending CancelledError instead of winning; and a force-closed coroutine would swallow GeneratorExit once and re-await ("coroutine ignored GeneratorExit"). The catch's only job — defer the inner task's failure so it unifies with the cancellation at the bottom — needs Exception only. A user abort now always propagates immediately.

Verification

  • Full test matrix -m "not prime and not modal" passes.
  • test_cancelled_stop_still_frees_runtime — the e2e trip-wire for the guard, parametrized over runtimes so it actually gates PRs: subprocess (always in CI), docker (CI matrix), prime (local-only, real API DELETE observed server-side). A gate holds teardown open so the cancel deterministically lands mid-stop(); asserts teardown ran to completion, the resource is really gone (workdir / container / sandbox TERMINATED), the drain was reported, and the cancellation propagated. Verified to fail on all CI variants when the run_shielded guard in Runtime.stop is removed.
  • Live smoke test (docker, 3-second teardown window, single SIGINT mid-drain): the WARNING lines above are the verbatim output, exit 130, zero leaked containers. A second SIGINT mid-drain exits immediately with the atexit backstop leaving no containers behind.

🤖 Generated with Claude Code


Note

Medium Risk
Changes shutdown and cancellation behavior on the path that frees containers and paid sandboxes; fixes are guarded by new e2e leak checks but alter how second Ctrl-C and BaseException interact with shielded work.

Overview
When Ctrl-C hits while shielded runtime teardown is still running, Runtime.stop now tracks in-flight stops and logs one aggregated warning (count, oldest age, hint to Ctrl-C again) on the first absorbed cancellation, then a single closing line when the drain finishes or is aborted. Per-runtime detail stays at DEBUG.

run_shielded gains an optional interrupted callback (used by stop() for that report) and no longer swallows KeyboardInterrupt / SystemExit: user aborts propagate immediately instead of being chained under a pending CancelledError.

With console=False (rich dashboard), WARNING logs still print via Rich’s console so drain messages don’t look like a silent hang.

A new parametrized e2e test (subprocess / docker / prime) cancels mid-stop(), asserts teardown completes, resources are freed, drain text is logged, and cancellation still propagates.

Reviewed by Cursor Bugbot for commit b2a917e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Report interrupted teardown drains and immediately propagate user aborts in Runtime.stop

  • Runtime.stop now tracks in-flight teardowns in a module-level _STOPPING registry and, on first absorbed cancellation, logs a single aggregated warning with the in-flight count and oldest teardown age.
  • run_shielded in aio.py accepts an optional interrupted callback invoked on the first absorbed CancelledError; BaseException (e.g. KeyboardInterrupt, SystemExit) now propagates immediately instead of being swallowed.
  • When console=False, WARNING-level logs are now emitted via the active rich Console rather than suppressed, so warnings surface above a Live region.
  • A new e2e test test_cancelled_stop_still_frees_runtime validates that teardown completes and resources are freed even when cancellation arrives during stop(), and that the aggregated warning is logged.
  • Behavioral Change: KeyboardInterrupt/SystemExit during a shielded teardown now re-raises immediately; previously these were caught and suppressed by run_shielded.

Macroscope summarized b2a917e.

rasdani and others added 22 commits July 1, 2026 02:58
…patch

An empty --command alongside --script-path previously passed the
exactly-one check (bool("") is falsy) but run_action dispatches on
`command is not None`, so the empty command ran and the script was
silently ignored. Validate on `is None` so validation and execution
agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubprocessRuntime.start() mkdirs /tmp/<name>, so the fixed
debug-{task.idx} name failed on a concurrent session or a stale
workdir left by a failed teardown. Suffix debug and validate
runtime names with a short uuid, matching eval's per-rollout
unique naming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves verifiers/v1/cli/validate.py against main's setup injection
change (#1908): setup is now dispatched via invoke() with task/trace/
runtime available by name. Applied the same convention to the setup
call sites this branch added (_run_noop and the debug CLI's
debug_task), constructing the trace with the taskset's state class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public docs/ are still v0-facing (the v1 migration is tracked in
#1916); drop the V1 sections added to docs/evaluation.md and
docs/reference.md. The validate/debug CLI docs live in
verifiers/v1/GUIDE.md and README.md. The SandboxDebugEnv deprecation
note stays: the class warns at runtime either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: reintroduce once v1 goes public. The validate/debug
guidance lives in verifiers/v1/GUIDE.md meanwhile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review, no reason for this to be a knob: /tmp is writable in any
task image. Traces still record the path under
trace.info["debug"]["remote_script_path"].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the flat --setup-timeout/--validate-timeout (validate) and
--setup-timeout/--timeout (debug) with a shared CheckTimeoutConfig:
--timeout.setup and --timeout.total, mirroring eval's nested
--timeout.* TOML shape. Renaming eval's --timeout.rollout to
--timeout.total is deferred to a follow-up since that flag has
downstream consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: a debug CLI should save everything the action printed,
not a configurable 2000-char tail. trace.info["debug"] keys are
renamed stdout_tail/stderr_tail -> stdout/stderr accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: future-proofs the aggregate mode against a third
high-level validation. The aggregation helpers now take a list of
subresult rows instead of exactly two; the nested apply_answer/noop
keys are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review, keep docs/ untouched by this PR; the runtime
DeprecationWarning already points v1 users at the debug CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fixed /tmp path is a shared host file on the subprocess runtime
(write resolves absolute paths outside the per-runtime workdir), so
concurrent runs with different scripts could execute each other's.
Key the path by the run-unique runtime name; /tmp rather than the
workdir so the script never pollutes the repo state being inspected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A CancelledError delivered while awaiting runtime.stop() in the
finally sailed past 'except Exception' and aborted debug_task before
the caller could append_trace — losing exactly the cancelled trace
the cancelled-flag path exists to persist. Absorb it there and let
the caller re-raise after appending. Composes with #1920, which
makes stop() complete teardown and then re-raise the cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The upload is a remote call on prime/modal and ran outside
asyncio.wait_for, so a stalled write hung forever with
--timeout.total never applying. Upload + execution now share one
total budget via run_timed (formerly run_command), rather than each
getting their own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: shield runtime teardown from cancellation framework-wide

A Ctrl-C / SIGTERM (converted to KeyboardInterrupt by the CLIs) cancels the
owning task, and the cancellation lands inside the finally that awaits
runtime.stop(), truncating teardown mid-await and leaking live docker
containers or paid Prime sandboxes. The unshielded pattern existed at five
sites: rollout.py, both validate modes, debug_task, and the AsyncExitStack
callback in mcp/launch.py.

Fix it once at the framework level: Runtime.stop() is now a concrete
framework method that runs the overridable teardown() (modal/prime move
their bodies there) to completion under cancellation via run_shielded, then
re-raises the cancellation. A bare asyncio.shield is not enough — it
re-raises immediately and the orphaned inner task is cancelled by
asyncio.run's shutdown anyway — so the helper keeps awaiting until teardown
finishes. All call sites stay unchanged and every future caller is safe by
construction.

run_shielded also replaces the two inline copies of the pattern
(host_endpoint's finally — fixing a corner where a teardown error could
swallow a captured cancellation — and append_trace, whose recovery await
was itself unshielded against a second cancellation).

The sync atexit backstop is unchanged: a second Ctrl-C raises
KeyboardInterrupt out of the event loop itself, beyond any task-level
shield.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep modal's atexit backstop armed through a truncated teardown

ModalRuntime.teardown consumed its `_sandbox` guard before the first await,
so loop death (second Ctrl-C) mid-terminate left cleanup() keyed off None —
the atexit backstop could not terminate the sandbox and it leaked to its
max-lifetime. Pre-existing (identical body under the old stop()), with a
wider window before the shield; consume the guard only after the terminate
attempt, so a truncating CancelledError propagates with the backstop still
armed. Document the contract on Runtime.teardown: overrides must not consume
state cleanup() keys off before their first await.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: remove teardown test files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
When Ctrl-C lands while runtime teardowns are in flight, the shielded stop()
(#1920) keeps them running — but silently, so the wait reads as a hang and
users escalate to kill -9, which skips the atexit backstop entirely. Track
in-flight teardowns in a registry and report the drain once, in aggregate
(one line for 512 teardowns, not 512): count, oldest age, and that a second
Ctrl-C aborts with best-effort cleanup at exit; one closing line when the
drain finishes. Per-runtime detail at DEBUG.

Also narrow run_shielded's inner catch from BaseException to Exception: a
KeyboardInterrupt/SystemExit raised out of the teardown (a signal delivered
while the interpreter executes teardown code) must propagate immediately —
never be swallowed by the loop or chained under the pending CancelledError —
and a force-closed coroutine must not ignore GeneratorExit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@macroscopeapp

macroscopeapp Bot commented Jul 4, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

This PR introduces new feature behavior around interrupt handling during teardown, including a significant change from catching BaseException to Exception (allowing KeyboardInterrupt/SystemExit to propagate immediately). Runtime behavior changes of this nature around critical teardown/cleanup paths warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/runtimes/base.py Outdated
@rasdani rasdani requested review from mikasenghaas and willccbb July 4, 2026 02:09
rasdani and others added 2 commits July 4, 2026 03:32
…gold to narrow

Per review: replace --mode {apply-answer, noop, all} with
all-by-default plus two restriction flags. The checks are named for
what they are — gold (setup + validate hook) and setup-only — in
flags, row modes, nested subresult keys, and runtime names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancels the owner while stop() is in flight against a live Prime sandbox and
asserts, in-process: the DELETE still completed server-side (TERMINATED, not
RUNNING), the drain was reported, and the cancellation propagated afterwards.
Verified to fail when the run_shielded guard in Runtime.stop is removed.
prime-marked: runs under -m prime locally, excluded from CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc98d9e. Configure here.

Comment thread verifiers/v1/cli/validate.py
rasdani and others added 2 commits July 4, 2026 04:49
… local

The prime-marked variant alone never gates PRs (CI excludes real sandboxes),
so parametrize the guard test over the runtimes CI does run: subprocess
(always) and docker (CI matrix), with prime as the local full-realism
variant. The gate holds teardown open so the cancel lands before the real
cleanup starts — an unshielded stop() genuinely leaks the workdir/container
and fails the leak check. Verified: both CI variants fail with the shield
removed and pass with it intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main now contains #1905 (squash-merged), so the drain branch's base
content is upstream. Conflicts: pyproject.toml keeps both the debug
and new replay console scripts; runtimes/base.py and utils/aio.py
take this branch's side (main's versions are the older pre-drain
content from the #1905 squash).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rasdani rasdani changed the base branch from feat/v1-debug-cli to main July 8, 2026 03:05
rasdani and others added 2 commits July 8, 2026 03:07
The 'all in-flight teardowns finished' line fired whenever _STOPPING
emptied — including when a second Ctrl-C aborted teardowns mid-drain
(this branch makes run_shielded propagate user aborts immediately).
Track the abort and report 'teardown drain aborted' instead, so the
user who just aborted isn't told everything finished.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setup_logging(console=False) left loguru with no sink at all when no
log_file was given (validate), silently discarding the teardown-drain
warnings this branch adds — Ctrl-C still looked like a hang in the
default validate UX. Add a WARNING-level sink through the dashboard's
own rich Console: an active Live renders prints above the live region
cleanly, and eval's rich mode gets on-screen warnings too instead of
file-only. Verified under a pty: the warning renders above the frame,
no tearing, INFO stays hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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