Skip to main content

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

Project description

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

blockpath-0.1.0.tar.gz (233.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

blockpath-0.1.0-py3-none-any.whl (45.1 kB view details)

Uploaded Python 3

File details

Details for the file blockpath-0.1.0.tar.gz.

File metadata

  • Download URL: blockpath-0.1.0.tar.gz
  • Upload date:
  • Size: 233.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for blockpath-0.1.0.tar.gz
Algorithm Hash digest
SHA256 33eac2917b68924c49807ee661793863eaa6fd6ae957d1395a919fbef0a1f4db
MD5 19fed21a9598867d542fcb3f32b6b455
BLAKE2b-256 2dc3b4def117096859b9ba4a90aa6018b3eb310a7d0e6514fd6761f75dc16080

See more details on using hashes here.

Provenance

The following attestation bundles were made for blockpath-0.1.0.tar.gz:

Publisher: release.yml on iraettae/blockpath

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file blockpath-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: blockpath-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 45.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for blockpath-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6ba26547a7d54c9ba4ab60ab57d1988872dd514cb67cc2640fd38cf356696912
MD5 e018f355362b23031e13c41fbd29578e
BLAKE2b-256 655554bfc1ae24cb99a7ea9e86b4c0dee4a60b9cea4d5edc52bdd62a77354277

See more details on using hashes here.

Provenance

The following attestation bundles were made for blockpath-0.1.0-py3-none-any.whl:

Publisher: release.yml on iraettae/blockpath

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page