fastapi-loopguard 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/.github/workflows/ci.yml +1 -1
- fastapi_loopguard-0.6.0/CHANGELOG.md +104 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/CLAUDE.md +4 -4
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/FINDINGS.md +35 -14
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/PKG-INFO +23 -3
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/README.md +20 -1
- fastapi_loopguard-0.6.0/assets/demo.gif +0 -0
- fastapi_loopguard-0.6.0/assets/demo.tape +59 -0
- fastapi_loopguard-0.6.0/assets/error-page.gif +0 -0
- fastapi_loopguard-0.6.0/docs/AI-HARNESS.md +111 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/docs/CONFIGURATION.md +8 -2
- fastapi_loopguard-0.6.0/evals/README.md +64 -0
- fastapi_loopguard-0.6.0/evals/runner.py +104 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/app_skeleton.py +8 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/checks.py +15 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/helpers.py +16 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/reference/blocking.py +10 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/reference/clean.py +10 -0
- fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/task.md +11 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/app_skeleton.py +8 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/checks.py +20 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/helpers.py +16 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/reference/blocking.py +12 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/reference/clean.py +14 -0
- fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/task.md +13 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/app_skeleton.py +8 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/checks.py +15 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/helpers.py +9 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/reference/blocking.py +11 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/reference/clean.py +13 -0
- fastapi_loopguard-0.6.0/evals/tasks/03-report-export/task.md +14 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/app_skeleton.py +8 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/checks.py +15 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/helpers.py +9 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/reference/blocking.py +12 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/reference/clean.py +14 -0
- fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/task.md +13 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/app_skeleton.py +8 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/checks.py +18 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/helpers.py +18 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/reference/blocking.py +11 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/reference/clean.py +11 -0
- fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/task.md +11 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/examples/demo_app.py +4 -3
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/pyproject.toml +5 -2
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/__init__.py +0 -1
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/config.py +16 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/logging.py +17 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/middleware.py +33 -16
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/monitor.py +113 -53
- fastapi_loopguard-0.6.0/src/fastapi_loopguard/pytest_plugin.py +262 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_config.py +49 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_cumulative_blocking.py +61 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_enforcement_mode.py +207 -1
- fastapi_loopguard-0.6.0/tests/test_evals_runner.py +57 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_logging.py +64 -1
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_middleware.py +392 -1
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_monitor.py +1236 -915
- fastapi_loopguard-0.6.0/tests/test_pytest_plugin.py +579 -0
- fastapi_loopguard-0.5.0/CHANGELOG.md +0 -39
- fastapi_loopguard-0.5.0/assets/error-page-screenshot.png +0 -0
- fastapi_loopguard-0.5.0/src/fastapi_loopguard/pytest_plugin.py +0 -135
- fastapi_loopguard-0.5.0/tests/test_pytest_plugin.py +0 -280
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/.github/dependabot.yml +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/.github/workflows/publish.yml +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/.gitignore +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/LICENSE +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/assets/error-page-screenshot-console.png +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/assets/error-page-screenshot-endpoint.png +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/assets/loopguard-logo.webp +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/examples/locustfile.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/examples/run_stress_test.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/examples/stress_app.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/context.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/metrics.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/py.typed +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/__init__.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_context.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_metrics.py +0 -0
- {fastapi_loopguard-0.5.0 → fastapi_loopguard-0.6.0}/tests/test_packaging.py +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.6.0 (2026-08-12)
|
|
4
|
+
|
|
5
|
+
Detection-core hardening plus the AI test harness. Fixes found by a
|
|
6
|
+
line-by-line audit of the detection path; every item below changes
|
|
7
|
+
observable behavior.
|
|
8
|
+
|
|
9
|
+
- The adaptive threshold can no longer ratchet itself blind. Recalculation
|
|
10
|
+
is clamped to the `fallback_threshold_ms` ceiling; previously each raise
|
|
11
|
+
widened the censor gate that admits the next round of samples, compounding
|
|
12
|
+
50 -> 241 -> 573ms within seconds on merely-noisy traffic.
|
|
13
|
+
- Cumulative detection no longer counts timer jitter. The window now sums
|
|
14
|
+
baseline-corrected excess lag (`max(0, lag - baseline)`); raw per-tick
|
|
15
|
+
platform jitter (~2ms idle on macOS) alone could previously cross the
|
|
16
|
+
200ms default within one 1000ms window on a completely idle loop.
|
|
17
|
+
- Lazy stop/start race fixed: a request arriving while the monitor was
|
|
18
|
+
stopping (last request just finished) used to observe stale lifecycle
|
|
19
|
+
flags and run unmonitored. Flags are now cleared before the stop awaits,
|
|
20
|
+
so a mid-stop request starts a fresh monitor.
|
|
21
|
+
- `stop()` no longer swallows a cancellation aimed at the calling task.
|
|
22
|
+
It runs inside request `finally` blocks; the old
|
|
23
|
+
`suppress(CancelledError)` turned client disconnects and server shutdown
|
|
24
|
+
into requests that ignored their own cancellation.
|
|
25
|
+
- `stop()` issued during a blocking `start()` calibration now vetoes the
|
|
26
|
+
loop start instead of being a silent no-op.
|
|
27
|
+
- A lifespan app that raises no longer leaks the monitor tasks.
|
|
28
|
+
- One blocking event now logs exactly one summary line instead of one line
|
|
29
|
+
per in-flight request (O(N) log formatting on the loop thread right after
|
|
30
|
+
a stall). The per-context `on_blocking` callback contract is unchanged.
|
|
31
|
+
- New config validation: `fallback_threshold_ms >= monitor_interval_ms`,
|
|
32
|
+
`adaptive_update_interval_ms >= monitor_interval_ms`, and `exclude_paths`
|
|
33
|
+
may not be a bare string (it would silently become a substring match).
|
|
34
|
+
- Strict mode's clean-path headers are now computed from the request
|
|
35
|
+
context instead of hardcoded zero literals.
|
|
36
|
+
- Calibration clamps a negative raw baseline (timers may fire up to
|
|
37
|
+
clock_resolution early) to 0.
|
|
38
|
+
|
|
39
|
+
AI test harness (pytest plugin):
|
|
40
|
+
|
|
41
|
+
- New `loopguard_all_async` mode (ini or `--loopguard-all-async`): every
|
|
42
|
+
async test is treated as `@pytest.mark.no_blocking`, with a new
|
|
43
|
+
`@pytest.mark.allow_blocking` opt-out marker. Built for gating
|
|
44
|
+
AI-generated code without per-test annotations.
|
|
45
|
+
- New `loopguard_report` option (ini or `--loopguard-report=PATH`): writes
|
|
46
|
+
a JSON verdict file at session end with per-test blocking events and
|
|
47
|
+
concrete sync->async fix hints. See `docs/AI-HARNESS.md`.
|
|
48
|
+
- The detector is now armed before the test body runs. A test that blocked
|
|
49
|
+
before its first real await (an ASGI request dispatch does exactly that)
|
|
50
|
+
was previously invisible to the gate.
|
|
51
|
+
- BREAKING: the `loopguard_detector` fixture was removed. It yielded a
|
|
52
|
+
detector that was never started, so assertions on its (always empty)
|
|
53
|
+
`blocking_events` passed unconditionally.
|
|
54
|
+
- `@pytest.mark.no_blocking` on a synchronous test now emits a warning
|
|
55
|
+
instead of silently doing nothing.
|
|
56
|
+
- The plugin no longer calls `asyncio.iscoroutinefunction` (deprecated on
|
|
57
|
+
Python 3.14), so downstream suites running `-W error` stay green.
|
|
58
|
+
- `BlockingDetector.stop()` no longer swallows the test's own cancellation.
|
|
59
|
+
|
|
60
|
+
Also:
|
|
61
|
+
|
|
62
|
+
- New `evals/` directory: a starter benchmark (5 FastAPI tasks + runner)
|
|
63
|
+
scoring whether an AI model writes non-blocking async code.
|
|
64
|
+
- `configure_logging()` is idempotent and disables propagation;
|
|
65
|
+
`StructuredFormatter` preserves `exc_info` tracebacks.
|
|
66
|
+
- `prometheus-client` joined the `dev` extra so the metrics tests run in CI.
|
|
67
|
+
|
|
68
|
+
## 0.5.0 (2026-08-11)
|
|
69
|
+
|
|
70
|
+
Correctness release. Every fix below changes externally observable behavior.
|
|
71
|
+
|
|
72
|
+
- `dev_mode` no longer turns blocking into 503s. Previously, one endpoint's
|
|
73
|
+
sync block made every concurrent innocent request fail with 503 while the
|
|
74
|
+
actual culprit returned 200. `dev_mode` now only adds diagnostic headers;
|
|
75
|
+
a 503 requires explicitly setting `enforcement_mode="strict"`. Docs no
|
|
76
|
+
longer claim the library identifies which endpoint caused blocking — it
|
|
77
|
+
reports the requests that were in flight during the stall.
|
|
78
|
+
- A single blocking event is reported once, not twice. A 300ms block used to
|
|
79
|
+
produce two events summing to ~600ms in headers and logs (single-shot plus
|
|
80
|
+
a cumulative re-count of the same sample). Cumulative window sums are now
|
|
81
|
+
recorded separately from individual lag samples.
|
|
82
|
+
- Calibration can no longer be poisoned by the app's own blocking. Background
|
|
83
|
+
calibration during a busy blocking startup used to raise the detection
|
|
84
|
+
threshold to ~8x the fallback, after which real blocking went undetected.
|
|
85
|
+
A calibrated threshold now only ever tightens the fallback.
|
|
86
|
+
- The adaptive threshold no longer chases sustained blocking. ~120 blocks of
|
|
87
|
+
~70ms used to yield ~9 detections and a final threshold near 380ms;
|
|
88
|
+
detection now keeps firing for the whole run.
|
|
89
|
+
- Adaptive mode no longer discards a calibration-tightened threshold. Its
|
|
90
|
+
floor used to be pinned at the fallback and its first update fired
|
|
91
|
+
immediately, snapping a calibrated 10ms threshold back to 50ms on the
|
|
92
|
+
first tick. The floor now follows the calibrated threshold.
|
|
93
|
+
- Responses using ASGI extension messages no longer hang in strict mode:
|
|
94
|
+
`http.response.pathsend` (Starlette `FileResponse` on Hypercorn/Granian),
|
|
95
|
+
`http.response.trailers`, and unknown message types now pass through.
|
|
96
|
+
- `"trailers": True` and any other key on `http.response.start` survive
|
|
97
|
+
header injection instead of being silently dropped.
|
|
98
|
+
- The monitor no longer leaks background tasks when startup fails
|
|
99
|
+
(`lifespan.startup.failed` / `lifespan.shutdown.failed` now stop it), and a
|
|
100
|
+
lazily started monitor (apps without lifespan, e.g. tests using
|
|
101
|
+
`httpx.ASGITransport`) stops when the last in-flight request finishes.
|
|
102
|
+
- `__version__` now matches the installed package metadata (it was hard-coded
|
|
103
|
+
to 0.3.0 while the package shipped as 0.4.1), and the 503 error page links
|
|
104
|
+
to the real repository.
|
|
@@ -22,7 +22,7 @@ ruff format --check src/ tests/ # CI verifies formatting; drop --check
|
|
|
22
22
|
coverage run -m pytest tests/ && coverage report --fail-under=80 # the CI gate
|
|
23
23
|
|
|
24
24
|
pip install -e ".[stress]" # everything under examples/ needs uvicorn + locust
|
|
25
|
-
python examples/demo_app.py # demo on :8765 — /api/users returns 503 (
|
|
25
|
+
python examples/demo_app.py # demo on :8765 — /api/users returns 503 (strict mode)
|
|
26
26
|
python examples/stress_app.py # stress target on :8000
|
|
27
27
|
python examples/run_stress_test.py --skip-locust # validation suite against a running :8000
|
|
28
28
|
```
|
|
@@ -62,11 +62,11 @@ src/fastapi_loopguard/
|
|
|
62
62
|
1. **Pure ASGI — never `BaseHTTPMiddleware`.** It is deprecated, breaks contextvars, and leaks memory. `__call__` dispatches on `scope["type"]`; WebSocket and every other type pass through untouched, with no context registered.
|
|
63
63
|
2. **Blocking attributes to ALL active requests.** The sentinel measures loop lag, not call stacks, so it cannot know which request blocked. `_handle_blocking` iterates every context from `get_active_requests()` and records the same lag on each. This over-reports under concurrency **by design** — narrowing it is a redesign, not a bug fix.
|
|
64
64
|
3. **The registry needs no locks.** asyncio is single-threaded, so `RequestRegistry` is a plain dict keyed by `request_id`. Never add a lock, a `threading` primitive, or thread-safety without first changing that premise and saying so.
|
|
65
|
-
4. **Calibration never blocks the first request, and can only tighten.** `start_with_background_calibration()` starts `_monitor_loop` immediately on `fallback_threshold_ms` and calibrates in a named background task. Baseline is the **minimum** of `calibration_iterations` samples (the idle floor — robust to contamination from live traffic), and the calibrated threshold is clamped to `[monitor_interval_ms, fallback_threshold_ms]`: it may lower the fallback, never raise it. The adaptive window is censored (only sub-threshold samples admitted)
|
|
65
|
+
4. **Calibration never blocks the first request, and can only tighten.** `start_with_background_calibration()` starts `_monitor_loop` immediately on `fallback_threshold_ms` and calibrates in a named background task. Baseline is the **minimum** of `calibration_iterations` samples (the idle floor — robust to contamination from live traffic), and the calibrated threshold is clamped to `[monitor_interval_ms, fallback_threshold_ms]`: it may lower the fallback, never raise it. The adaptive window is censored (only sub-threshold samples admitted), its floor follows the calibrated threshold, and its recalculated value is clamped to the `fallback_threshold_ms` ceiling — without the ceiling each raise widens the censor gate and the threshold ratchets upward unboundedly. So neither calibration nor adaptation can be poisoned upward by the app's own blocking or its noise. A failed or cancelled calibration keeps the fallback threshold and must never raise into startup. Config guarantees `fallback_threshold_ms >= monitor_interval_ms`, so the clamp can never invert.
|
|
66
66
|
5. **Unregister always runs.** `_handle_http` wraps dispatch in `try/finally: unregister_request(request_id)`. An exception from the wrapped app must never leak a context into the registry — a leak makes every later blocking event attribute to a dead request forever.
|
|
67
67
|
6. **`dev_mode` is headers-only; a 503 requires explicit strict mode.** `_get_effective_enforcement_mode()` always returns `enforcement_mode` — `dev_mode` never changes it. Because blocking is attributed to ALL in-flight requests (invariant 2), a status change punishes innocent bystanders, so it must stay opt-in via `enforcement_mode="strict"`. No text anywhere may claim the library identifies WHICH endpoint blocked — it narrows it to the requests in flight during the stall.
|
|
68
|
-
7. **One blocking event is reported exactly once.** A sample that fires the single-shot detection is never appended to `_lag_history`, within one `_monitor_loop` iteration a single-lag trigger suppresses the cumulative one, and `_lag_history` is cleared after a cumulative fire so a window reports at most once. Cumulative window sums are recorded in `RequestContext.cumulative_events`, separate from the individual-lag `blocking_events` list; `blocking_count` / `total_blocking_ms` sum both.
|
|
69
|
-
8. **Lifecycle calls are idempotent, and lazy monitors stop when idle.** `start()`, `start_with_background_calibration()`, and `stop()` all return early when already in the target state. `_handle_http` lazily starts the monitor for apps that run without ASGI lifespan and stops it when the last in-flight request unregisters (so `httpx.ASGITransport` tests leak no tasks); lifespan-managed monitors persist between requests. Double-start must stay a no-op. Consequence: in lazy mode background calibration rarely completes and the fallback threshold governs.
|
|
68
|
+
7. **One blocking event is reported exactly once.** A sample that fires the single-shot detection is never appended to `_lag_history`, within one `_monitor_loop` iteration a single-lag trigger suppresses the cumulative one, and `_lag_history` is cleared after a cumulative fire so a window reports at most once. The window stores **baseline-corrected excess lag** (`max(0, lag - baseline)`), never raw lag — raw per-tick timer jitter alone would sum past the default cumulative threshold on an idle loop. Cumulative window sums are recorded in `RequestContext.cumulative_events`, separate from the individual-lag `blocking_events` list; `blocking_count` / `total_blocking_ms` sum both. One event also emits exactly **one log line** (a summary across in-flight requests), never one line per context — no O(N) work on the loop thread right after a stall.
|
|
69
|
+
8. **Lifecycle calls are idempotent, race-free, and cancellation-safe; lazy monitors stop when idle.** `start()`, `start_with_background_calibration()`, and `stop()` all return early when already in the target state. `start()` marks `_running` **before** its blocking calibration so a concurrent `stop()` vetoes the loop start. `_stop_monitor` clears `_started`/`_lazy_started`/`_monitor` **before** awaiting the monitor's stop — that await yields, and a request arriving mid-stop must see `_started=False` and start a fresh monitor rather than run unmonitored. `stop()` awaits cancelled tasks via `_cancel_and_wait`, which re-raises when the *calling* task has a pending cancellation (it runs inside request `finally` blocks; a bare `suppress(CancelledError)` would make requests ignore their own cancellation). A lifespan app that raises gets the monitor stopped in the `except` path, not leaked. `_handle_http` lazily starts the monitor for apps that run without ASGI lifespan and stops it when the last in-flight request unregisters (so `httpx.ASGITransport` tests leak no tasks); lifespan-managed monitors persist between requests. Double-start must stay a no-op. Consequence: in lazy mode background calibration rarely completes and the fallback threshold governs.
|
|
70
70
|
|
|
71
71
|
## Conventions
|
|
72
72
|
|
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
# Findings not fixed in the 0.5
|
|
1
|
+
# Findings not fixed in the 0.5/0.6 passes
|
|
2
2
|
|
|
3
|
-
Everything noticed while fixing the 0.5 correctness defects
|
|
4
|
-
left alone. Each item is its own future task.
|
|
3
|
+
Everything noticed while fixing the 0.5 correctness defects (and the 0.6
|
|
4
|
+
hardening pass) but deliberately left alone. Each item is its own future task.
|
|
5
5
|
|
|
6
6
|
## Detection design
|
|
7
7
|
|
|
8
|
-
1.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
median+MAD over the censored window would be more robust than a percentile.
|
|
8
|
+
1. ~~**Sub-threshold noise can still inflate the adaptive threshold.**~~
|
|
9
|
+
**Fixed in the 0.6 pass:** the adaptive threshold is now clamped to the
|
|
10
|
+
`fallback_threshold_ms` ceiling, closing both the one-step inflation and
|
|
11
|
+
the compounding ratchet (each raise widened the censor gate). Median+MAD
|
|
12
|
+
over the censored window remains a possible refinement below the ceiling.
|
|
14
13
|
|
|
15
14
|
2. **Strict mode cannot fail a response after `http.response.start` has passed.**
|
|
16
15
|
If blocking is first detected mid-stream, the 200 and its headers are
|
|
17
16
|
already on the wire and the body keeps streaming; the response then claims
|
|
18
|
-
no blocking. Inherent to header-based reporting
|
|
17
|
+
no blocking. Inherent to header-based reporting. Documented by test
|
|
18
|
+
(`test_enforcement_mode.py::TestStrictModeStreaming`). Related accepted
|
|
19
|
+
behavior, also documented by test: if the app raises after strict mode
|
|
20
|
+
swallowed its `http.response.start`, the exception wins — the client gets
|
|
21
|
+
the server's 500, not LoopGuard's 503.
|
|
19
22
|
|
|
20
23
|
3. **Explicit strict mode still 503s all in-flight requests.**
|
|
21
24
|
The sentinel cannot name the culprit, so strict mode punishes bystanders
|
|
@@ -59,11 +62,12 @@ left alone. Each item is its own future task.
|
|
|
59
62
|
11. **`docs/CONFIGURATION.md` names metrics that do not exist** (known gap #2)
|
|
60
63
|
and `get_metrics()` can never find an instance (known gap #3).
|
|
61
64
|
|
|
62
|
-
12.
|
|
63
|
-
calibration is unreliable"
|
|
64
|
-
the
|
|
65
|
+
12. ~~**`docs/CONFIGURATION.md` describes `fallback_threshold_ms` as "Used if
|
|
66
|
+
calibration is unreliable"**~~ — fixed in the 0.6 pass; the docs now
|
|
67
|
+
describe it as the hard ceiling.
|
|
65
68
|
|
|
66
|
-
13.
|
|
69
|
+
13. ~~**`_handle_lifespan` locals `started` / `shutdown_complete` are
|
|
70
|
+
write-only.**~~ — removed in the 0.6 pass.
|
|
67
71
|
|
|
68
72
|
14. **The `structlog` extra is declared but nothing imports structlog**
|
|
69
73
|
(already noted in CLAUDE.md).
|
|
@@ -75,6 +79,23 @@ left alone. Each item is its own future task.
|
|
|
75
79
|
old `max(..., fallback)` floor masked the bug. The fix removes the
|
|
76
80
|
mechanism either way, but tests that "prove" poisoning need dense blocking.
|
|
77
81
|
|
|
82
|
+
## Accepted in the 0.6 hardening pass (documented, not fixed)
|
|
83
|
+
|
|
84
|
+
16. **Installing the middleware twice injects duplicate `x-*` headers.**
|
|
85
|
+
Nothing marks a scope as already instrumented; two instances register two
|
|
86
|
+
contexts per request and both send wrappers extend the headers. Low
|
|
87
|
+
priority — a doubled middleware is a user configuration error.
|
|
88
|
+
|
|
89
|
+
17. **`Accept` negotiation is a bare substring test with last-header-wins.**
|
|
90
|
+
Duplicate `Accept` headers collapse via `dict()`, and q-values are
|
|
91
|
+
ignored, so `text/html;q=0.1, application/json;q=0.9` still gets the HTML
|
|
92
|
+
page. Cosmetic: only chooses the error-page format.
|
|
93
|
+
|
|
94
|
+
18. **8-hex-char request ids (32 bits) can collide silently.** A collision
|
|
95
|
+
overwrites the registry entry and the first unregister removes the second
|
|
96
|
+
request's context. Birthday bound is ~77k concurrent in-flight requests;
|
|
97
|
+
accepted as unrealistic, noted here so it is a known trade.
|
|
98
|
+
|
|
78
99
|
## Fixed since the first draft
|
|
79
100
|
|
|
80
101
|
- Adaptive mode discarding the calibrated threshold (floor pinned at the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-loopguard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Detect event-loop blocking in FastAPI/Starlette with per-request attribution
|
|
5
5
|
Project-URL: Homepage, https://github.com/parhamdavari/fastapi-loopguard
|
|
6
6
|
Project-URL: Documentation, https://github.com/parhamdavari/fastapi-loopguard#readme
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
22
22
|
Classifier: Topic :: System :: Monitoring
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.12
|
|
25
|
-
Requires-Dist: starlette<
|
|
25
|
+
Requires-Dist: starlette<2.0,>=0.37.0
|
|
26
26
|
Provides-Extra: all
|
|
27
27
|
Requires-Dist: prometheus-client>=0.19.0; extra == 'all'
|
|
28
28
|
Requires-Dist: structlog>=24.1.0; extra == 'all'
|
|
@@ -31,6 +31,7 @@ Requires-Dist: coverage>=7.4.0; extra == 'dev'
|
|
|
31
31
|
Requires-Dist: fastapi>=0.110.0; extra == 'dev'
|
|
32
32
|
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
33
33
|
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: prometheus-client>=0.19.0; extra == 'dev'
|
|
34
35
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
35
36
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
36
37
|
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
@@ -57,6 +58,10 @@ Description-Content-Type: text/markdown
|
|
|
57
58
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
58
59
|
</p>
|
|
59
60
|
|
|
61
|
+
<p align="center">
|
|
62
|
+
<img src="assets/demo.gif" alt="LoopGuard demo: a fast endpoint passes, a blocking endpoint fails with a 503, and the console banner explains why" width="800" />
|
|
63
|
+
</p>
|
|
64
|
+
|
|
60
65
|
---
|
|
61
66
|
|
|
62
67
|
When something blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and narrows it down to the requests that were in flight when the loop stalled**. The sentinel measures loop lag, so it cannot name the single guilty handler — it reports every request that was active during the stall.
|
|
@@ -106,7 +111,7 @@ app.add_middleware(LoopGuardMiddleware, config=config)
|
|
|
106
111
|
Returns an educational 503 page that explains what went wrong and how to fix it:
|
|
107
112
|
|
|
108
113
|
<p align="center">
|
|
109
|
-
<img src="assets/error-page
|
|
114
|
+
<img src="assets/error-page.gif" alt="Strict mode error page" width="600" />
|
|
110
115
|
</p>
|
|
111
116
|
|
|
112
117
|
---
|
|
@@ -129,6 +134,21 @@ Writes structured logs listing the requests that were in flight:
|
|
|
129
134
|
|
|
130
135
|
---
|
|
131
136
|
|
|
137
|
+
## Testing AI-Generated Code
|
|
138
|
+
|
|
139
|
+
AI agents write plausible async code that quietly blocks the event loop. The bundled pytest plugin turns that into a red test and a machine-readable report the agent can fix from — no per-test annotations:
|
|
140
|
+
|
|
141
|
+
```ini
|
|
142
|
+
# pytest.ini
|
|
143
|
+
[pytest]
|
|
144
|
+
loopguard_all_async = true # every async test fails on blocking
|
|
145
|
+
loopguard_report = loopguard.json # verdicts + fix hints for the agent
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
See [docs/AI-HARNESS.md](docs/AI-HARNESS.md) for the report schema, the `no_blocking` / `allow_blocking` markers, and a drop-in snippet for your project's agent instructions.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
132
152
|
<p align="center">
|
|
133
153
|
<a href="docs/CONFIGURATION.md"><strong>Full Configuration Reference</strong></a>
|
|
134
154
|
</p>
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="assets/demo.gif" alt="LoopGuard demo: a fast endpoint passes, a blocking endpoint fails with a 503, and the console banner explains why" width="800" />
|
|
17
|
+
</p>
|
|
18
|
+
|
|
15
19
|
---
|
|
16
20
|
|
|
17
21
|
When something blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and narrows it down to the requests that were in flight when the loop stalled**. The sentinel measures loop lag, so it cannot name the single guilty handler — it reports every request that was active during the stall.
|
|
@@ -61,7 +65,7 @@ app.add_middleware(LoopGuardMiddleware, config=config)
|
|
|
61
65
|
Returns an educational 503 page that explains what went wrong and how to fix it:
|
|
62
66
|
|
|
63
67
|
<p align="center">
|
|
64
|
-
<img src="assets/error-page
|
|
68
|
+
<img src="assets/error-page.gif" alt="Strict mode error page" width="600" />
|
|
65
69
|
</p>
|
|
66
70
|
|
|
67
71
|
---
|
|
@@ -84,6 +88,21 @@ Writes structured logs listing the requests that were in flight:
|
|
|
84
88
|
|
|
85
89
|
---
|
|
86
90
|
|
|
91
|
+
## Testing AI-Generated Code
|
|
92
|
+
|
|
93
|
+
AI agents write plausible async code that quietly blocks the event loop. The bundled pytest plugin turns that into a red test and a machine-readable report the agent can fix from — no per-test annotations:
|
|
94
|
+
|
|
95
|
+
```ini
|
|
96
|
+
# pytest.ini
|
|
97
|
+
[pytest]
|
|
98
|
+
loopguard_all_async = true # every async test fails on blocking
|
|
99
|
+
loopguard_report = loopguard.json # verdicts + fix hints for the agent
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
See [docs/AI-HARNESS.md](docs/AI-HARNESS.md) for the report schema, the `no_blocking` / `allow_blocking` markers, and a drop-in snippet for your project's agent instructions.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
87
106
|
<p align="center">
|
|
88
107
|
<a href="docs/CONFIGURATION.md"><strong>Full Configuration Reference</strong></a>
|
|
89
108
|
</p>
|
|
Binary file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# VHS tape for assets/demo.gif — reproduce with:
|
|
2
|
+
# brew install vhs
|
|
3
|
+
# pip install -e ".[stress]" (needs uvicorn; run from the repo root)
|
|
4
|
+
# vhs assets/demo.tape
|
|
5
|
+
#
|
|
6
|
+
# Story: pain -> catch -> install.
|
|
7
|
+
# 1. baseline: the async app answers in ~0.1s
|
|
8
|
+
# 2. one request runs blocking code -> EVERY request freezes with it
|
|
9
|
+
# (the hang is real: /api/items stalls ~2s on screen, then the
|
|
10
|
+
# blocking request comes back 503)
|
|
11
|
+
# 3. the LoopGuard console banner explains the fix + install CTA
|
|
12
|
+
|
|
13
|
+
Output assets/demo.gif
|
|
14
|
+
|
|
15
|
+
Set Shell bash
|
|
16
|
+
Set FontSize 14
|
|
17
|
+
Set Width 800
|
|
18
|
+
Set Height 540
|
|
19
|
+
Set Padding 12
|
|
20
|
+
Set TypingSpeed 20ms
|
|
21
|
+
Set Theme "Catppuccin Mocha"
|
|
22
|
+
Set WindowBar Colorful
|
|
23
|
+
Set BorderRadius 8
|
|
24
|
+
|
|
25
|
+
Hide
|
|
26
|
+
Type "source .venv/bin/activate && python examples/demo_app.py >/tmp/loopguard-demo.log 2>&1 & sleep 2; TIMEFORMAT=' took %Rs'; clear"
|
|
27
|
+
Enter
|
|
28
|
+
Sleep 2.5s
|
|
29
|
+
Show
|
|
30
|
+
|
|
31
|
+
Type "# a healthy async app"
|
|
32
|
+
Enter
|
|
33
|
+
Type "time curl -s localhost:8765/api/items"
|
|
34
|
+
Enter
|
|
35
|
+
Sleep 2s
|
|
36
|
+
|
|
37
|
+
Ctrl+L
|
|
38
|
+
Type "# one request runs blocking code - every request freezes with it"
|
|
39
|
+
Enter
|
|
40
|
+
Type "(curl -so /dev/null -w 'culprit -> %{http_code}\n' localhost:8765/api/users &)"
|
|
41
|
+
Enter
|
|
42
|
+
Type "time curl -s localhost:8765/api/items"
|
|
43
|
+
Enter
|
|
44
|
+
Sleep 4.5s
|
|
45
|
+
|
|
46
|
+
Ctrl+L
|
|
47
|
+
Type "# the server console explains what happened"
|
|
48
|
+
Enter
|
|
49
|
+
Type "grep -B 2 -A 18 'LOOPGUARD' /tmp/loopguard-demo.log"
|
|
50
|
+
Enter
|
|
51
|
+
Sleep 2.5s
|
|
52
|
+
Type "# pip install fastapi-loopguard -> app.add_middleware(LoopGuardMiddleware)"
|
|
53
|
+
Enter
|
|
54
|
+
Sleep 2.5s
|
|
55
|
+
|
|
56
|
+
Hide
|
|
57
|
+
Type "kill %1"
|
|
58
|
+
Enter
|
|
59
|
+
Sleep 500ms
|
|
Binary file
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# LoopGuard as a Test Harness for AI-Generated Code
|
|
2
|
+
|
|
3
|
+
AI coding agents write plausible async code that quietly blocks the event
|
|
4
|
+
loop — `requests` instead of `httpx`, `open().read()` in a handler, a sync
|
|
5
|
+
SDK call inside `async def`. The type checker passes, the tests pass, and
|
|
6
|
+
the app freezes under load.
|
|
7
|
+
|
|
8
|
+
LoopGuard's pytest plugin turns that failure mode into a red test with a
|
|
9
|
+
machine-readable explanation the agent can fix from — no per-test
|
|
10
|
+
annotations required.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```ini
|
|
15
|
+
# pytest.ini (or [tool.pytest.ini_options] in pyproject.toml)
|
|
16
|
+
[pytest]
|
|
17
|
+
asyncio_mode = auto
|
|
18
|
+
loopguard_all_async = true # every async test is a blocking gate
|
|
19
|
+
loopguard_report = loopguard.json # verdicts for the agent to read
|
|
20
|
+
loopguard_threshold_ms = 50
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run the suite as usual:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pytest
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Any async test whose execution blocks the event loop past the threshold
|
|
30
|
+
fails, and `loopguard.json` records what happened. Both settings also
|
|
31
|
+
exist as CLI flags: `--loopguard-all-async`, `--loopguard-report=PATH`.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
| Option | Where | Default | Meaning |
|
|
36
|
+
|--------|-------|---------|---------|
|
|
37
|
+
| `loopguard_threshold_ms` | ini | `50` | Lag beyond this fails the test |
|
|
38
|
+
| `loopguard_all_async` | ini / `--loopguard-all-async` | off | Treat every async test as `@pytest.mark.no_blocking` |
|
|
39
|
+
| `loopguard_report` | ini / `--loopguard-report=PATH` | off | Write the JSON verdict file |
|
|
40
|
+
| `@pytest.mark.no_blocking` | marker | — | Gate one test explicitly (works without all-async mode) |
|
|
41
|
+
| `@pytest.mark.allow_blocking` | marker | — | Exempt one test from all-async mode |
|
|
42
|
+
|
|
43
|
+
Exit semantics are plain pytest: flagged tests fail, so any CI that runs
|
|
44
|
+
pytest is already enforcing the gate.
|
|
45
|
+
|
|
46
|
+
## The report
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"schema_version": 1,
|
|
51
|
+
"threshold_ms": 50.0,
|
|
52
|
+
"totals": {"tests": 42, "flagged": 1},
|
|
53
|
+
"tests": [
|
|
54
|
+
{
|
|
55
|
+
"nodeid": "tests/test_api.py::test_upload",
|
|
56
|
+
"verdict": "blocked",
|
|
57
|
+
"events": [{"lag_ms": 180.24, "threshold_ms": 50.0}],
|
|
58
|
+
"hints": [
|
|
59
|
+
"time.sleep(n) -> await asyncio.sleep(n)",
|
|
60
|
+
"requests.get(url) -> await httpx.AsyncClient().get(url)",
|
|
61
|
+
"open(f).read() -> await aiofiles.open(f)",
|
|
62
|
+
"subprocess.run(...) -> await asyncio.create_subprocess_exec(...)",
|
|
63
|
+
"CPU-bound work -> await asyncio.to_thread(func)"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"nodeid": "tests/test_api.py::test_list",
|
|
68
|
+
"verdict": "clean",
|
|
69
|
+
"events": [],
|
|
70
|
+
"hints": []
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only instrumented tests appear (`totals.tests` counts them). A `blocked`
|
|
77
|
+
verdict means the loop lagged past the threshold while that test ran; the
|
|
78
|
+
sentinel measures lag, not call stacks, so the culprit is in the code that
|
|
79
|
+
test executed — usually the endpoint it called.
|
|
80
|
+
|
|
81
|
+
## Interpreting the strict 503 (runtime harness)
|
|
82
|
+
|
|
83
|
+
For integration tests that drive a live app, run the middleware with
|
|
84
|
+
`LoopGuardConfig(enforcement_mode="strict")`: blocking requests fail with
|
|
85
|
+
a 503 whose JSON body carries the same shape of diagnosis
|
|
86
|
+
(`error: "event_loop_blocked"`, blocking count and total ms, and the same
|
|
87
|
+
fix suggestions under `help.common_causes`).
|
|
88
|
+
|
|
89
|
+
## Drop-in snippet for a consumer project's CLAUDE.md / agents.md
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
## Async discipline (enforced)
|
|
93
|
+
|
|
94
|
+
This project gates async code with fastapi-loopguard. `pytest` fails any
|
|
95
|
+
async test that blocks the event loop for >50ms and writes verdicts to
|
|
96
|
+
`loopguard.json`.
|
|
97
|
+
|
|
98
|
+
When a test fails with "Event loop blocking detected":
|
|
99
|
+
1. Read `loopguard.json`; find the `blocked` entry for that test.
|
|
100
|
+
2. The blocking call is in the code path that test exercises. Replace
|
|
101
|
+
sync calls with the async equivalents listed under `hints`.
|
|
102
|
+
3. Never widen `loopguard_threshold_ms` or add `allow_blocking` to make
|
|
103
|
+
a test pass — fix the blocking call instead.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Scoring models instead of guarding CI
|
|
107
|
+
|
|
108
|
+
The same gate scores whether a model writes non-blocking async code: run
|
|
109
|
+
each generated solution against a functional test file plus the plugin,
|
|
110
|
+
and read `totals.flagged` from the report. A ready-made task set lives in
|
|
111
|
+
`evals/` at the repository root.
|
|
@@ -52,7 +52,9 @@ config = LoopGuardConfig(
|
|
|
52
52
|
| `monitor_interval_ms` | float | `10.0` | Sentinel check frequency (ms) |
|
|
53
53
|
| `threshold_multiplier` | float | `5.0` | Blocking detected when lag > baseline × this |
|
|
54
54
|
| `calibration_iterations` | int | `100` | Samples during startup calibration |
|
|
55
|
-
| `fallback_threshold_ms` | float | `50.0` |
|
|
55
|
+
| `fallback_threshold_ms` | float | `50.0` | Threshold before/without calibration, and the hard ceiling a calibrated or adaptive threshold can never exceed. Must be ≥ `monitor_interval_ms` (lag below the sampling interval cannot be resolved). |
|
|
56
|
+
|
|
57
|
+
**Validation:** `exclude_paths` must be a collection of paths — a bare string is rejected (it would silently become a substring match).
|
|
56
58
|
|
|
57
59
|
---
|
|
58
60
|
|
|
@@ -68,6 +70,8 @@ Catches "death by a thousand cuts" - many small blocks that add up.
|
|
|
68
70
|
|
|
69
71
|
**Example:** With defaults, alerts if blocking totals >200ms within any 1-second window.
|
|
70
72
|
|
|
73
|
+
Only lag **in excess of the calibrated baseline** counts toward the window sum, so platform timer jitter on an idle loop cannot accumulate into a false positive.
|
|
74
|
+
|
|
71
75
|
---
|
|
72
76
|
|
|
73
77
|
## Adaptive Threshold
|
|
@@ -80,7 +84,9 @@ Dynamically adjusts threshold based on observed latency. Useful for high-concurr
|
|
|
80
84
|
| `adaptive_window_size` | int | `1000` | Samples in sliding window |
|
|
81
85
|
| `adaptive_percentile` | float | `0.95` | Percentile for baseline (0.5-0.99) |
|
|
82
86
|
| `adaptive_min_samples` | int | `100` | Min samples before activation |
|
|
83
|
-
| `adaptive_update_interval_ms` | float | `1000.0` | Recalculation frequency (ms) |
|
|
87
|
+
| `adaptive_update_interval_ms` | float | `1000.0` | Recalculation frequency (ms). Must be ≥ `monitor_interval_ms`. |
|
|
88
|
+
|
|
89
|
+
The adaptive threshold is clamped to `[calibrated threshold, fallback_threshold_ms]`: adaptation may tighten detection but can never raise the threshold above the fallback, so a noisy loop cannot ratchet the detector blind.
|
|
84
90
|
|
|
85
91
|
---
|
|
86
92
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# LoopGuard Evals
|
|
2
|
+
|
|
3
|
+
A small benchmark that scores whether an AI model writes **non-blocking**
|
|
4
|
+
async FastAPI code. Each task ships a prompt, a provided `helpers.py`, and
|
|
5
|
+
functional checks; LoopGuard's pytest plugin is the judge.
|
|
6
|
+
|
|
7
|
+
The trap is always the same shape: `helpers.py` offers a slow synchronous
|
|
8
|
+
function (and sometimes an async variant). A correct solution keeps the
|
|
9
|
+
event loop free — by using the async variant or wrapping the sync one in
|
|
10
|
+
`asyncio.to_thread` — while a naive solution calls the sync helper directly
|
|
11
|
+
and blocks the loop for 100–200 ms, which the plugin flags deterministically
|
|
12
|
+
against the 50 ms threshold.
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
evals/
|
|
18
|
+
runner.py score one solution against one task
|
|
19
|
+
tasks/<name>/
|
|
20
|
+
task.md the prompt to give the model
|
|
21
|
+
app_skeleton.py starting file the model completes
|
|
22
|
+
helpers.py provided API (contains the trap)
|
|
23
|
+
checks.py functional pytest checks
|
|
24
|
+
reference/clean.py known-good solution (ground truth)
|
|
25
|
+
reference/blocking.py known-blocking solution (ground truth)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`reference/` is ground truth for validating the harness — exclude it from
|
|
29
|
+
any context you give the model under test.
|
|
30
|
+
|
|
31
|
+
## Running
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e ".[dev]" # from the repository root
|
|
35
|
+
|
|
36
|
+
python evals/runner.py \
|
|
37
|
+
--task evals/tasks/01-user-lookup \
|
|
38
|
+
--solution path/to/model_output.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Output (also written with `--out scores.json`):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"task": "01-user-lookup",
|
|
46
|
+
"functional": true,
|
|
47
|
+
"non_blocking": false,
|
|
48
|
+
"score": 0,
|
|
49
|
+
"flagged": ["test_checks.py::test_returns_user"],
|
|
50
|
+
"detail": "1 test(s) blocked the event loop"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`score` is 1 only when every check passes **and** nothing blocked the loop.
|
|
55
|
+
To benchmark a model, generate one solution per task from `task.md` (plus
|
|
56
|
+
`app_skeleton.py` and `helpers.py` as context) and sum the scores.
|
|
57
|
+
|
|
58
|
+
## Scoring semantics
|
|
59
|
+
|
|
60
|
+
| exit | blocked verdicts | functional | non_blocking | score |
|
|
61
|
+
|------|------------------|------------|--------------|-------|
|
|
62
|
+
| 0 | none | true | true | 1 |
|
|
63
|
+
| ≠0 | ≥1 | unknown | false | 0 |
|
|
64
|
+
| ≠0 | none | false | true | 0 |
|