Skip to content

fix(backend): stop the async scanner flagging coroutines handed to asyncio - #12115

Merged
kodjima33 merged 1 commit into
BasedHardware:mainfrom
igor-popov-dev:fix/async-scanner-coroutine-handoff
Aug 24, 2026
Merged

fix(backend): stop the async scanner flagging coroutines handed to asyncio#12115
kodjima33 merged 1 commit into
BasedHardware:mainfrom
igor-popov-dev:fix/async-scanner-coroutine-handoff

Conversation

@igor-popov-dev

@igor-popov-dev igor-popov-dev commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What changed and why

#12060 taught the async blocker scanner that await get_async_redis_client() is not a
synchronous DB call. That skip is keyed on the Call node being the direct operand of an
await, so it only covers the single-coroutine form.

Awaiting two accessors at once, or one with a deadline, is spelled asyncio.gather(...),
asyncio.create_task(...) or asyncio.wait_for(...). The inner call then stops being the
operand of the await, and the same accessor #12060 cleared is reported again the moment a
second one is awaited beside it. Calling an async def only builds a coroutine object;
whether the event loop receives it directly or through asyncio, the calling frame does not
block.

backend-async-blockers is a blocking pre-push gate with no allowlist and no inline waiver,
so a false positive leaves only two routes: restructure working code around the linter, or
bypass the gate.

Coverage is deliberately narrow, matching the rest of this scanner: only arguments written at
the call site count — directly, unpacked with *, or as elements of a literal list/tuple/set,
which is how asyncio.wait and as_completed take their awaitables. A list built elsewhere
and passed in by name stays outside the analysis and is still reported.

The rule does not widen. An unawaited database.* call is still reported, including on a line
that also carries a handoff, because the skip stays keyed on node identity rather than line.

Out of scope, named honestly: async with cm() and async for x in gen() still report the
operand call. Unlike an awaitable argument, a class-based async context manager or a sync
function returning an async iterable does run code in the calling frame, so clearing those
needs its own argument.

Product invariants affected

none

How it was verified

Ran the scanner against the forms in question rather than reading it, loading
backend/scripts/scan_async_blockers.py and calling scan_dirs on a one-file tree:

Before this change (upstream/main, 02c48d7):

source in an async def async_helpers_with_blocking
await get_async_redis_client() clean (#12060)
await asyncio.gather(get_async_redis_client(), get_async_cache_client()) get_async_redis_client, get_async_cache_client
await asyncio.create_task(get_async_redis_client()) get_async_redis_client
await asyncio.wait_for(get_async_redis_client(), timeout=5) get_async_redis_client
await asyncio.wait([get_async_redis_client(), get_async_cache_client()]) both
client = get_async_redis_client() (no await) get_async_redis_client

After: every awaited form is clean, and the unawaited call is still reported.

Full-tree effect, the honest number: on the scanned dirs
(backend/routers backend/utils backend/dependencies.py, 400 files, 141 async endpoints) the
summary is byte-identical before and after — all categories zero. No current finding is
silently dropped by this change; what it removes is the next false positive, not an existing
one.

Tests

backend/tests/unit/test_scan_async_blockers.py — 31 passed, up from 27.

Regression tests, red on upstream/main before the fix:

  • test_coroutines_handed_to_asyncio_are_not_blocking — the five handoff spellings; first
    failure is AssertionError: await asyncio.gather(get_async_redis_client(), get_async_cache_client()) / Left contains one more item: {...'function': 'dispatcher'...}.
  • test_task_group_create_task_is_not_blockinggroup.create_task(...) inside a
    TaskGroup.
  • test_a_sync_db_call_beside_a_handoff_on_one_line_is_still_blocking — before the fix the
    assertion collects two calls where one is correct, which is what pins the skip to node
    identity rather than to the line.

Pinning that the rule does not widen: test_a_call_asyncio_never_receives_is_still_blocking
(an await asyncio.sleep(1) in the same function does not clear a later sync call). It passes
both before and after, by design — it exists so a broader future skip cannot pass silently.

scripts/pr-preflight --base upstream/main and black --check were run on the branch.

Failure class (fixes)

Failure-Class: none

Same declaration as #12060, which fixed the previous instance in this scanner.

Review in cubic

…yncio

BasedHardware#12060 taught `_scan_function_body` that `await get_async_redis_client()` is
not a blocking DB call. The skip is keyed on the Call node being the direct
operand of an `await`, so it only covers the single-coroutine form.

Awaiting two accessors at once, or one with a deadline, is spelled
`asyncio.gather(...)`, `asyncio.create_task(...)`, `asyncio.wait_for(...)`.
The inner call then stops being that operand and the same accessor BasedHardware#12060
cleared is reported again the moment a second one is awaited beside it.
Calling an `async def` only builds a coroutine object; whether the event loop
receives it directly or through asyncio, the calling frame does not block.

`backend-async-blockers` is a blocking pre-push gate with no allowlist and no
inline waiver, so every false positive costs a correct change.

Arguments written at the call site are covered — directly, unpacked with `*`,
or as elements of a literal list/tuple/set. A name built elsewhere and passed
in stays outside the analysis, like the rest of this scanner.

The rule does not widen: an unawaited `database.*` call is still reported,
including on a line that also carries a handoff.

Failure-Class: none

@kodjima33 kodjima33 left a comment

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.

Follow-up to #12060: extends the async-blocker scanner's await-skip to cover coroutines handed off via asyncio.gather/wait/wait_for/create_task/shield, not just direct await. Verified not fixed on main. Thorough tests, scoped, CI green. Confidence 5/5.

@kodjima33
kodjima33 merged commit 9c83dc4 into BasedHardware:main Aug 24, 2026
34 checks passed
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.

2 participants