Skip to content

sysmon + branch: statement after an async for whose iterator raises is reported as executed #2303

Description

@SimplicityGuy

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 raising

driver.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.py

Actual (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 --branch is 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's AsyncRetrying(reraise=True) does. The minimal form above needs neither the try/except nor the with.
  • Possibly the origin is in CPython: a bare sys.monitoring tool with LINE, BRANCH_LEFT and BRANCH_RIGHT enabled on consume.__code__ receives BRANCH_RIGHT from 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 = true with sysmon results in false negative #2289 (try/else + with, sysmon + branch false positive). For that example the same bare sys.monitoring probe 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions