Skip to content

mutate_only_covered_lines crashes on C-extension deps (numpy, libyaml): cannot load module more than once per process #528

Closed

Description

@ejfine

Summary

When mutate_only_covered_lines = true, the coverage-gathering phase unloads
every module imported during its test run (_unload_modules_not_in), including
third-party C extensions. The subsequent stats run then re-imports them in the same
process. C extensions that forbid re-initialization crash:

  • numpy → ImportError: cannot load module more than once per process
  • PyYAML C loader (_yaml, used by VCR/vcrpy cassettes) → corrupted parse:
    yaml.constructor.ConstructorError: could not determine a constructor for the tag None

This is not test-specific. The crash depends only on when the C extension first
loads, not on who imports it. It is triggered by any single-init C extension that is
first imported during the coverage run (after mutmut's sys.modules snapshot) and then
re-imported during the stats run — whether that import originates in a test module or in
the source code under mutation. A source_paths module that imports numpy crashes the
same way as a test that imports it.

Setting mutate_only_covered_lines = false avoids it entirely (no coverage pre-run →
the deps import exactly once in the stats run; per-mutant runs os.fork() and inherit
via copy-on-write), which is what points at the unload as the cause.

Environment

  • mutmut 3.6.0
  • Python 3.13.9 (Linux)
  • pytest 9.x, numpy, vcrpy + PyYAML built with libyaml

Reproduction

pyproject.toml:

[tool.mutmut]
source_paths = ["src/mypkg/"]
pytest_add_cli_args_test_selection = ["tests/unit/"]
mutate_only_covered_lines = true

A single unit test importing numpy is enough:

  from numpy.testing import assert_almost_equal

  def test_close():
      assert_almost_equal(1.0, 1.0)

Equivalently, importing numpy from a src/mypkg/ module under mutation (with any test
that exercises it) reproduces the identical crash — confirming the issue is not confined
to the test suite.

mutmut run fails in the stats phase:
ImportError: cannot load module more than once per process
(For libyaml, any vcrpy cassette test produces the tag None ConstructorError instead.)

Root cause

mutmut/code_coverage.py gather_coverage():
modules = dict(sys.modules) # snapshot before the coverage run
cov = coverage.Coverage(data_file=None)
runner.collect_main_test_coverage(cov) # pytest #1 -> imports numpy, _yaml, ...
...
_unload_modules_not_in(modules) # pops EVERYTHING imported during the run
_unload_modules_not_in removes any sys.modules entry not present in the
pre-snapshot — i.e. the entire delta, not just the modules under mutation. The stats
run (run_stats, also in the parent process) then re-imports those C extensions, which
fail or corrupt on second init. (The snapshot is taken before pytest #1, so any
dependency first imported during the run — by a test, a conftest, or a source module —
is in the delta and gets unloaded.)

The unload is intended so the mutated source modules reload fresh in later phases.
It does not need to reload stdlib/third-party deps — and must not, for C extensions
that single-init-guard.

Suggested fix

Scope the unload to modules under source_paths (the only ones that get mutated),
leaving everything else loaded. Roughly:

  def _unload_modules_not_in(modules, *, mutation_paths):
      for name in list(sys.modules):
          if name == "mutmut.code_coverage":
              continue
          if name in modules:
              continue
          mod = sys.modules.get(name)
          file = getattr(mod, "__file__", None)
          if file and _is_under(file, mutation_paths):  # only reload what we mutate
              sys.modules.pop(name, None)
      importlib.invalidate_caches()

This keeps the fresh-reload behavior for the code being mutated while never touching
numpy/libyaml/other C extensions, fixing the crash without disabling
mutate_only_covered_lines.

Activity

  1. Otto-AA commented on Jun 14, 2026

    @Otto-AA
    Collaborator

    Hi, thanks for raising and explaining the issue!

    Scope the unload to modules under source_paths (the only ones that get mutated),
    leaving everything else loaded.

    I like the idea, but I think we would get a problem if the top-level code of a source module changes some (global) state in a 3rd party library.

    Some examples: (I only verified the logging example, SQLAlchemy output by the AI but makes sense at least conceptually)

    Logging has a global logger. Following code would register the logging handler twice:

    # src/main.py
    import logging
    logging.getLogger().addHandler(logging.StreamHandler())
    
    logging.getLogger().setLevel(logging.DEBUG)
    logging.info("Hello")

    Libraries sometimes provide global event listening. Here's a (not double checked example by the AI) of SQLAlchemy:

    # src/main.py
    from sqlalchemy import event
    @event.listens_for(Engine, "before_cursor_execute")
    def log_query(query): print(query)

    So in general, when we load the 3rd party library only once but the source code twice, we may register functions too often. We would need to reset the 3rd party library as well (which is what we currently do).

    I guess a proper fix would be to refactor the coverage to run in a separate process, such that the modules are not shared imported into the main mutmut process. Then we do not need to unload them, which makes our life easier in this regard. But this likely needs some more communication between the coverage utility and the main mutmut process. I did not look much into this yet, so maybe I'm missing something, but I think isolation would be the direction we need to go to, to prevent further issues.

  2. rileyjmurray commented on Jul 21, 2026

    @rileyjmurray

    Chiming in to say I've also run into this issue. My codebases (pyGSTi and LoQS) are numpy-heavy and have expensive test suites, so the mutate_only_covered_lines = true path being broken makes mutmut non-viable for my projects.

    Mutmut was the first project recommended to me by Gemini and Sonnet 5 when I searched for "mutation testing Python," so I suspect there are plenty of would-be users like me.

  3. chwiese commented on Aug 16, 2026

    @chwiese

    Another affected C extension: cryptography. Same trigger (mutate_only_covered_lines = true → _unload_modules_not_in() evicts cryptography.* → stats pass re-imports), but it fails silently rather than with an ImportError.

    Re-importing the pure-Python layer creates a new EllipticCurve ABC, while _rust.abi3.so is not re-created (generate_private_key is the same object across the re-import) and its type check still rejects instances of the new class:

    TypeError: curve must be an EllipticCurve instance
    

    The traceback points at the user's own correct code, so this one is hard to trace back to module eviction.

    Spelling out the symptoms below so searches land on this issue rather than dead-ending:

    • Surfaces as failed to collect stats. runner returned 1 during the stats phase (or Failed to run clean test).
    • The test passes under plain pytest and fails only under mutmut.
    • Applies to any pyo3 / Rust extension whose Python-level types are bound at first init, not just cryptography.
    • Workaround until fixed: mutate_only_covered_lines = false, or --ignore the affected test file via pytest_add_cli_args_test_selection.

    Minimal reproduction — no mutmut, no pytest

    import sys
    from cryptography.hazmat.primitives.asymmetric import ec
    
    # Pass 1 (coverage run): works
    ec.generate_private_key(ec.SECP256R1())
    
    # What _unload_modules_not_in() does
    for name in [m for m in sys.modules if m.startswith("cryptography")]:
        sys.modules.pop(name, None)
    
    # Pass 2 (stats run): same process, re-imported
    from cryptography.hazmat.primitives.asymmetric import ec as ec2
    
    curve = ec2.SECP256R1()                       # current, freshly imported class
    assert isinstance(curve, ec2.EllipticCurve)   # passes
    ec2.generate_private_key(curve)               # TypeError

    The isinstance assert passing immediately before the call fails is what makes the error misleading — the object is a valid EllipticCurve by the current class.

    Environment

    mutmut 3.7.0, cryptography 50.0.0, pytest 9.1.1, coverage 7.15.4, Python 3.14.4, macOS 26.5.2 (arm64)

  4. MaxWolf-01 commented on Sep 9, 2026

    @MaxWolf-01

    Disclaimer: AI-drafted, verified by me before filing. I answer follow-ups.

    Still reproduces on 3.7.0 and main (7f8ba00). Two additions:

    It can segfault instead of raising. One source file, one test that does await asyncpg.connect(...), mutate_only_covered_lines = true: mutmut run dies with SIGSEGV in asyncpg/connect_utils.py during the stats phase, exit 139, no message naming the eviction. Twenty lines without mutmut reproduce it: snapshot sys.modules, import asyncpg and connect, evict what the snapshot lacks, re-import, connect again.

    The scoped unload suggested above trades the crash for a registry error. Evicting only project modules on a FastAPI/SQLModel/asyncpg app (7k statements) clears the segfault and then fails with Table 'x' is already defined for this MetaData instance: the MetaData lives in a third-party module the scoped unload keeps, and the re-imported model registers twice. Five lines (class Thing(SQLModel, table=True)) plus the evict-and-reimport script show it.

    What works: collect coverage in a separate pytest --cov process and hand mutmut the data file, so no pre-pass and no eviction happen in its process.

  5. nicklafleur commented on Sep 12, 2026

    @nicklafleur
    Collaborator

    fixed in #566

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions