-
Notifications
You must be signed in to change notification settings - Fork 7
Bound the AutoSetup wait, and stop the task holding it from outliving the run #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
63af10d
f9a9948
51b1ea9
59e1ab0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| import re | ||
| import sys | ||
| import tempfile | ||
| import time | ||
| from collections.abc import Callable | ||
| from pydantic import BaseModel, Field | ||
| from pathlib import Path | ||
|
|
@@ -85,6 +86,46 @@ async def _drain( | |
| sink(buf) | ||
|
|
||
|
|
||
| #: How long to give the AutoSetup child to exit before we stop waiting on it. The wait below sits | ||
| #: in a ``finally`` that a cancellation also travels through, so it has to be bounded: an | ||
| #: unbounded wait there parks the whole run behind one subprocess, with no timeout above it that | ||
| #: can help and nothing left running to say why. | ||
| _REAP_TIMEOUT_S = 30.0 | ||
|
|
||
|
|
||
| async def _reap(proc: asyncio.subprocess.Process) -> int | None: | ||
| """Wait for ``proc`` to exit, escalating to a kill, and stop waiting rather than block forever. | ||
|
|
||
| Returns the exit status, or ``None`` if the child could not be reaped at all — a child that | ||
| will not die tells us nothing about whether AutoSetup succeeded, so the caller treats that as | ||
| a failure. | ||
| """ | ||
| started = time.monotonic() | ||
| for escalate in (False, True): | ||
| if escalate and proc.returncode is None: | ||
| _logger.warning( | ||
| "AutoSetup child still alive after %.0fs, killing it", _REAP_TIMEOUT_S | ||
| ) | ||
| proc.kill() | ||
| try: | ||
| async with asyncio.timeout(_REAP_TIMEOUT_S): | ||
| # Shielded: the timeout must cancel our wait, not the child's exit plumbing. | ||
| returncode = await asyncio.shield(proc.wait()) | ||
| except TimeoutError: | ||
| continue | ||
| # Logged where it ends, not only where it begins: a wait that says nothing on the way out | ||
| # cannot be told apart, afterwards, from one that never returned. | ||
| _logger.info( | ||
| "AutoSetup child exited %d after %.1fs", returncode, time.monotonic() - started | ||
| ) | ||
| return returncode | ||
| _logger.error( | ||
| "AutoSetup child outlived a kill after %.1fs, giving up on it and continuing", | ||
| time.monotonic() - started, | ||
| ) | ||
| return proc.returncode | ||
|
|
||
|
|
||
| async def run_autosetup( | ||
| project_root: Path, | ||
| relative_path: str, | ||
|
|
@@ -176,7 +217,12 @@ def log_complete(self, returncode: int): | |
| raise | ||
| finally: | ||
| _logger.debug("AutoSetup process complete, waiting for exit") | ||
| returncode = await proc.wait() | ||
| returncode = await _reap(proc) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm confused, do we know why this hung? how did
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am also confused how it happened, but either the kill in the exception didn't kill the process on a timely manner, or Claude's commentary:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude answers: We do now, from the container logs. One of the two runs ends with the CancelledError coming out of The kill only exists in the except branch, and the wait after it is that same unbounded wait, so nothing there was a bound on anything. Whether the wait itself is what held for 22h I can't tell from that log, because nothing logged when it returned. The other run froze at the same point in teardown with its autosetup already exited 0, so this isn't the whole incident. I've pushed the rest: the task holding the subprocess is now cancelled with the extraction it runs beside instead of being left for teardown, the reap logs how it ended, and there's a test for the path that actually happened. |
||
| if returncode is None: | ||
| return SetupFailure( | ||
| error="AutoSetup did not exit", | ||
| stderr="\n".join(stderr_lines), | ||
| ) | ||
| cb.log_complete(returncode) | ||
| if returncode != 0: | ||
| return SetupFailure( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """Tests for the bounded reap of the AutoSetup subprocess (``composer/spec/source/autosetup.py``). | ||
|
|
||
| The wait for the child sits in a ``finally``, which is also the path a cancelled run takes on its | ||
| way out. Whatever the child is doing, that wait has to end. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
||
| from composer.spec.source import autosetup | ||
|
|
||
| pytestmark = pytest.mark.asyncio | ||
|
|
||
|
|
||
| async def _spawn(code: str) -> asyncio.subprocess.Process: | ||
| return await asyncio.create_subprocess_exec( | ||
| sys.executable, "-c", code, | ||
| stdout=asyncio.subprocess.DEVNULL, | ||
| stderr=asyncio.subprocess.DEVNULL, | ||
| stdin=asyncio.subprocess.DEVNULL, | ||
| ) | ||
|
|
||
|
|
||
| async def test_a_child_that_exits_reports_its_status(): | ||
| proc = await _spawn("raise SystemExit(3)") | ||
| assert await autosetup._reap(proc) == 3 | ||
|
|
||
|
|
||
| async def test_a_child_that_will_not_exit_is_killed(monkeypatch): | ||
| monkeypatch.setattr(autosetup, "_REAP_TIMEOUT_S", 0.2) | ||
| # Ignores SIGTERM, so only the kill ends it. | ||
| proc = await _spawn( | ||
| "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(300)" | ||
| ) | ||
| returncode = await asyncio.wait_for(autosetup._reap(proc), timeout=10) | ||
| assert returncode is not None and returncode < 0 | ||
|
|
||
|
|
||
| async def test_the_reap_ends_inside_a_cancelled_task(monkeypatch): | ||
| """The shape a run takes on its way out: the wait sits in a ``finally`` of a cancelled task. | ||
|
|
||
| ``asyncio.timeout`` there has to raise ``TimeoutError`` off its own timer rather than pass the | ||
| task's pending cancellation through, or the escalation never happens and the task never | ||
| finishes unwinding. | ||
| """ | ||
| monkeypatch.setattr(autosetup, "_REAP_TIMEOUT_S", 0.2) | ||
| proc = await _spawn( | ||
| "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(300)" | ||
| ) | ||
| reaped: list[int | None] = [] | ||
|
|
||
| async def run_and_reap() -> None: | ||
| try: | ||
| await asyncio.Event().wait() | ||
| finally: | ||
| reaped.append(await autosetup._reap(proc)) | ||
|
|
||
| task = asyncio.create_task(run_and_reap()) | ||
| await asyncio.sleep(0.05) | ||
| task.cancel() | ||
|
|
||
| _, pending = await asyncio.wait({task}, timeout=10) | ||
| assert not pending, "the reap never returned inside a cancelled task" | ||
| assert task.cancelled() | ||
| assert reaped and reaped[0] is not None and reaped[0] < 0 | ||
|
|
||
|
|
||
| async def test_the_reap_says_how_it_ended(caplog): | ||
| proc = await _spawn("raise SystemExit(0)") | ||
| with caplog.at_level("INFO", logger="composer.spec.source.autosetup"): | ||
| assert await autosetup._reap(proc) == 0 | ||
| assert any("AutoSetup child exited 0" in r.getMessage() for r in caplog.records) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused; this is just gather? or a task group? What is this doing that either of those two abstractions don't already do for us?