Async Errors
tyc::missing_await
A sync context calls an async def without await:
async def fetch(url: str) -> str: ...
def main() -> None: let body: str = fetch("...") # ❌ coroutine, not strFix: add await (and make main async):
async def main_async() -> None: let body: str = await fetch("...")
def main() -> None: asyncio.run(main_async())tyc::async_without_await (warning)
An async def contains no await:
async def helper() -> int: return 42 # ⚠️ no awaitFix: drop async, or actually await something. Don’t suppress — it’s almost always a mistake.
The warning already stands down where async is genuinely required despite the missing await: async-protocol dunders (__aenter__ / __aexit__ / __aiter__ / __anext__), declaration-only Protocol/interface bodies, async generators (yield), and — the case you’ll hit most — a method that is async only to satisfy an async contract: it implements an interface whose same-named method is async def (and the class conforms), or it overrides an async def base method. A trivial impl ConsoleSink: async def deliver(...) that just prints, satisfying interface Sink: async def deliver(...), checks clean without a dead await asyncio.sleep(0). (Gated on the interface method being async — an async impl of a sync interface method still warns.)
tyc::gather_opportunity (advice)
Two or more adjacent independent awaited calls inside an async def could run concurrently. This advice (Hint severity, on by default) suggests wrapping them in an explicit gather: block, which lowers to an asyncio.TaskGroup:
async def load(users: UserApi, posts_api: PostApi, uid: int) -> tuple[User, list[Post]]: let user = await users.get(uid) # 💡 tyc::gather_opportunity — let posts = await posts_api.for_user(uid) # these awaits could run concurrently return (user, posts)Unlike auto_gather_missed, this diagnostic is callee-agnostic: it fires for awaited method calls on imported clients (await client.get_user(...)), the shape auto-gather never touches, because the suggested fix — an explicit gather: block — works for any awaitable, no @gatherable decorator needed. Independence is decided by static data flow: a run breaks the moment a later await references a name bound earlier (including via the callee’s receiver b = await a.next(), keyword args, comprehensions, walrus, slices, or f-strings), and a single non-matching statement between two awaits ends the run.
It never rewrites — concurrency is a behaviour change the author opts into — and being advice-level it never blocks a build. When auto-gather is also on, runs it folds into a TaskGroup are gone before this pass runs, so they aren’t double-reported.
Fix: wrap the independent awaits in a gather: block:
async def load(users: UserApi, posts_api: PostApi, uid: int) -> tuple[User, list[Post]]: gather: user = users.get(uid) posts = posts_api.for_user(uid) return (user, posts)Awaits on the same receiver (await conn.execute(...) twice, await client.login() then await client.fetch()) are treated as dependent and never suggested — the object carries state between the calls, so running them concurrently would reorder it.
Knob: [strictness] suggest-gather (default true). tyc explain gather_opportunity documents it offline.
tyc::auto_gather_missed (advice)
Two or more adjacent await CALLEE(...) statements look independent enough to fold into an asyncio.TaskGroup block, but at least one callee is a same-module async def that lacks the @gatherable decorator. The auto-gather pass only rewrites runs where every callee carries @gatherable; without it, the awaits stay sequential silently. This advice-level diagnostic surfaces the missed opportunity so users can decide whether to decorate.
Only fires when [strictness] auto-gather = true.
async def a() -> int: ...
@gatherableasync def b() -> int: ...
async def main() -> None: let x: int = await a() # 💡 tyc::auto_gather_missed — `a` is not @gatherable let y: int = await b()Fix: decorate a (and any other same-module async callees in the run) with @gatherable to fold the awaits into an asyncio.TaskGroup automatically:
@gatherableasync def a() -> int: ...Externally-imported async callees are never rewritten — opting a project into auto-gather does not surprise callers that aren’t ready to run concurrently.
tyc::parallel_opportunity (advice)
Only fires when [python] free-threaded = true. A comprehension or integer accumulator loop that could be parallelised across a thread pool (or PEP 734 sub-interpreters), but the enabling knob is off — or a float accumulator loop that matches every reduction condition except the required int annotation:
# [python] free-threaded = true, [strictness] auto-parallel not setys: list[int] = [transform(x) for x in xs] # 💡 tyc::parallel_opportunity# [python] free-threaded = truemut total: float = 0.0for x in xs: total += x # 💡 tyc::parallel_opportunity — float reordering changes resultsThe lint never rewrites anything — it names the knob to flip, or (for the float case) explains why the shape is permanently ineligible.
Fix: flip [strictness] auto-parallel (and auto-parallel-reductions for the loop-reduction case). For the float case, refactor to an int accumulation if the domain allows, or write the parallel reduction explicitly with sum(typhon_runtime.parallel.map_pure(lambda x: EXPR, ITER)) so the reordering is a deliberate, visible choice.
Knob: [strictness] suggest-parallel (default true).
tyc::shared_mut_across_tasks (advice)
Only fires when [python] free-threaded = true. Flags a go-spawned same-module function that writes module-level mutable state — a global NAME assignment, or a write to a module-level mut binding:
# [python] free-threaded = truemut hits: int = 0
async def record() -> None: global hits hits = hits + 1 # concurrent write to shared state
async def serve() -> None: go record() # 💡 tyc::shared_mut_across_tasksUnder free-threaded Python a go-spawned task runs concurrently with the spawner (and every other task), so an unguarded write to shared state is a data race — lost updates, torn reads, or corrupt containers. With the GIL, most such writes were accidentally atomic; free-threading removes that safety net.
The lint is conservative: it only fires for a bare-name callee that resolves to a same-module def and directly writes a global or module-level mut binding. A go obj.method() (attribute callee) or an indirect shared write is not flagged.
Fix: guard the shared state (e.g. an asyncio.Lock), or have the task return its result and let the spawner (or a queue) accumulate it instead of writing a global:
import asyncio
freeze let LOCK = asyncio.Lock()mut hits: int = 0
async def record() -> None: global hits async with LOCK: hits = hits + 1Knob: [strictness] suggest-parallel (default true).
tyc::return_in_except_star
return, break, or continue inside an except* handler:
try: gather: user = fetch_user(id) posts = fetch_posts(id)except* ValueError: return None # ❌ CPython rejects this at compile timeCPython refuses to compile all three — an except* handler can run once per matching subgroup, so a jump out of it has no defined meaning. Before this diagnostic existed, tyc check passed and tyc build emitted a build/main.py that could not be imported.
Fix: set a flag in the handler and act on it after the try, or use plain except if the code does not need exception-group splitting. A jump bound to a loop declared inside the handler is legal and not flagged; nested def / class bodies are exempt.
Related: a failing task inside gather: (which lowers to asyncio.TaskGroup) surfaces as ExceptionGroup('unhandled errors in a TaskGroup', [...]) — catch it with except*, not a plain except. The VM models this identically; see the async reference and docs/vm.md for the residual divergences.
tyc::interface_isinstance
isinstance(x, MyInterface):
interface Drawable: def draw(self) -> None
def f(x: object) -> None: if isinstance(x, Drawable): # ❌ x.draw()Fix: use static narrowing or a sealed union; or write an explicit predicate function. See Interfaces for why.
tyc::go_outside_async
A go spawn runs where no event loop is running: in module-level code, or
in a synchronous function that module-level code calls. spawn would raise
RuntimeError: no running event loop, leaving the coroutine never awaited.
async def work() -> None: print("w")
def kick() -> None: go work()
kick() # ❌ `go` needs a running event loop — `work (via kick)` is spawned from module-level codeFix: spawn from inside a coroutine driven by asyncio.run, or run the
coroutine to completion instead of spawning it (asyncio.run(work())):
import asyncio
async def work() -> None: await asyncio.sleep(0) print("w")
async def kick() -> None: go work() await asyncio.sleep(0.01)
asyncio.run(kick())A synchronous function that spawns is fine when only coroutines call it; the check flags the module-level call that would run it without a loop.
tyc::blocking_in_async (warning)
A known-blocking standard-library call (time.sleep, requests.get,
subprocess.run, input, socket.recv, …) appears directly inside an
async def. It stalls the event loop and every other coroutine on it.
import time
async def poll() -> None: time.sleep(1) # ⚠ `time.sleep(...)` blocks the event loopFix: use the async equivalent (await asyncio.sleep(1)), or move the
call to a worker thread with await asyncio.to_thread(time.sleep, 1). Only
direct calls are flagged, and calls inside unsafe: are not. Severity:
[strictness] blocking-in-async.
Where next
async,await,gather,goreference — the surface.- Async and Concurrency (tour) — teaching page.