Skip to content

fix(runner): bound container wait so one wedged run can't stall a batch - #316

Open
vaibhavdabas16 wants to merge 3 commits into
TIGER-AI-Lab:mainfrom
vaibhavdabas16:fix/host-side-container-timeout
Open

fix(runner): bound container wait so one wedged run can't stall a batch#316
vaibhavdabas16 wants to merge 3 commits into
TIGER-AI-Lab:mainfrom
vaibhavdabas16:fix/host-side-container-timeout

Conversation

@vaibhavdabas16

Copy link
Copy Markdown
Contributor

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> wait with while proc.poll() is None: and no bound
  • batch.py:313 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; 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_wait

It now takes timeout_s and returns whether the deadline 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, and this preserves the same invariant #303 and #302 are about: a run that got far enough to produce anything always records it. --human runs stay unbounded by design.

Ask 2 — per-job bound in batch.py

proc.communicate() is wrapped 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 — clawbench-run reports its own timeout first, and this only fires when the child process 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 it.

job_timeout_s() reads time_limit from both corpus layouts — <case>/task.json and claw-eval's flat <suite>/<id>.json — and falls back to 1800s when the task file is unreadable.

On the new utils/timeouts.py

Three constants shared by run.py, docker.py and batch.py. batch.py cannot import them from run_support.docker, because that pulls in run_support.config, which resolves a container engine at import time and sys.exits when neither Docker nor Podman is installed — and batch.py must stay importable without one (tests/test_batch_and_tui_helpers.py depends 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 into docker.py. Happy to take a different shape here if you'd prefer.

Corpus

  • v2
  • v1
  • both
  • not applicable

Host-side runner/batch change; no task data involved.

Test plan

  • New tests/test_host_timeout.py, 12 tests: job_timeout_s against both corpus layouts, four unreadable-task fallbacks, override and disable; docker_wait killing a never-exiting container and returning promptly; timeout_s=None preserving the old unbounded behaviour; BATCH_JOB_GRACE_S > HOST_TIMEOUT_GRACE_S so the layering cannot silently invert; and batch.py still importing with no container engine present.
  • Demonstrated the hang directly rather than only through tests — driving the unpatched docker_wait against 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']
  • Full suite: 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 clean main on this machine — those task files are git symlinks (mode 120000) that Windows checks out as text. Unrelated.
  • ruff check and ruff format --check clean.

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.py workaround filed as #315.

One deliberate choice worth a maintainer's eye: a host timeout is classified as the existing infra_failure category with host_timeout: ... in failure_reason, matching the convention at run.py:415. The issue's wording ("infra_failure / host_timeout") could instead mean a distinct host_timeout category — that would need adding it to NON_MODEL_FAILURE_CATEGORIES and widening the infra_failure predicate in classify_run, which changes semantics shared with every other failure path. I took the conservative route; say the word and I'll switch it.

Comment thread src/clawbench/runner/batch.py Outdated
f"after {int(bound or 0)}s — killing"
)
try:
os.killpg(proc.pid, signal.SIGKILL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-run reached 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.

@Perry2004 Perry2004 added the bug Something isn't working label Aug 30, 2026
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.
@vaibhavdabas16
vaibhavdabas16 force-pushed the fix/host-side-container-timeout branch from 2ede580 to c8ebd8a Compare August 31, 2026 17:20
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No host-side timeout on container wait — one wedged container stalls an entire batch indefinitely

2 participants