scripts.check_async_await
Reference › scripts › check_async_await
An async-correctness gate that fails on un-awaited coroutines, resolving them by type through Pyright. No module or test imports it; it takes no options and is run by a gate or by hand.
main first looks for the pyright executable. If it is absent the gate prints a SKIPPED notice pointing at the dev dependencies and returns 0. Otherwise it runs Pyright with JSON output over the configured target directories, under a 600-second budget (a measured cold run took up to 380 seconds on the whole codebase). A timeout, or output that cannot be parsed, returns 2. It then filters the general diagnostics with _is_async_misuse, which keeps the coroutine-misuse diagnostics, and drops paths matched by _excluded. Any remaining hit is printed and fails the gate.
At a glance
| Source | scripts/check_async_await.py |
| Lines of code | 150 |
| Classes | 0 |
| Functions | 3 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | none |
Class diagram
No classes; see Functions.
Dependency diagram
No internal imports and no importers.
API
Functions
| Function | Signature | Summary |
|---|---|---|
_excluded |
_excluded(path: str) -> bool |
|
_is_async_misuse |
_is_async_misuse(diag: dict) -> bool |
True iff the diagnostic is a genuine un-awaited-coroutine defect. |
main |
main() -> int |
Source
#!/usr/bin/env python3
"""Async-correctness gate: fail on un-awaited coroutines (resolve-by-type via Pyright).
Detects the bug class where an ``async def`` is called WITHOUT ``await`` — the
coroutine is created and silently discarded (the work never runs, no exception is
raised), or its un-awaited result is used as if it were the resolved value.
Pyright is the detector because it ignores the ``# mypy: ignore-errors`` directive
present on ~228 source files (so it sees code mypy skips), AND because it is
*deterministic*: a runtime gate (erroring on the "coroutine never awaited"
RuntimeWarning) was evaluated and rejected — that warning fires at GC time via the
unraisable-exception hook, so under the free-threaded *batched* test runner it
fires non-deterministically and is mis-attributed to an innocent module, causing
flaky red builds. Static analysis has none of that timing dependence. See
``docs/sync-vs-async.md``.
Scope: both ``src/orchestrata_ultimate`` (production) and ``tests/`` (test
infrastructure can leak un-awaited coroutines too — e.g. an async driver fixture
whose teardown discards ``driver.close()``). The suite's ``--ignore``'d dirs
(``generated_recovery``, ``_generated_broken``) are excluded to match collection.
The gate is scoped to the TRUE-POSITIVE signatures only:
* ``reportUnusedCoroutine`` — a bare ``async_fn()`` whose result is discarded.
* operations on a ``CoroutineType`` — attribute/index/iteration/operator access on
the un-awaited result of an ``async def``.
It deliberately EXCLUDES:
* redis-py ``Awaitable[T] | T`` (``ResponseT``) stub noise on the *sync* client —
Pyright emits ``Awaitable[...]`` (a union) for those; a real un-awaited async
function is ``CoroutineType[...]`` (no union). See docs/sync-vs-async.md §1.
* interface/argument signature mismatches (``reportArgumentType`` /
``reportIncompatibleMethodOverride``) — not un-awaited-coroutine bugs.
Exit 0 when clean, 1 when an offender is found. Skips (exit 0, warning) when
pyright is not installed, so a contributor without pyright is not blocked
locally; CI installs pyright (a declared dev dependency) and enforces the gate.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
# Production code plus test infrastructure.
TARGETS = ["src/orchestrata_ultimate", "tests"]
# Mirror the suite's collection ignores (pyproject addopts: --ignore=...). These
# dirs never run, so their broken/generated contents are out of scope.
_EXCLUDE_DIRS = ("/generated_recovery/", "/_generated_broken/")
# Pyright rules that, combined with a CoroutineType in the message, mean an
# un-awaited async result is being *operated on* (attribute/index/iter/operator).
_OP_RULES = frozenset(
{
"reportAttributeAccessIssue",
"reportIndexIssue",
"reportOperatorIssue",
"reportGeneralTypeIssues",
}
)
def _excluded(path: str) -> bool:
return any(d in path for d in _EXCLUDE_DIRS)
def _is_async_misuse(diag: dict) -> bool:
"""True iff the diagnostic is a genuine un-awaited-coroutine defect."""
if diag.get("rule") == "reportUnusedCoroutine":
return True
message = diag.get("message", "")
return "CoroutineType[" in message and diag.get("rule") in _OP_RULES
def main() -> int:
if shutil.which("pyright") is None:
print(
"[async-gate] SKIPPED: pyright is not installed. Install the 'pyright' "
"dev dependency (uv sync --dev) to enable this gate locally.",
file=sys.stderr,
)
return 0
# Pyright on this codebase measured 162s–380s (2026-09-10, cold cache
# vs. warm cache, src/+tests/ ~122K LOC). Budget 600s — 1.5x the
# worst measured run — so a genuine regression is caught without
# false-timeout on legitimate runs. The old external `timeout 90`
# wrapper simply killed a correct analysis mid-run.
PYRIGHT_TIMEOUT = 600
print(
f"[async-gate] Running pyright on {', '.join(TARGETS)} "
f"(timeout {PYRIGHT_TIMEOUT}s)...",
file=sys.stderr,
flush=True,
)
try:
proc = subprocess.run(
[sys.executable, "-m", "pyright", "--outputjson", *TARGETS],
capture_output=True,
text=True,
timeout=PYRIGHT_TIMEOUT,
)
except subprocess.TimeoutExpired:
print(
f"[async-gate] TIMEOUT: pyright exceeded {PYRIGHT_TIMEOUT}s budget.",
file=sys.stderr,
)
return 2
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
print("[async-gate] ERROR: could not parse pyright output.", file=sys.stderr)
print(proc.stdout[-2000:], file=sys.stderr)
print(proc.stderr[-2000:], file=sys.stderr)
return 2
diags = data.get("generalDiagnostics", [])
hits = [
d for d in diags if _is_async_misuse(d) and not _excluded(d.get("file", ""))
]
if not hits:
print(
f"[async-gate] OK: no un-awaited coroutines in {', '.join(TARGETS)} "
f"({len(diags)} pyright diagnostics scanned)."
)
return 0
print(
f"[async-gate] BLOCKED: {len(hits)} un-awaited coroutine(s) found — an async "
f"function is called without `await`, so the work never runs. "
f"See docs/sync-vs-async.md.\n",
file=sys.stderr,
)
for d in hits:
try:
rel = os.path.relpath(d["file"])
except ValueError:
rel = d["file"]
line = d["range"]["start"]["line"] + 1
first = (d.get("message", "") or "").splitlines()[0]
print(f" {rel}:{line} [{d.get('rule')}] {first}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())