Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion composer/cli/console_autoprove.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Entry point for the auto-prove pipeline — console (no TUI) mode."""

import asyncio
import logging

import composer.bind as _

from composer.diagnostics.timing import RunSummary
from composer.ui.autoprove_console import AutoProveConsoleHandler
from composer.spec.source.autoprove_common import _entry_point

_logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Main
Expand Down Expand Up @@ -37,6 +40,30 @@ async def _main() -> int:
return 0


def _name_pending(loop: asyncio.AbstractEventLoop) -> None:
"""Say what the loop still holds before it is asked to close.

Closing cancels whatever is left and then waits for all of it, with no bound. Anything named
here is something that wait can be spent on.
"""
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
if pending:
_logger.info(
"%d task(s) still running at shutdown: %s",
len(pending),
", ".join(sorted(t.get_name() for t in pending)),
)


def main() -> int:
return asyncio.run(_main())
runner = asyncio.Runner()
try:
try:
return runner.run(_main())
finally:
_logger.info("pipeline returned; shutting the event loop down")
_name_pending(runner.get_loop())
finally:
runner.close()
_logger.info("event loop closed")

4 changes: 4 additions & 0 deletions composer/pipeline/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Protocol, AsyncIterator, TYPE_CHECKING
import json
import logging
import sys
import pathlib
import enum
Expand Down Expand Up @@ -53,6 +54,8 @@
from composer.spec.util import fs_forbidden_read
import hashlib

_logger = logging.getLogger(__name__)


def autoprover_version() -> str:
"""The git commit recorded for a ``git+``-installed ``ai-composer`` (its ``direct_url.json``),
Expand Down Expand Up @@ -477,3 +480,4 @@ async def cont[FormT: BackendResult, A: ArtifactIdentifier, U: FeatureUnit, Main
await at_exit(init_source, data_logger)
except Exception:
pass
_logger.info("run exit handlers done")
96 changes: 57 additions & 39 deletions composer/pipeline/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
Protocol, Any, ClassVar, Concatenate, cast, Awaitable, Sequence, Callable, ContextManager, overload
)
from abc import ABC, abstractmethod
from contextlib import nullcontext
from contextlib import nullcontext, asynccontextmanager
from collections.abc import AsyncIterator, Coroutine


from composer.io.multi_job import TaskInfo
Expand Down Expand Up @@ -464,6 +465,24 @@ async def _run_pipeline_inner[P: enum.Enum, FormT: BackendResult, H, A: Artifact
extra_context=extra_context, max_bug_rounds=max_bug_rounds, ecosystem=ecosystem,
)


@asynccontextmanager
async def _alongside[T](coro: Coroutine[Any, Any, T]) -> AsyncIterator[asyncio.Task[T]]:
"""Run ``coro`` beside the body, and end it with the body rather than letting it outlive it.

A task still running when its scope is left is reachable only by ``asyncio.run``'s teardown,
which cancels it after the run's exit handlers have finished and then waits for it with no
bound of its own. Whatever the task is holding at that point is held there.
"""
task = asyncio.create_task(coro)
try:
yield task
except BaseException:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
raise

Comment on lines +469 to +484

Copy link
Copy Markdown
Contributor

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?


