fix(runner): bound container wait so one wedged run can't stall a batch - #316
fix(runner): bound container wait so one wedged run can't stall a batch#316vaibhavdabas16 wants to merge 3 commits into
Conversation
| f"after {int(bound or 0)}s — killing" | ||
| ) | ||
| try: | ||
| os.killpg(proc.pid, signal.SIGKILL) |
There was a problem hiding this comment.
I think this kills the per-run Python process and left the container process.
Also it is using SIGKILL so it would bypass the per-run cleanups in the finally block.
There was a problem hiding this comment.
You're right on both counts, and the second one made the first worse. Fixed in c8ebd8a.
The container isn't in that process group. run.py shells out to the engine (docker_run(container, ...), run.py:508) and the daemon owns the container from there, so killpg reached the Python child and the CLI client and nothing else. And because SIGKILL can't be caught, run.py never reached its finally: docker_rm(container) (run.py:743-745, which is an rm -f). So every timed-out job leaked exactly the container whose CPU and memory the bound exists to reclaim. The marker I wrote into the job log — "container and child process killed" — was describing something the code didn't do.
The fix
stop_wedged_job() sends SIGTERM first. run.py already installs a SIGTERM handler that raises KeyboardInterrupt (run.py:423-429) and unwinds through that same finally, so the container actually goes away. Worth noting that handler is installed for every run, not just --human — I'd initially misremembered the --human Ctrl+C path at run.py:478 as the only graceful hook.
SIGKILL follows only if the child is still alive after JOB_KILL_GRACE_S (60s). Graceful teardown shouldn't become a second way to hang the batch, and a run wedged badly enough to ignore SIGTERM must not keep its concurrency slot. The grace covers docker rm -f, the disposable-mailbox delete and the browser-runtime teardown — short subprocess and network calls, not agent work.
Two things I changed beyond the literal comment
- The job log now reports which of the two actually happened. On escalation it says the container may still be running rather than claiming a clean teardown, since that's precisely the case where
run.py's cleanup was skipped. - An already-gone process group is reported as not escalated: it means
clawbench-runreached the end of its own teardown, and labelling it SIGKILLed would send someone hunting for a container that isn't there.
Tests
Four new tests in tests/test_host_timeout.py drive a stub run that honours only the signals it's told to, so the SIGTERM-then-SIGKILL sequence is observable. As a control I reverted stop_wedged_job to the old SIGKILL-first behaviour and re-ran — 3 of the 4 fail, so they're pinning the fix rather than passing vacuously.
One limitation to flag: those 4 skip on Windows, where signal.SIGKILL and os.killpg don't exist — which is also why this path doesn't run there. I verified them locally by re-running the file with signal.SIGKILL shimmed in (16 passed), and that's how the control above was run too, but genuine POSIX coverage comes from the ubuntu and macos jobs here.
Also rebased onto current main (the branch was 32 commits behind). Full suite locally: 222 passed, 8 skipped. ruff format clean; ruff check adds no new finding classes over the previous head.
Still not verified: no live containerized run — I don't have Docker on this machine, so the orphaned-container behaviour is reasoned from the code and exercised by injection, not observed against a genuinely stuck Chromium. If you have a box where you can wedge one, that's the check I can't do.
The only time limit lived inside the container (entrypoint.sh's
MAX_WAIT=${TIME_LIMIT_S:-1800}). docker_wait() blocked on `<engine> wait`
with `while proc.poll() is None:` and no deadline, and batch.py awaited
proc.communicate() with no per-job bound. If the in-container watchdog
never fired — entrypoint crash, wedged Chromium, engine hiccup, zombie
container — the host waited forever: clawbench-run blocked, and in batch
mode the job held a concurrency slot indefinitely. With batches routinely
running 8-20 hours, a single wedged container could stall 130 tasks
overnight with no error and no summary.
Two host-side deadlines, layered:
- docker_wait() takes timeout_s and returns whether it expired. On expiry
it kills the container and returns True. run.py passes
time_limit_s + HOST_TIMEOUT_GRACE_S (5 min), records
"host_timeout: ..." as the failure reason, and classifies the run as
infra_failure so it stays out of adjusted scoring — then carries on to
copy results and write run-meta.json as usual. Partial data from a
killed container is still worth keeping. --human runs stay unbounded.
- batch.py wraps proc.communicate() in asyncio.wait_for, sized from the
case's own time_limit plus BATCH_JOB_GRACE_S (15 min) so it sits above
the run's own deadline and only fires when the child itself is wedged.
On expiry it kills the process group, marks the job "error", notes
host_timeout in the job log, and the batch proceeds. --job-timeout
overrides the derived value; 0 disables the bound.
job_timeout_s() reads time_limit from either corpus layout — <case>/task.json
and claw-eval's flat <suite>/<id>.json — and falls back to 1800s when the
task file is unreadable.
The three constants live in a new clawbench.utils.timeouts so run.py,
docker.py and batch.py share one definition. batch.py cannot import them
from run_support.docker: that module pulls in run_support.config, which
resolves a container engine at import time and exits when neither Docker
nor Podman is present, and batch.py must stay importable without one. A
test pins that property, and another pins BATCH_JOB_GRACE_S >
HOST_TIMEOUT_GRACE_S so the layering cannot silently invert.
Fixes TIGER-AI-Lab#298.
Review catch on TIGER-AI-Lab#316: the host-timeout path killed the process group with SIGKILL, which reaped clawbench-run but left its container running. The container is not in that process group. run.py shells out to the engine (docker_run(container, ...) at run.py:508) and the daemon owns the container from there; killpg reaches the Python child and the CLI client, not the container. SIGKILL also cannot be caught, so run.py never reached its `finally: docker_rm(container)` (run.py:743-745, an `rm -f`). Every timed-out job therefore leaked the container whose CPU and memory the bound exists to reclaim — and the marker written into the job log said "container and child process killed", which was not what happened. stop_wedged_job() sends SIGTERM first. run.py already installs a SIGTERM handler that raises KeyboardInterrupt (run.py:423-429) and unwinds through that same finally, so the container actually goes away; that handler is installed for every run, not only --human. SIGKILL follows only if the child is still alive after JOB_KILL_GRACE_S (60s), because a run wedged badly enough to ignore SIGTERM must not hold its concurrency slot open either. The grace covers a few short subprocess and network calls — docker rm -f, mailbox delete, browser-runtime teardown — not agent work. The function reports whether it had to escalate, and the job log now says which happened: SIGKILL means run.py skipped its cleanup, so the log points at a container that may still be up rather than claiming a clean teardown. An already-gone process group is reported as not-escalated: it means clawbench-run reached the end of its own teardown, and saying it was SIGKILLed would send a reader hunting for a container that is not there. Four tests in tests/test_host_timeout.py drive a stub run that honours only the signals it is told to. Three of them fail against the previous SIGKILL-first behaviour. They are skipped where signal.SIGKILL and os.killpg do not exist, which is the same platform on which this path does not run.
2ede580 to
c8ebd8a
Compare
Three errors from the previous commit, all in CI's Pyright step: - `killed_hard` was assigned only on the asyncio.TimeoutError branch and read under `if host_timed_out:`. The two are set together, but nothing in the types says so, so it is initialised alongside host_timed_out. - stop_wedged_job() annotated `proc` as asyncio.subprocess.Process, which the test's stub run cannot be. It needs only a pid and a way to drain the pipes, so it now takes a Protocol saying exactly that; the stub is a legitimate implementation of it rather than a cast around the type. Verified with `pyright --pythonplatform Linux` to match the ubuntu runner: 0 errors. Without that flag a Windows host also reports os.killpg, signal.SIGKILL and tui.py's pre-existing os.sysconf as unknown attributes, which is the host talking, not the code.
What does this PR do?
Fixes #298 — both asks.
The only time limit lived inside the container (
entrypoint.sh:251,MAX_WAIT=${TIME_LIMIT_S:-1800}). On the host side there was no deadline anywhere:docker_wait()(docker.py:522) blocked on<engine> waitwithwhile proc.poll() is None:and no boundbatch.py:313awaitedproc.communicate()with no per-job boundIf the in-container watchdog never fired — entrypoint crash, wedged Chromium, engine hiccup, zombie container — the host waited forever.
clawbench-runblocked; in batch mode the job held a concurrency slot indefinitely. With batches routinely running 8–20 hours, one wedged container could stall a 130-task run overnight with no error and no summary.Ask 1 — deadline in
docker_waitIt now takes
timeout_sand returns whether the deadline expired. On expiry it kills the container and returnsTrue.run.pypassestime_limit_s + HOST_TIMEOUT_GRACE_S(5 min), recordshost_timeout: ...as the failure reason, and classifies the run asinfra_failureso it stays out of adjusted scoring — then carries on to copy results and writerun-meta.jsonas usual. Partial data from a killed container is still worth keeping, and this preserves the same invariant #303 and #302 are about: a run that got far enough to produce anything always records it.--humanruns stay unbounded by design.Ask 2 — per-job bound in
batch.pyproc.communicate()is wrapped inasyncio.wait_for, sized from the case's owntime_limitplusBATCH_JOB_GRACE_S(15 min) so it sits above the run's own deadline —clawbench-runreports its own timeout first, and this only fires when the child process itself is wedged. On expiry it kills the process group, marks the joberror, noteshost_timeoutin the job log, and the batch proceeds.--job-timeoutoverrides the derived value;0disables it.job_timeout_s()readstime_limitfrom both corpus layouts —<case>/task.jsonand claw-eval's flat<suite>/<id>.json— and falls back to 1800s when the task file is unreadable.On the new
utils/timeouts.pyThree constants shared by
run.py,docker.pyandbatch.py.batch.pycannot import them fromrun_support.docker, because that pulls inrun_support.config, which resolves a container engine at import time andsys.exits when neither Docker nor Podman is installed — andbatch.pymust stay importable without one (tests/test_batch_and_tui_helpers.pydepends on it).A shared module seemed better than duplicating two integers across modules that must agree. That import-time probe is the underlying problem, and I've filed it separately as #315 with a suggested lazy-
engine()fix; if that lands, this module could fold back intodocker.py. Happy to take a different shape here if you'd prefer.Corpus
Host-side runner/batch change; no task data involved.
Test plan
tests/test_host_timeout.py, 12 tests:job_timeout_sagainst both corpus layouts, four unreadable-task fallbacks, override and disable;docker_waitkilling a never-exiting container and returning promptly;timeout_s=Nonepreserving the old unbounded behaviour;BATCH_JOB_GRACE_S > HOST_TIMEOUT_GRACE_Sso the layering cannot silently invert; andbatch.pystill importing with no container engine present.docker_waitagainst a stub container that never exits, on a background thread:UNPATCHED docker_wait still running after 6s? True <- hangs forever PATCHED returned True after 2.0s; issued: ['kill', 'wedged']204 passed, 3 skipped. The one failure,test_host_tasks.py::test_checked_task_json_files_parse_and_validate[v1-lite], reproduces identically on a cleanmainon this machine — those task files are git symlinks (mode120000) that Windows checks out as text. Unrelated.ruff checkandruff format --checkclean.Not verified: no live containerized run — I don't have Docker on this machine. The wedged-container path is exercised by injection, not by a genuinely stuck Chromium.
Related issues
Fixes #298. Root cause of the
utils/timeouts.pyworkaround filed as #315.One deliberate choice worth a maintainer's eye: a host timeout is classified as the existing
infra_failurecategory withhost_timeout: ...infailure_reason, matching the convention atrun.py:415. The issue's wording ("infra_failure / host_timeout") could instead mean a distincthost_timeoutcategory — that would need adding it toNON_MODEL_FAILURE_CATEGORIESand widening theinfra_failurepredicate inclassify_run, which changes semantics shared with every other failure path. I took the conservative route; say the word and I'll switch it.