Repository navigation
Description
Describe the bug
With the sysmon core and branch measurement, take an async for loop that ends because the async iterator raises an exception, not StopAsyncIteration. The statement after the loop is reported as executed, along with the arc from the loop to it, although it never runs. With COVERAGE_CORE=ctrace, and with sysmon without --branch, the line is correctly reported as missing. The equivalent synchronous for loop is measured correctly under sysmon with --branch.
Versions
- Python 3.14.5 (
Python 3.14.5 (main, May 10 2026, 19:20:57) [Clang 22.1.3 ]) - coverage 7.16.1 with C extension (fresh venv, nothing else installed)
- macOS 26.6.2, arm64
To Reproduce
target.py:
EXECUTED = []
async def numbers():
yield 1
raise ValueError("done")
async def consume():
async for n in numbers():
pass
EXECUTED.append("after loop") # never runs: the loop only ends by raisingdriver.py:
import asyncio
import target
try:
asyncio.run(target.consume())
except ValueError:
pass
print("EXECUTED =", target.EXECUTED)Commands:
$ COVERAGE_CORE=sysmon coverage run --branch driver.py && coverage report -m --include=target.py
$ COVERAGE_CORE=ctrace coverage run --branch driver.py && coverage report -m --include=target.pyActual (sysmon, --branch). EXECUTED is empty, so line 12 never ran, yet it is counted as covered:
EXECUTED = []
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------
target.py 8 0 2 0 100%
-------------------------------------------------------
TOTAL 8 0 2 0 100%
coverage json for this run: executed_lines [1, 4, 5, 6, 9, 10, 11, 12], executed_branches [[10, 11], [10, 12]].
Expected (and what ctrace gives):
EXECUTED = []
Name Stmts Miss Branch BrPart Cover Missing
-------------------------------------------------------
target.py 8 1 2 1 80% 12
-------------------------------------------------------
TOTAL 8 1 2 1 80%
coverage json: executed_lines [1, 4, 5, 6, 9, 10, 11], missing_branches [[10, 12]].
Notes:
- sysmon without
--branchis correct:target.py 8 1 88% 12. - The same over-report happens in the shape where I first hit it:
try: async for attempt in <retrying async iterator>: with attempt: ... return .../except ...:followed by an unreachable statement. There the iterator yields exception-swallowing context managers and re-raises from__anext__when attempts run out, as tenacity'sAsyncRetrying(reraise=True)does. The minimal form above needs neither thetry/exceptnor thewith. - Possibly the origin is in CPython: a bare
sys.monitoringtool with LINE, BRANCH_LEFT and BRANCH_RIGHT enabled onconsume.__code__receivesBRANCH_RIGHTfrom line 10 to an offset on line 12 when__anext__raises, and no LINE event for line 12. - Possibly related, but a different construct: try-else + with +
branch = truewith sysmon results in false negative #2289 (try/else +with, sysmon + branch false positive). For that example the same baresys.monitoringprobe receives no event for the unreachable line.
Impact: under the default 3.14 core with branch coverage, an unreachable line after such a loop (often a deliberate raise AssertionError("unreachable") tripwire) and its arc are over-reported as covered.
Activity
shashb27 commentedon Oct 1, 2026
Reproduced on Windows (Python 3.14.3, coverage 7.16.2): sysmon + branch reports the after-loop statement as executed (100%) while
EXECUTED = []; ctrace correctly reports it missing.The root cause is that CPython 3.14 emits the same loop-exit branch event (
BRANCH_RIGHTfromCLEANUP_THROWto the after-loop offset) whether theasync forends viaStopAsyncIterationor via a propagating exception. Your probe note in the report pointed exactly at this. On the exception path the after-loop line never runs (noLINEevent — the frame unwinds), but the branch arc gets credited unconditionally, which marks the never-run line as executed. The equivalent synchronousforraises without emitting any branch event, which is why it measures correctly.I've opened #2319 with a fix: branch arcs to not-yet-executed destination lines are deferred and only recorded if the destination line actually runs — genuine branches always execute their destination, so the only arcs discarded are exception-path artifacts. Your repro now reports the after-loop line as missing, matching ctrace. Full probe output and verification details in the PR. Thanks for the clear report!
KRRT7 commentedon Nov 13, 2026
The extra branch comes from CPython.
INSTRUMENTED_END_ASYNC_FORinPython/bytecodes.cemitsBRANCH_RIGHTbefore checking forStopAsyncIteration, so the loop-exit branch is reported even when another exception ends the loop. It's the same on main. I've reported it as python/cpython#161928.Even if CPython fixes it, already-released 3.14 and 3.15 versions will still have it, so a workaround like #2304 or #2319 would still be needed for those, perhaps only on those versions.