# ---- the driver --------------------------------------------------------------
async def run_pipeline_inner[P: enum.Enum, FormT: BackendResult, H, A: ArtifactIdentifier, U: FeatureUnit, Main, App: BaseApplication, Pre](
backend: PipelineBackend[P, FormT, H, A, U, Main, App, Pre],
Expand Down Expand Up @@ -535,45 +554,44 @@ async def _run_analysis():
async def _prepare_formalization() -> Formalizer[FormT, U] | StagedFormalizer[FormT, U]:
with named_budget_or_nop("formalization_preparation"):
return await prepared.prepare_formalization(run)
staged_task = asyncio.create_task(_prepare_formalization())

batches: list[_Batch[U]] = await _extract_all(
backend.analysis_spec.properties_key,
prepared.main,
backend.backend_guidance,
run,
phases["extraction"],
interactive,
threat_model,
extra_context,
max_bug_rounds,
ecosystem,
plugin_manager.bind_phase(
phases.get("extraction_plugin") or phases["extraction"],
)
)
# 3b. Prioritized runs formalize one property, not every component's. This is the first
# point where every component's candidates exist together — inference never sees more
# than the unit it was given, and the per-component plugin hook cannot compare across
# units either.
#
# It sits *before* the ``staged_task`` await rather than after because the ranking
# reads nothing that pre-formalization produces: it needs the extracted batches and
# the guidance documents, both of which are already in hand. Awaiting the setup first
# would queue the run's cheapest decision behind its most expensive step — on a real
# contract that is hours during which the focus is knowable but unknown. It also puts
# the focus in hand earlier than the setup finishes, which is what a pre-formalization
# step would need in order to aim at it.
# The empty-batch check deliberately stays *after* the await below rather than moving
# up here to guard this: a setup that failed is the more useful error than "nothing was
# extracted", and raising early would mask it.
deprioritized: list[DeprioritizedProperty] = []
if batches and run.run_mode is RunMode.PRIORITIZED:
with named_budget_or_nop("property_extraction"):
batches, deprioritized = await _prioritize(
backend.analysis_spec.properties_key, backend.artifact_store,
batches, run, phases["extraction"], threat_model, extra_context,
async with _alongside(_prepare_formalization()) as staged_task:
batches: list[_Batch[U]] = await _extract_all(
backend.analysis_spec.properties_key,
prepared.main,
backend.backend_guidance,
run,
phases["extraction"],
interactive,
threat_model,
extra_context,
max_bug_rounds,
ecosystem,
plugin_manager.bind_phase(
phases.get("extraction_plugin") or phases["extraction"],
)
)
# 3b. Prioritized runs formalize one property, not every component's. This is the first
# point where every component's candidates exist together — inference never sees more
# than the unit it was given, and the per-component plugin hook cannot compare across
# units either.
#
# It sits *before* the ``staged_task`` await rather than after because the ranking
# reads nothing that pre-formalization produces: it needs the extracted batches and
# the guidance documents, both of which are already in hand. Awaiting the setup first
# would queue the run's cheapest decision behind its most expensive step — on a real
# contract that is hours during which the focus is knowable but unknown. It also puts
# the focus in hand earlier than the setup finishes, which is what a pre-formalization
# step would need in order to aim at it.
# The empty-batch check deliberately stays *after* the await below rather than moving
# up here to guard this: a setup that failed is the more useful error than "nothing was
# extracted", and raising early would mask it.
deprioritized: list[DeprioritizedProperty] = []
if batches and run.run_mode is RunMode.PRIORITIZED:
with named_budget_or_nop("property_extraction"):
batches, deprioritized = await _prioritize(
backend.analysis_spec.properties_key, backend.artifact_store,
batches, run, phases["extraction"], threat_model, extra_context,
)

staged = await staged_task
if not batches:
Expand Down
48 changes: 47 additions & 1 deletion composer/spec/source/autosetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm confused, do we know why this hung? how did proc.kill(); proc.wait() not work??

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.

I am also confused how it happened, but either the kill in the exception didn't kill the process on a timely manner, or gather returned without the process actually ending. The question is whether we think that this fix is hiding a more serious issue.

Claude's commentary:
▎ We don't. The run sat in that wait until the job's hard time cap killed
▎ the process, so there was nothing left to inspect.

▎ On the kill: it only runs in the except BaseException branch. If the gather returns
▎ normally with the child still alive, nothing kills it, and the old await proc.wait()
▎ in the finally just blocks. And where we do kill, the wait after it was that same
▎ unbounded wait, so a child that doesn't die on SIGKILL parks the run the same way. The
▎ bound is what ends it either way.

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.

Claude answers: We do now, from the container logs. One of the two runs ends with the CancelledError coming out of _drain, then the debug line in the finally. So asyncio.run's teardown cancelled the drain, proc.kill() did run, and the run was last seen going into the unbounded proc.wait().

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(
Expand Down
74 changes: 74 additions & 0 deletions tests/test_autosetup_reap.py
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)
Loading