Skip to content

Commit 166f649

Browse files
committed
ci: run strict-no-cover in scripts/test to catch stale pragmas locally
PR #2302 passed ./scripts/test (100% coverage) but failed CI when strict-no-cover found lines marked '# pragma: no cover' that the new test actually executed. The claim in CLAUDE.md that ./scripts/test 'matches CI exactly' was false. Adds strict-no-cover as a final step in scripts/test. The tool internally spawns 'uv run coverage json' without --frozen, which rewrites uv.lock on machines with registry overrides; UV_FROZEN=1 propagates to that subprocess. Also converts session.py:426 from 'no cover' to 'lax no cover'. The except-handler during connection cleanup is a genuine race (stream may or may not already be closed depending on timing), so it is nondeterministically covered. strict-no-cover would have flagged it intermittently on high-core machines. CLAUDE.md now documents the fast targeted path (single test file + strict-no-cover, ~4s, no false positives on partial runs), the three pragma types, and the known coverage.py ->exit arc quirk with nested async-with that only the CI matrix catches.
1 parent 75a80b6 commit 166f649

File tree

3 files changed

+26
-4
lines changed

3 files changed

+26
-4
lines changed

CLAUDE.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,37 @@ This document contains critical information about working with this codebase. Fo
2929
- IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns.
3030
- IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible.
3131
- Coverage: CI requires 100% (`fail_under = 100`, `branch = true`).
32-
- Full check: `./scripts/test` (~20s, matches CI exactly)
33-
- Targeted check while iterating:
32+
- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the
33+
default Python. Not identical to CI: CI also runs 3.10–3.14 × {ubuntu, windows},
34+
and some branch-coverage quirks only surface on specific matrix entries.
35+
- Targeted check while iterating (~4s, deterministic):
3436

3537
```bash
3638
uv run --frozen coverage erase
3739
uv run --frozen coverage run -m pytest tests/path/test_foo.py
3840
uv run --frozen coverage combine
3941
uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0
42+
UV_FROZEN=1 uv run --frozen strict-no-cover
4043
```
4144

4245
Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0`
43-
and `--include` scope the report to what you actually changed.
46+
and `--include` scope the report. `strict-no-cover` has no false positives on
47+
partial runs — if your new test executes a line marked `# pragma: no cover`,
48+
even a single-file run catches it.
49+
- `strict-no-cover` can occasionally flag subprocess-runner functions in `tests/`
50+
on high-core machines (`-n auto` → racy subprocess coverage). If the flagged
51+
lines are inside a `tests/…/run_*server*()` body and unrelated to your change,
52+
re-run or convert that pragma to `lax no cover`. Flags in `src/` are real.
53+
- Coverage pragmas:
54+
- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` fails if
55+
it IS executed. When your test starts covering such a line, remove the pragma.
56+
- `# pragma: lax no cover` — excluded from coverage but not checked by
57+
`strict-no-cover`. Use for lines covered on some platforms/versions but not
58+
others, or nondeterministically (races, subprocess coverage).
59+
- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the
60+
`->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows).
61+
There is no local detector; when CI reports `X->exit` missing on a test line,
62+
add this pragma to line X.
4463
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
4564
- Use `anyio.Event`set it in the callback/handler, `await event.wait()` in the test
4665
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`

scripts/test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ uv run --frozen coverage erase
66
uv run --frozen coverage run -m pytest -n auto $@
77
uv run --frozen coverage combine
88
uv run --frozen coverage report
9+
# strict-no-cover spawns `uv run coverage json` internally without --frozen;
10+
# UV_FROZEN=1 propagates to that subprocess so it doesn't touch uv.lock.
11+
UV_FROZEN=1 uv run --frozen strict-no-cover

src/mcp/shared/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ async def _receive_loop(self) -> None:
423423
try:
424424
await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error))
425425
await stream.aclose()
426-
except Exception: # pragma: no cover
426+
except Exception: # pragma: lax no cover
427427
# Stream might already be closed
428428
pass
429429
self._response_streams.clear()

0 commit comments

Comments
 (0)