fix(backend): stop the async scanner flagging coroutines handed to asyncio - #12115
Merged
kodjima33 merged 1 commit intoAug 24, 2026
Merged
Conversation
…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
approved these changes
Aug 24, 2026
kodjima33
left a comment
Collaborator
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
#12060 taught the async blocker scanner that
await get_async_redis_client()is not asynchronous DB call. That skip is keyed on the
Callnode being the direct operand of anawait, 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(...)orasyncio.wait_for(...). The inner call then stops being theoperand of the
await, and the same accessor #12060 cleared is reported again the moment asecond one is awaited beside it. Calling an
async defonly builds a coroutine object;whether the event loop receives it directly or through asyncio, the calling frame does not
block.
backend-async-blockersis 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.waitandas_completedtake their awaitables. A list built elsewhereand 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 linethat also carries a handoff, because the skip stays keyed on node identity rather than line.
Out of scope, named honestly:
async with cm()andasync for x in gen()still report theoperand 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.pyand callingscan_dirson a one-file tree:Before this change (upstream/main, 02c48d7):
async defasync_helpers_with_blockingawait get_async_redis_client()await asyncio.gather(get_async_redis_client(), get_async_cache_client())get_async_redis_client,get_async_cache_clientawait asyncio.create_task(get_async_redis_client())get_async_redis_clientawait asyncio.wait_for(get_async_redis_client(), timeout=5)get_async_redis_clientawait asyncio.wait([get_async_redis_client(), get_async_cache_client()])client = get_async_redis_client()(no await)get_async_redis_clientAfter: 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) thesummary 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/mainbefore the fix:test_coroutines_handed_to_asyncio_are_not_blocking— the five handoff spellings; firstfailure 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_blocking—group.create_task(...)inside aTaskGroup.test_a_sync_db_call_beside_a_handoff_on_one_line_is_still_blocking— before the fix theassertion 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 passesboth before and after, by design — it exists so a broader future skip cannot pass silently.
scripts/pr-preflight --base upstream/mainandblack --checkwere run on the branch.Failure class (fixes)
Failure-Class: none
Same declaration as #12060, which fixed the previous instance in this scanner.