Skip to content

fix: use anyio instead of asyncio primitives so trio runs work - #851

Merged
Rogan-Inglis merged 5 commits into
mainfrom
rogan/fix/anyio-gather
Aug 12, 2026
Merged

Rogan-Inglis merged 5 commits into
mainfrom
rogan/fix/anyio-gather

Conversation

@Rogan-Inglis

@Rogan-Inglis Rogan-Inglis commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #850, which noticed from asyncio import gather in iac_fast/scorers.py. It turned out to be seven scorers, not one.

CLAUDE.md requires anyio rather than asyncio primitives because the backend is chosen at runtime by INSPECT_ASYNC_BACKEND. asyncio primitives raise under trio, and since the default is asyncio the breakage only surfaces in trio runs — which is how this went unnoticed.

The bug is real

[asyncio] asyncio.gather        -> [1, 1]
          _async_utils.gather   -> [1, 1]
[trio]    asyncio.gather        -> RuntimeError: There is no current event loop
          _async_utils.gather   -> [1, 1]

End to end, against a scorer that runs with a mock model and no sandbox:

$ INSPECT_ASYNC_BACKEND=trio pytest control_arena/settings/data_poisoning/tests/test_scorers.py

# on main
control_arena/settings/data_poisoning/scorers.py:285: DeprecationWarning: There is no current event loop
1 failed

# on this branch
1 passed

What changed

  • Seven scorers using from asyncio import gather — apps, bash, bigcodebench, data_poisoning, iac, iac_fast, sae_interp_sabotage — switched to control_arena._async_utils.gather. Scorers run inside inspect's event loop, so these were live breakage.
  • Two attack-policy modules in examples/paper_replications/ai_control, which run inside the loop for the same reason.
  • vllm's asyncio.as_completed replaced by a new _async_utils.as_completed_with_exceptions, an anyio task group over a memory object stream. This one is reached from cli.py, which already runs under the configured backend, so it was breaking for real. The replacement preserves completion-order streaming (results are written to CSV as they land) and keeps a failing coroutine from cancelling its siblings — an anyio task group cancels the whole group by default, so that is the property most easily lost in this conversion, and it is tested.
  • The remaining asyncio.run entry points in examples/ and scripts/. These owned their own event loop so they were self-consistent rather than broken, but CLAUDE.md asks entry points to honour the configured backend, and leaving them would have meant exempting them from the new lint rule.

New: _async_utils.run_sync

anyio.run cannot replace asyncio.run directly — it takes a function plus positional arguments, so it cannot accept a coroutine built with keyword arguments, which is what existing asyncio.run(f(a, b=c)) call sites have. run_sync(coro) takes the coroutine itself and runs it on the configured backend, so those call sites convert without functools.partial scattered around.

Preventing recurrence

ruff now bans the asyncio primitives via TID251, with a per-symbol message naming the replacement:

TID251 `asyncio.gather` is banned: Use control_arena._async_utils.gather (or gather_with_exceptions).

Covered: gather, as_completed, create_task, run, sleep, wait_for, Semaphore. It catches both the from asyncio import gather form and asyncio.gather(...) — in fact it is what surfaced the examples/ and scripts/ call sites I had not planned to touch. kubernetes_asyncio and tqdm.asyncio are unaffected; the latter is only used to construct progress bars, never for its asyncio-specific classmethods.

Tests

  • run_sync under both backends, including that it honours INSPECT_ASYNC_BACKEND and accepts keyword-built coroutines.
  • gather: a single failure raised as itself with its __cause__ chain intact, several raised as a group.
  • as_completed_with_exceptions: completion order, empty input, generator input, None results, a failure reported without cancelling its siblings, early exit cancelling the rest, an exception from the loop body surfacing unwrapped, and both backends.
  • process_commits_streaming itself: rows written in completion order, one failing commit not abandoning the others, skips counted.
  • anyio_backend_of, the conftest classifier that decides which tests are trio variants.

Verified: ruff format, ruff check, pyright (0 errors), and 986 passing under asyncio.

🤖 Generated with Claude Code

Rogan-Inglis and others added 2 commits July 30, 2026 15:41
CLAUDE.md requires anyio rather than asyncio primitives, because the backend is
chosen at runtime by INSPECT_ASYNC_BACKEND. asyncio primitives raise under trio,
and since the default is asyncio the breakage only surfaces in trio runs -- which
is how `from asyncio import gather` reached seven scorers unnoticed.

Demonstrating the failure, and that it is not theoretical:

    [asyncio] asyncio.gather -> [1, 1]
    [trio]    asyncio.gather -> RuntimeError: There is no current event loop

End to end, `INSPECT_ASYNC_BACKEND=trio pytest
control_arena/settings/data_poisoning/tests/test_scorers.py` fails on main,
pointing at scorers.py:285, and passes here.

Converted:

- Seven scorers using `from asyncio import gather` (apps, bash, bigcodebench,
  data_poisoning, iac, iac_fast, sae_interp_sabotage) to
  control_arena._async_utils.gather. Scorers run inside inspect's event loop, so
  these were live breakage.
- Two attack-policy modules in examples/paper_replications, which run inside the
  loop for the same reason.
- vllm's task-generation `asyncio.as_completed` to an anyio task group over a
  memory object stream. It is reached from cli.py, which already runs under
  anyio.run(backend=get_async_backend()), so it was breaking for real. The
  replacement keeps completion-order streaming and keeps a failing coroutine from
  cancelling its siblings -- a task group cancels the whole group by default, so
  that is the property most easily lost in the move.
- The remaining `asyncio.run` entry points in examples/ and scripts/. These owned
  their own loop so they were self-consistent, but CLAUDE.md asks entry points to
  honour the configured backend.

Added `_async_utils.run_sync`, which anyio.run cannot replace directly: anyio.run
takes a function plus positional arguments, so it cannot accept a coroutine built
with keyword arguments, which is what existing `asyncio.run(f(a, b=c))` call
sites have.

To stop this recurring, ruff now bans the asyncio primitives via TID251 with
per-symbol messages pointing at the replacement. That is what surfaced the
examples/ and scripts/ call sites, and it catches both the `from asyncio import
gather` and `asyncio.gather(...)` forms.

Tests cover run_sync under both backends and the new completion iterator,
including that a failure does not cancel its siblings.

Note for reviewers: the trio suite is broadly red on main for unrelated reasons
(102 failures in compaction, tool output and sandbox utils). This change is
neutral against that baseline -- the same 102 fail before and after, with no
regressions -- so it removes one class of breakage rather than turning trio
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checked against inspect, which solved this first. Two things came out of it.

`_async_utils.gather` was not actually a drop-in for `asyncio.gather`: an anyio
task group wraps even a single failure in an ExceptionGroup, and gather passed
that straight through, so it raised ExceptionGroup where asyncio.gather raised
the original exception.

    asyncio.gather         -> ValueError
    control_arena gather   -> ExceptionGroup      <- before
    inspect util.collect   -> ValueError
    inspect tg_collect     -> ValueError

Both of inspect's equivalents unwrap, and ours now does too: a single failure
raises as itself, several raise as a group, matching inspect_ai.util.collect.
Catching ExceptionGroup rather than BaseExceptionGroup keeps cancellation
propagating as a group.

This mattered for the conversions in the previous commit -- the seven scorers had
been raising the original exception via asyncio.gather and would have started
raising ExceptionGroup instead, changing the error recorded against a failed
sample. No call site catches around a gather (checked all 23), so nothing was
actively broken, but the trap was real and the existing test encoded the wrong
expectation.

The TID251 banned-api approach in the previous commit turns out to be exactly
what inspect uses for its own bans, so no change needed there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rogan-Inglis

Copy link
Copy Markdown
Collaborator Author

Checked this against inspect (inspect_ai @ 448d5878b) as suggested. Their guidance is in AGENTS.md:

Async Concurrency: Use inspect_ai._util._async.tg_collect() instead of asyncio.gather() for running concurrent async tasks. Use inspect_ai.util.collect() only inside sample subtasks (it adds transcript span grouping).

Four things came out of it, one of which was a real defect in this PR.

1. _async_utils.gather was not a drop-in for asyncio.gather — fixed

An anyio task group wraps even a single failure in an ExceptionGroup, and our gather passed that straight through:

asyncio.gather        : ValueError        (is ValueError? True)
control_arena gather  : ExceptionGroup    (is ValueError? False)   <- before
inspect util.collect  : ValueError        (is ValueError? True)
inspect tg_collect    : ValueError        (is ValueError? True)

Both of inspect's equivalents unwrap; ours did not. That matters for this PR specifically: the seven scorers were raising the original exception via asyncio.gather and would have started raising ExceptionGroup, changing the error recorded against a failed sample.

I checked all 23 gather call sites in the repo — none catches around the call, so nothing was actively broken, but the trap was real and test_gather_propagates_exception had encoded the wrong expectation as correct.

gather now unwraps a single failure and raises several as a group, matching inspect_ai.util.collect. Catching ExceptionGroup rather than BaseExceptionGroup keeps cancellation propagating as a group. Tests updated to assert parity with asyncio.gather rather than the old behaviour.

This also fixes the ~14 pre-existing gather users that predate this PR.

2. The lint approach matches inspect's exactly

Inspect selects "TID251" and configures [tool.ruff.lint.flake8-tidy-imports.banned-api] with per-symbol .msg — the same mechanism I used here, just for typing.TypedDict rather than asyncio. No change needed.

3. No as_completed equivalent exists upstream

Inspect has nothing for streaming results in completion order, so the hand-rolled _as_completed for vllm stands.

4. Deliberately not changed, worth knowing about

  • inspect_ai.util.collect is public and a drop-in for our gather, so we could delete _async_utils.gather entirely. I did not, because collect wraps each task in a span(), which changes transcripts across ~23 call sites including monitors and scorers — an eval-comparability change that does not belong in an anyio fix. Inspect's own guidance also reserves collect for sample subtasks and points elsewhere for general use. Worth a follow-up if we want to drop the duplication; tg_collect is the closer match but is private.
  • inspect_ai._util._async.run_coroutine is far more capable than the run_sync added here — it handles notebooks via nest_asyncio, detects an already-running loop, and works around an anyio bug where every anyio.run call leaks its return value (see _anyio_run_released, which has an excellent comment explaining the _run_vars reference cycle). Our run_sync is a plain anyio.run. That is no worse than the asyncio.run it replaced — both raise inside a running loop — and the leak is immaterial for CLIs that exit, but if we ever want notebook-safe entry points, that is the prior art.

Full suite green (987 passed), pyright clean, and the end-to-end trio proof still holds.

control-arena is an inspect extension, so it should not be maintaining a
second-rate copy of inspect's async entry point. `run_sync` now calls
`inspect_ai._util._async.run_coroutine` and `get_async_backend` calls
`configured_async_backend`. Both live in a private inspect module, so
`_async_utils` is the only place that imports it; if it moves, one file changes.

This is not just deduplication -- the version being replaced was a plain
`anyio.run`, which refuses to start inside a running loop. Inspect's re-enters
via nest_asyncio, so `run_sync` now works from a notebook:

    from inside a running loop -> done      # previously RuntimeError

It also releases anyio's per-loop state afterwards, avoiding a leak of the
return value on every call, and refuses clearly when called from inside a trio
task rather than failing obscurely.

Separately, while checking the delegation had not broken anything: both
parametrised backend tests were being skipped in CI. conftest skips anyio trio
variants by matching the nodeid substring "[trio", which also caught the "[trio]"
parameter id on two plain sync tests that drive anyio themselves. CI runs pytest
without --runtrio, so the assertions that run_sync and the vllm completion
iterator honour the trio backend never actually executed there. Renamed the ids
to "on-asyncio"/"on-trio" so both cases run in the default invocation.

Also filled in a section header in the test file that had lost its title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rogan-Inglis

Copy link
Copy Markdown
Collaborator Author

Delegated to inspect. run_sync now calls inspect_ai._util._async.run_coroutine and get_async_backend calls configured_async_backend. _async_utils is the only module that imports the private inspect_ai._util._async, so if it moves, one file changes.

This turned out to be more than deduplication — the version it replaced was a plain anyio.run, which refuses to start inside a running loop:

from inside a running asyncio loop -> done      # previously RuntimeError
from plain sync code               -> done

So run_sync now works in a notebook, releases anyio's per-loop state afterwards (avoiding a leaked return value per call), and refuses clearly when called from inside a trio task instead of failing obscurely.

A gap this uncovered: two of my new tests were dead in CI

While checking the delegation hadn't broken anything I noticed test_run_sync_honours_the_configured_backend[trio] had gone from PASSED to SKIPPED — and then that it had always been skipped in the default invocation.

conftest.py skips anyio trio variants by matching the nodeid substring "[trio". That also catches a "[trio]" parametrise id on a plain sync test. Both of the backend-parametrised tests I added in this PR were affected, and since CI runs:

uv run pytest -n 8 -m "not skip_ci and not long_running"

with no --runtrio, the assertions that run_sync and the vllm completion iterator honour the trio backend never executed in CI at all. They only ran in the --runtrio pass I was doing locally, which is exactly the sort of thing that looks like coverage and isn't.

Renamed the ids to on-asyncio / on-trio so both cases run in the default invocation. CI count goes 987 → 989.

Worth flagging as a general trap: any @pytest.mark.parametrize value containing trio in this repo silently disappears from CI.

Also filled in a section header in test_async_utils.py that had lost its title on main.

Full suite green (989 passed), pyright clean, trio invocation unchanged against its 102-failure baseline.

The root conftest decided whether a test was a trio variant with
`"[trio" in item.nodeid`. That also matches an ordinary
`@pytest.mark.parametrize` value spelled "trio". Because CI runs pytest without
--runtrio, anything classified as a trio variant is skipped in the only
invocation CI performs, so such a test never runs anywhere while still looking
like coverage. Both backend-parametrised tests added earlier in this branch were
in exactly that state.

anyio's pytest plugin parametrises async tests by injecting an `anyio_backend`
param, so that param is what actually determines the backend. `anyio_backend_of`
reads it, handling anyio's ("trio", {options}) form, and returns None for tests
that are not anyio variants at all.

Verified behaviour-preserving before switching: across the whole suite, zero
items are classified differently by the two approaches. The previous commit's
workaround -- renaming the parametrise ids to "on-trio" so they dodged the
substring -- is reverted, since natural ids are safe now and keeping them proves
the fix.

Added tests for the classifier itself, including the case that caused this, since
the failure mode is silent loss of CI coverage rather than a visible error.

Note that inspect has the same substring check in its own conftest, so there was
no upstream version to adopt here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rogan-Inglis

Copy link
Copy Markdown
Collaborator Author

Tightened it. Answering the "how" in case it's useful beyond this PR.

The discriminator

anyio's pytest plugin parametrises async tests by injecting an anyio_backend param, so that param — not the test id — is what actually determines the backend. Dumping item.callspec.params at collection makes the distinction obvious:

test_gather_preserves_order[trio]                          anyio_backend='trio'   other={}
test_gather_preserves_order[asyncio]                       anyio_backend='asyncio' other={}
test_run_sync_returns_the_coroutine_result                 anyio_backend=<none>   other={}
test_run_sync_honours_the_configured_backend[trio]         anyio_backend=<none>   other={'backend': 'trio'}

The last line is the bug: a genuine anyio variant and an ordinary parametrize value spelled trio are indistinguishable by nodeid, but trivially distinguishable by param.

def anyio_backend_of(item) -> str | None:
    callspec = getattr(item, "callspec", None)
    if callspec is None:
        return None
    backend = callspec.params.get("anyio_backend")
    if isinstance(backend, tuple):
        backend = backend[0]   # anyio accepts ("trio", {options})
    return backend if isinstance(backend, str) else None

--runtrio then selects anyio_backend_of(item) == "trio" and the default run skips it, with the same "sync tests are skipped in the trio pass" behaviour as before.

Checked before switching

I ran a probe over the whole suite comparing the two classifications:

=== items classified differently: 0 ===

So it is behaviour-preserving today — the only items it would have changed were the two I had already worked around.

Reverted the workaround

The ids=["on-asyncio", "on-trio"] rename from the previous commit is gone; natural asyncio/trio ids are safe now, and keeping them is what demonstrates the fix. Both cases run in the default invocation:

test_run_sync_honours_the_configured_backend[asyncio] PASSED
test_run_sync_honours_the_configured_backend[trio]    PASSED
test_works_under_both_backends[asyncio]               PASSED
test_works_under_both_backends[trio]                  PASSED

Added a guard

control_arena/tests/test_backend_selection.py covers the classifier directly, including {"backend": "trio"} → None, which is the case that deleted two tests from CI. The failure mode here is silent loss of coverage rather than a visible error, so it seemed worth six cheap tests.

Worth knowing

Inspect has the identical substring check in tests/conftest.py (if "[trio" not in item.nodeid), so there was no upstream version to adopt — and the same trap exists there. Might be worth an upstream issue.

CI invocation 989 → 995 (the six guard tests). Trio invocation unchanged against its 102-failure baseline. ruff and pyright clean.

return csvfile, writer


async def _as_completed(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This looks like it should be in an async utils file rather than hidden in a vllm specific file?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

also worth double checking there isn't an inspect version already we can import

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved. It is now control_arena/_async_utils.as_completed_with_exceptions, generic in the result type and named to match gather_with_exceptions (an outcome is either a result or the Exception that replaced it, rather than the (result, error) tuple the vllm-local version yielded).

Promoting it to shared code turned up two defects the local version had, neither of which the old tests could see because they only ever consumed it to completion:

1. Leaving the loop early left producers sending into a closed stream. break, or an exception in the loop body, closes the receiving end while siblings are still running, and they then raise BrokenResourceError out of the task group. The remaining coroutines are now cancelled instead.

2. A bare async generator holding a task group is closed whenever the consumer gets round to it, which may be after its enclosing cancel scopes have moved on:

RuntimeError: Attempted to exit a cancel scope that isn't the current tasks's current cancel scope

That replaces whatever the loop body was actually doing. It is an async context manager now, so it closes at the point the caller leaves:

async with as_completed_with_exceptions(coros) as outcomes:
    async for outcome in outcomes:
        ...

There was a third, related trap: an exception from the caller's own loop body passes through the task group, which wraps it in an ExceptionGroup — exactly what gather was fixed for earlier in this PR. Both now share _reraise_alone, which also keeps the __cause__ chain that raise ... from None was discarding (so a failure raised from an underlying error no longer loses it).

Tests split accordingly: the iterator's behaviour is in control_arena/tests/test_async_utils.py (completion order, failure isolation, None results, empty input, generator input, early exit, loop-body exception, both backends), and the vllm tests now cover what the call site does with the outcomes — rows written in completion order, one failing commit not abandoning the rest, skips counted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Checked against the pinned inspect_ai (0.3.250). There is no equivalent to import.

The two candidates both wait for everything and return in submission order, which is the opposite of what this call site needs:

order streams as they land failure isolation
inspect_ai.util.collect submission no — barrier no, first failure raised
inspect_ai._util._async.tg_collect submission no — barrier no, first failure raised (or a group)
as_completed_with_exceptions completion yes yes, reported per item

The vllm generation script writes each row to CSV as it lands so a long run is resumable if interrupted, so a barrier would change its behaviour rather than just its implementation.

Grepping the rest of the package for the pattern found nothing reusable either — the closest is model/_call_tools.py, which hand-rolls the same task-group-plus-memory-object-stream arrangement inline for streaming tool results. So the approach matches inspect's, even though the helper does not exist there to import.

Where inspect does have prior art I have taken it: run_sync and get_async_backend delegate to inspect_ai._util._async (earlier commit in this PR), and _async_utils remains the only module importing that private path.

Addresses review: `_as_completed` was a general-purpose replacement for
`asyncio.as_completed` living in a vllm-specific file. It is now
`_async_utils.as_completed_with_exceptions`, generic in the result type and
named to match `gather_with_exceptions`.

Checked for an upstream version first, as suggested: inspect has none. Both
`inspect_ai.util.collect` and `inspect_ai._util._async.tg_collect` wait for
every task and return results in submission order, so neither can stream
outcomes as they land.

Promoting it surfaced two defects that the vllm-local version had:

- Leaving the loop early -- `break`, or an exception in the body -- closed the
  receiving end of the stream while producers were still running, so they
  raised BrokenResourceError out of the task group. Now cancelled instead.
- As a bare async generator holding a task group, it could only be closed by
  whoever got round to it, and closing it after its enclosing cancel scopes had
  moved on raised `RuntimeError: Attempted to exit a cancel scope...` in place
  of whatever the loop body was doing. It is now an async context manager, so
  it closes deterministically at the point the caller leaves.

An exception from the caller's own loop body also passes through the task
group, which wraps it in an ExceptionGroup -- the same trap `gather` had. Both
now share `_reraise_alone`, which additionally keeps the failure's `__cause__`
chain that `raise ... from None` was discarding.

Tests for the iterator move to test_async_utils.py; the vllm tests now cover
what the call site does with the outcomes -- rows written in completion order,
one failing commit not abandoning the rest, skips counted.

Also: the ruff ban now names run_sync and the new helper as the replacements
rather than raw anyio, extends to the asyncio primitives CONTRIBUTING already
listed but lint did not (subprocesses, to_thread, Lock/Event/Queue, timeout,
get_event_loop), and CLAUDE.md/AGENTS.md/CONTRIBUTING.md list both new helpers.
@Rogan-Inglis

Copy link
Copy Markdown
Collaborator Author

Both review comments addressed in 567dba9 (details in the threads). Then a pass over the whole PR, which turned up four things worth recording.

1. The lint ban was narrower than the documented rule

CONTRIBUTING.md bans asyncio.create_subprocess_exec() and loop.run_in_executor(), but neither was in the ruff list, and nor were the primitives most likely to be reached for next: Lock, Event, Queue, to_thread, timeout, get_event_loop/new_event_loop. A ban list that is a strict subset of the written rule teaches people the rule is advisory. All added, each with the replacement named. No new violations anywhere in the repo — there is now no asyncio usage left outside docstrings.

2. The ban messages pointed at things this PR had superseded

asyncio.run said "Use anyio.run(fn, backend=get_async_backend())", which is exactly the awkward call shape run_sync was added to avoid, and asyncio.as_completed said "use an anyio task group with a memory object stream" — advice to hand-roll the helper that now exists. Both now name the helper.

3. Docs did not mention either new helper

CLAUDE.md, AGENTS.md, and CONTRIBUTING.md all list the asyncio → anyio mapping and all three still pointed at raw anyio.run. Updated, with as_completed_with_exceptions added and a note that ruff enforces the table.

4. gather was discarding __cause__

Follow-on from the ExceptionGroup unwrapping fix earlier in this PR: raise eg.exceptions[0] from None suppresses the group as context, but also clears whatever the failure was originally raised from. A scorer failing with raise ValueError(...) from KeyError(...) reached the caller with __cause__ = None. raise ... from exc.__cause__ suppresses the group and keeps the chain. Tested, and shared between gather and the new helper.

Verification

  • ruff format, ruff check, pyright (0 errors).
  • 1003 passed in the CI invocation (995 → 1003: five for the new iterator's edge cases, four for process_commits_streaming, minus the tuple-shaped tests that were replaced).
  • Trio invocation unchanged against its 102-failure baseline; every test added here passes under both backends.
  • Merged main locally (which now includes refactor(iac_fast)!: run through the sandbox API instead of the Docker SDK #852, touching iac_fast/scorers.py, a file this PR also touches): no conflicts, ruff check clean on the merged tree, 1052 passed.

Not changed, for the record

  • gather_with_exceptions catches BaseException, so a child's cancellation lands in the results list rather than propagating. The enclosing task group re-raises at its next checkpoint so nothing is actually swallowed, and asyncio.gather(return_exceptions=True) captures a child CancelledError too, so this is faithful to what it replaces. Flagging it as the one remaining place in the module where the semantics are subtle.
  • examples/apps_monitor_comparison.py lost its tqdm.asyncio bar around the eval-log writes. It was gather(*atqdm(tasks)), which iterates the list synchronously to unpack it, so the bar filled instantly and never tracked completion. The --- Saving Eval Logs --- line above it is the honest version of the same information.

@Rogan-Inglis
Rogan-Inglis merged commit 9a0cc45 into main Aug 12, 2026
4 checks passed
@Rogan-Inglis
Rogan-Inglis deleted the rogan/fix/anyio-gather branch August 12, 2026 09:20
Rogan-Inglis added a commit that referenced this pull request Aug 12, 2026
The scanner predated `run_sync` and `as_completed_with_exceptions`, so it used
`anyio.run` with a partial and hand-rolled batching to get incremental writes.

- `run_sync` at the CLI boundary, which also handles being called from a notebook.
- `as_completed_with_exceptions` in place of batched `gather`: verdicts are now
  written the moment each lands rather than at each batch boundary, so a crash
  costs one classification rather than up to a batch of them, and one failure no
  longer takes its siblings down. How many run at once is bounded by the model's
  max_connections, as it always really was.

Also pins the revision in the `stats` property, which still loaded the dataset's
main branch and so could report a task count the setting would never load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rogan-Inglis added a commit that referenced this pull request Aug 12, 2026
`pytest --runtrio` had **102 failures**. After this it has **0** (214
passed). The default suite is unaffected: 1132 passed.

## Where the 102 went

| Cause | Count | Ours? | Action |
|---|---:|---|---|
| `kubernetes-asyncio` is aiohttp-based | 67 | No -- library choice |
Skipped in the infra setting, with the reason recorded |
| Reading `EvalLog.samples` under trio | 34 | No -- upstream, **now
released** | Fixed by an inspect floor bump; the tests run |
| `aiofiles` is asyncio-only | 1 (+3 untested paths) | **Yes** | Fixed |

## The upstream fix is released, so nothing is gated any more

An earlier version of this PR carried a version-gated skip for the 34:
`eval_async()` returns logs whose `samples` is a `LazyList`, and
touching it calls inspect's *sync* `read_eval_log()`, which inspect
refuses to run under trio. inspect_ai #4676 fixes it, and at the time it
was on inspect's main but in no release.

It is released now -- 0.3.252, 4 August. Verified directly:

```
0.3.250 (previous floor)  RuntimeError: read_eval_log cannot be called from a trio async context
0.3.252                   works
0.3.257 (current)         works
```

So `control_arena/conftest.py`, the `inspect_log_trio_bug` marker and
its eight `pytestmark` lines are gone, replaced by
`inspect-ai>=0.3.252`. Those 34 tests now **run** under trio rather than
being skipped, and there is no gate left for anyone to remember to
delete. The full suite passes on 0.3.252 and on 0.3.257; the lock takes
the latter.

One of the eight was ours rather than upstream's. `test_tool_output_e2e`
calls the sync `read_eval_log_sample` from async code, which inspect
refuses under trio at any version. It uses the public
`read_eval_log_async` now -- fixed rather than skipped.

## aiofiles -- the part that was actually ours

aiofiles resolves its executor via `asyncio.get_event_loop()` and raises
`no running event loop` under trio. Replaced in the two remaining
production call sites (`export/writers/directory.py`,
`scorers/_run_monitors.py`) and one test helper; the third, in
`iac_fast/scorers.py`, disappeared when #852 rewrote that function.
`aiofiles` and `types-aiofiles` are dropped from the dependencies.

`copy_eval_files_async` did not want `anyio.open_file` at all -- it read
a whole file into memory and wrote it back out. It hands
`shutil.copyfile` to a worker thread now, which streams. An .eval log
can be hundreds of megabytes.

## The tests for that change were mocking it out

`test_directory_writer.py` patched `aiofiles.open`, now
`anyio.open_file`, in all ten tests. So the trio variants passed without
ever opening a file -- they exercised everything except the thing this
PR changes. They write to `tmp_path` and assert on file contents now,
which is both simpler (no `AsyncMock` `__aenter__` dance, no indexing
into `write.call_args_list`) and real coverage of `anyio.open_file`
under both backends.

`test_file_encoding` asserted that `encoding="utf-8"` was passed. It now
writes `café ✅ 日本語` and checks it survives the round trip, including as
raw bytes in the file -- it fails if `ensure_ascii=False` is dropped,
which the kwarg assertion did not.

## The infra setting -- a real limitation, not a test artefact

Every infra task reaches the cluster through `kubernetes-asyncio`, which
is built on aiohttp, and aiohttp is asyncio-only:

```
kubernetes_asyncio/client/api_client.py:76
kubernetes_asyncio/client/rest.py:68
aiohttp/connector.py:313: RuntimeError: no running event loop
```

Most of the 67 were that RuntimeError. The rest were assertion failures
like `assert None == {'access-key': ...}`, which looked unrelated until
traced -- a broad `except Exception` in the task had swallowed the same
error and returned `None`.

A conftest under `settings/infra/` skips trio variants there with that
reason, and the README now records it. **The infra setting genuinely
does not work under trio** and will not until its Kubernetes client is
replaced.

That conftest is a third of its previous size. It uses
`pytest_runtest_setup`, which pytest calls only for the conftests
covering an item's path, so it needs none of the manual `INFRA_DIR`
filtering the collection hook required --
`pytest_collection_modifyitems` is handed *every* collected item, and
the first version of this file, which forgot to filter, silently skipped
the entire trio suite. It also shares `anyio_backend_of` with the root
conftest instead of being a third copy of it.

<details>
<summary>A dead end worth recording</summary>

I started migrating our own direct `aiohttp.ClientSession` use in those
tasks to httpx, and got four files in before pulling a full traceback
and discovering the failures come from `kubernetes-asyncio`, not from
our calls. Four of the six failing task modules do not use aiohttp
directly at all. I reverted it: it would have fixed nothing, while
changing scorer code that cannot be exercised without a cluster. (It
also introduced a bug on the way -- `as client` shadowing the imported
`kubernetes_asyncio.client` module, which would have been an
`UnboundLocalError` at a line executed earlier in the same function.)

Separately worth knowing: `aiohttp` is not a declared dependency of this
project. It arrives via `kubernetes-asyncio` and `miniopy-async`, and
several task modules import it directly.
</details>

## CI runs trio now

CI ran `pytest -n 8 -m "not skip_ci and not long_running"` with no
`--runtrio`, so **nothing ran these tests**. Going from 102 failures to
0 without a job to hold it there is a snapshot, not a fix -- 102 is what
silent drift looks like. There is a `Unit Tests (trio)` job now,
alongside a note in CONTRIBUTING that both invocations are needed for
full coverage.

Also regenerates `THIRD_PARTY_LICENSES.md`, which still listed
`types-aiofiles` and was failing the `checks` job.

## Verification

- Trio: **214 passed, 0 failed**. Asyncio: **1132 passed**.
- `ruff check`, `ruff format`, `pyright` (0 errors), and the licence
check all clean.
- Up to date with main, including #851 and #860.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <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