fix(backend): stop the async scanner counting awaited calls as blocking - #12060
Conversation
`_scan_function_body` classified every `database.*` import called inside an `async def` as a synchronous DB call, without checking whether it was awaited. `await get_async_redis_client()` is the correct way to reach the shared async client, and the scanner reported it as a blocking helper. That made a correct change unpushable: `backend-async-blockers` is a blocking pre-push gate and this scanner has no allowlist, no inline waiver, and no way to record that a finding is wrong. The only routes past it were to restructure working code around the linter or to bypass the gate. Awaited calls are now skipped, keyed on AST node identity rather than line number — one line can hold an awaited call and a synchronous one, and only the awaited half is safe. The rule does not widen: an unawaited `database.*` call inside an `async def` is still reported, with a test pinning that. Verification: python3 -m pytest backend/tests/unit/test_scan_async_blockers.py -> 27 passed (25 pre-existing + 2 new) Guard proven by removing it: with the awaited-call skip deleted, exactly one test fails — the awaited-accessor case. Failure-Class: none
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@Git-on-my-level small one, and it's blocking another PR.
Fix skips calls that are the direct operand of 27 tests pass (25 existing + 2 new). Proven by deleting the guard — exactly one test fails. Checks green. #11807 can't push until this lands. |
kodjima33
left a comment
There was a problem hiding this comment.
Confidence 5/5: root cause explained, scoped diff (60 lines, 2 files), CI green, new regression tests (added + removed to prove they catch the bug). Author aryanorastar is a trusted backend contributor per prior history. No abuse signals (no workflow file changes).
…ng (BasedHardware#12060) `_scan_function_body` classified every `database.*` import called inside an `async def` as a synchronous DB call, without checking whether it was awaited. `await get_async_redis_client()` is the correct way to reach the shared async client, and the scanner reported it as a blocking helper. That made a correct change unpushable: `backend-async-blockers` is a blocking pre-push gate and this scanner has no allowlist, no inline waiver, and no way to record that a finding is wrong. The only routes past it were to restructure working code around the linter or to bypass the gate. Awaited calls are now skipped, keyed on AST node identity rather than line number — one line can hold an awaited call and a synchronous one, and only the awaited half is safe. The rule does not widen: an unawaited `database.*` call inside an `async def` is still reported, with a test pinning that. Verification: python3 -m pytest backend/tests/unit/test_scan_async_blockers.py -> 27 passed (25 pre-existing + 2 new) Guard proven by removing it: with the awaited-call skip deleted, exactly one test fails — the awaited-accessor case. Failure-Class: none
…yncio (#12115) #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 #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
Summary
scan_async_blockersclassified everydatabase.*import called inside anasync defas a synchronous DB call, without checking whether it was awaited.await get_async_redis_client()is the correct way to reach the shared async client. The scanner reported it as a blocking helper:Why it needed fixing rather than working around
backend-async-blockersis a blocking pre-push gate, and this scanner has no allowlist, no inline waiver, and no way to record that a finding is wrong. So a correct change becomes unpushable, and the only routes past it are to restructure working code around the linter or to bypass the gate.Found while routing the proactive dispatcher onto the publisher's shared Redis client — the dispatcher was subscribing to a different server than the publisher writes to, so no message ever arrived.
The change
Calls that are the direct operand of
awaitare skipped. Keyed on AST node identity, not line number — one line can hold an awaited call and a synchronous one, and only the awaited half is safe.The rule does not widen: an unawaited
database.*call inside anasync defis still reported, pinned by its own test.Verification
Guard proven by removing it: with the awaited-call skip deleted, exactly one test fails — the awaited-accessor case. A test that has never failed is not evidence.
Product invariants affected
none
Failure class (fixes)
Failure-Class: none