Skip to content

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 str

Fix: 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 await

Fix: 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: ...
@gatherable
async 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:

@gatherable
async 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 set
ys: list[int] = [transform(x) for x in xs] # 💡 tyc::parallel_opportunity
# [python] free-threaded = true
mut total: float = 0.0
for x in xs:
total += x # 💡 tyc::parallel_opportunity — float reordering changes results

The 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 = true
mut 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_tasks

Under 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 + 1

Knob: [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 time

CPython 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 code

Fix: 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 loop

Fix: 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