Skip to content

Repository files navigation

blockpath

ci python

Find the synchronous call that stalls your event loop, and print the whole path to it: from the async handler down through services/ and clients/ to the blocking requests.get, across as many files and modules as it takes.

Existing linters catch a blocking call only when it sits directly in the async def body. In real code it almost never does -- it is three to five calls deep, in another module, behind a couple of helpers nobody thinks about. That is where it hides, and that is what blockpath finds.

The problem is real, and it was measured before a line of the analyzer was written

A survey of 57 public aiogram/FastAPI repositories, with the protocol and thresholds registered in advance so the result could not be steered, asked one question: when a coroutine can reach a blocking call, how deep is it?

depth 1 | ########################################   77  (48.7%)   <- a per-file linter sees these
depth 2 | ###############                            28  (17.7%)
depth 3 | ################                           31  (19.6%)
depth 4 | #########                                  17  (10.8%)
depth 5 | ##                                          3  ( 1.9%)
depth 6 | #                                           2  ( 1.3%)

        |                                           158 reachable blocking call sites

51% of them sit at depth 2 or deeper, across module boundaries, where no per-file linter can follow. As a cross-check, ruff --select ASYNC run on the same repositories flags 0 of those 81 deep sites. The histogram, and the fact that it rebuilds from scratch, is in benchmark/depth_histogram.md (make bench-depth).

What it looks like

blockpath check finding a blocking call through three files

$ blockpath check .
BLK001 blocking call reachable from a coroutine
  app/handlers/order.py:11    return resolve_address(address)                     <- entry: async def
    app/services/geo.py:6       return geocode(query)
      app/clients/nominatim.py:7  response = requests.get(BASE_URL, params=...)   <- blocks the event loop
  hint: await asyncio.to_thread(resolve_address, ...)

1 finding(s): 1 BLK001

The path through three files is the point: no existing linter prints it.

Quick start

pip install blockpath
blockpath check .
blockpath check . --json      # for CI

Exit code is 0 when clean, 1 when there are findings (fails a build), 2 if the tool itself errored -- so a crash is never mistaken for a clean run.

Optional configuration, in your own pyproject.toml:

[tool.blockpath]
tiers = ["error"]                       # error (default) | warning | off
entry-decorators = ["router.message"]   # mark framework handlers as entry points
strict = false                          # also report blocking calls handed to unresolved callees

How it works

Six layers, each a small tested unit, and one idea that carries the whole thing.

collect  ->  symbols  ->  resolve  ->  callgraph  ->  oracle  ->  analysis
(walk &      (names &     (name ->     (CALL/REF    (what      (three BFS,
 parse)       scopes)      function)    edges)       blocks)    the witness)

The idea is that a finding is the intersection of two independent reachability questions, and that intersection is why the tool stays quiet:

  1. Backward from every blocking leaf: which functions can reach a blocking call at all?
  2. Forward from every coroutine: which functions can a coroutine reach?
  3. A third pass over the intersection recovers the witness -- the exact chain of call sites.

Report only their intersection and time.sleep in a synchronous CLI script in the same repo stays silent. Report either half alone and the tool cries wolf and gets switched off. The whole analysis is O(V+E) -- three breadth-first searches, no Tarjan, no SCC, no fixpoint (ADR 0002).

The call graph is built from the AST without importing or running your code, so a project that needs a database to import is analysed the same as any other.

How good is it, honestly

Run over five of the surveyed repositories and adjudicated by hand, finding by finding, with a second pass auditing the riskiest calls (benchmark/adjudication.md):

  • precision 110/112 = 98.2% by the tool's literal claim (a blocking call reachable from a coroutine), of which 99 are product-code bugs and 11 are real blocking calls in test code; or 99/112 = 88.4% counting product bugs only. Both numbers are in the table, and each of the two false positives is explained with a permalink.
  • recall is a lower bound, never a point estimate. The runtime verifier (blockpath verify -- pytest) records which functions actually ran on the loop and compares them to the static findings, but it only sees the paths the tests exercised. It can prove a miss and can never prove completeness, and it says nothing about precision -- only the hand adjudication does.

Precision is not a number the analyzer asserts about itself; it is what survived a human reading each finding against the source.

Design decisions, and where it loses

  • Only calls by name are resolved. An attribute call on a value (self.session.get()) needs type inference, which this does not do; the cost is measured (about half of all call sites on one benchmark repo) rather than waved away (ADR 0003).
  • A reference is not a call. A function passed to run_in_executor(None, f) runs in a thread, so it is silent by construction -- no offload false positives -- while run_in_executor(None, f()) with parentheses is a real bug (BLK002) (ADR 0007).
  • Unknown is quiet by default. A path with an unresolved edge is not reported unless you ask with --strict (BLK003) (ADR 0006).
  • The complete list of limitations is written as they were found, not at the end.

Prior art

  • ruff ASYNC rules are written in Rust and run hundreds of times faster than this. They are the right tool for the depth-1 case, and blockpath does not try to compete on speed. They work per file, by an explicit architectural choice (parallel analysis without global state), so they do not build a cross-module call graph and cannot print the path -- which is the entire reason this exists. The 0-of-81 cross-check above is that difference, measured.
  • flake8-async is likewise per-file and does not follow calls between modules.

blockpath is not a replacement for either; it is the cross-module analysis they deliberately do not do.

Development

uv sync --all-extras
make check          # ruff, mypy --strict, pytest
make bench-depth    # rebuild the depth histogram from the pinned corpus
./scripts/reproduce_benchmark.sh   # rebuild the precision findings

This project is AI-assisted: written by a human, who made the product and architecture decisions and signed off at every checkpoint, with an AI pair implementing under direction. WORKLOG.md keeps the estimate-versus-actual log from day one, including where the estimates were wrong.

Python 3.12+ (it uses sys.monitoring, ADR 0001). MIT licensed.

About

Find blocking calls reachable from a coroutine, and print the whole call path across files and modules.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages