Skip to content

agusmdev/evloop-lint

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

evloop-lint

CI Python License: MIT

Detect synchronous, event-loop-blocking calls reachable from async code in FastAPI / async-Python projects — including the deeply nested, interprocedural, cross-file cases that ruff's flat ASYNC rules cannot follow.

app/main.py:4:5 EVL001 [definite] time.sleep blocks the event loop
  main.deep_handler [app/main.py:3] (async entry)
  -> service.fetch [app/service.py:7] (calls)
  -> db.query [app/db.py:4] (calls)
  -> time.sleep [app/db.py:4] (blocks via)
  fix: consider asyncio.sleep

ruff catches time.sleep only when you write it directly inside an async def. evloop-lint follows the call across files — router → service → repository → driver — and reports the whole chain. It is pure-stdlib (no runtime dependencies) and fast (hundreds of files in well under a second).

Quick start

The fastest way, with uv (no install, runs straight from this repo):

uvx --from git+https://github.com/agusmdev/evloop-lint evloop-lint path/to/your/app

Or clone and run:

git clone https://github.com/agusmdev/evloop-lint
cd evloop-lint
uv run --with pytest pytest          # run the test suite (41 tests)
uv run python -m evloop_lint.cli path/to/your/app

Or install into your environment:

pip install git+https://github.com/agusmdev/evloop-lint
evloop-lint path/to/your/app

Try it on a sample in 10 seconds

mkdir -p demo/app
cat > demo/app/db.py      <<'EOF'
import time
def query():
    time.sleep(1)          # the real blocker, hidden 3 hops deep
EOF
cat > demo/app/service.py <<'EOF'
from app.db import query
def fetch():
    return query()
EOF
cat > demo/app/main.py    <<'EOF'
from app.service import fetch
async def handler():
    fetch()                # ruff sees nothing here — evloop-lint follows the chain
EOF

uvx --from git+https://github.com/agusmdev/evloop-lint evloop-lint demo

You should see a single EVL001 finding with the full handler -> fetch -> query -> time.sleep chain, and a non-zero exit code.

What it finds

evloop-lint builds a project-wide call graph and propagates "reaches a blocking call" taint backward from every async def entry point, carrying an on-loop / off-loop context so that correctly-offloaded work (asyncio.to_thread, loop.run_in_executor, anyio.to_thread.run_sync, run_in_threadpool, …) is not flagged. It understands schedulers (call_soon, create_task), re-entry (anyio.from_thread.run), functools.partial, constructor __init__ bodies, @property getters, and FastAPI's threadpool semantics for plain def endpoints.

Rule codes

Code Meaning Tier(s)
EVL001 blocking I/O call on the loop definite / probable
EVL002 CPU-heavy call on the loop definite / probable
EVL003 unbounded loop, no yield point possible
EVL004 coroutine never awaited definite
EVL005 potential blocker past --max-depth possible
EVL006 ambiguous / dynamic dispatch possible
EVL011 blocking DB driver call definite / probable

Confidence tiers

Findings are emitted at a tier matching how the chain was resolved:

  • definite — resolved through real definitions to a known blocker. Shown by default; fails CI.
  • probable — confident heuristic method match (e.g. self.repo.find()). Opt-in: --confidence=probable.
  • possible — structural / weak / partial resolution. Opt-in: --confidence=possible.

The tool is optimistic: a call it cannot resolve is assumed safe, keeping the false-positive rate near zero so the default run stays trustworthy.

CLI flags

--max-depth N          max call hops to follow (default 4)
--confidence TIER      minimum tier to report (definite|probable|possible)
--format FMT           text | json | ndjson | sarif | github
--select CODES         only these rule codes (comma-separated)
--ignore CODES         exclude these rule codes
--exclude GLOBS        path globs to skip
--statistics           coverage + depth-truncation stats
--no-framework-detect  treat every async def as on-loop (max recall)
--strict               parse errors cause a non-zero exit
--exit-zero            always exit 0 (report only)

Exit codes: 0 no findings at/above the floor · 1 findings found · 2 usage error.

Suppress a line with # noqa or # noqa: EVL001.

Configuration

Via pyproject.toml:

[tool.evloop-lint]
max-depth = 4
confidence = "definite"
ignore = ["EVL003"]
exclude = ["tests/*", "migrations/*"]

CI integration

- name: Check for event-loop blockers
  run: uvx --from git+https://github.com/agusmdev/evloop-lint evloop-lint app/

SARIF output (--format sarif) uploads to GitHub code scanning; --format github emits inline PR annotations.

How it works / design

The detector is deliberately generic: every specific identifier (blocking primitives, offload primitives, framework registration shapes, wrappers) lives in a data registry (src/evloop_lint/registry.py), never in traversal logic. New libraries are data rows, not code changes.

It was developed through an adversarial loop: breaker agents generate realistic FastAPI code that tries to evade detection, a judge labels true escapes, and each escape is fixed generically and added as a permanent regression test (tests/test_adversarial.py). See docs/DESIGN.md for the full algorithm (D1–D10) and docs/adr/ for the key decisions.

License

MIT — see LICENSE.

About

Detect sync event-loop-blocking calls reachable from async code in FastAPI — including deeply nested, interprocedural cases ruff's flat ASYNC rules miss.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors