Conversation
5652a65 to
5167afe
Compare
|
Not a maintainer, but I hit #2247 independently and can confirm this is the right fix. I built a deterministic reproducer (no reliance on timing luck) and ran it against this branch. It installs a meta-path finder for Against current With this PR's patch applied: The detail I find most convincing is in between those two lines: with the patch, the victim thread never reaches Why this is better than the fix I tried. My own attempt was to have On the private API, since that is the obvious objection to this approach.
It could still disappear in some future CPython, so the Two smaller points:
|
|
@DSeaStar you've been very active in this repo, thanks! Do you use Discord? I'd like to chat with you in the #coveragepy channel if that works for you. |
|
You have correctly identified that my concern about this is the private import lock. |
|
Claude had some ideas for other ways to solve the problem: https://claude.ai/share/4812a770-3d00-44c2-a8a6-d0a81a99c7e3 |
|
@nedbat Thanks — I don't use Discord, but I'm happy to keep this here (or by email if you'd rather take it off-thread). I can't open the claude.ai share from where I am — the domain redirects me to an "unavailable in region" page — so I can't see the alternatives Claude came up with. If you paste the shortlist inline I'll work through it properly; I'd rather not guess at what's in there and re-propose something already rejected. Two things to add to what I wrote above, one of which I think changes the calculus. 1. Correction: 2. There is a way to fix the reported bug with no lock and no private API. The load-bearing fact is that
So at the class SysModuleSaver:
def __init__(self, expected: Iterable[str] = ()) -> None:
self.expected = set(expected)
self.old_modules = set(sys.modules)
def restore(self) -> None:
for m in set(sys.modules) - self.old_modules:
if m in self.expected:
del sys.modules[m]with The consequence I would dwell on: for a dot-free Residual: if another thread genuinely imports one of those ancestor packages concurrently, we could still delete it. That is a much narrower window than "any module in the process", and it is the one case where the lock would still buy something. So the choice as I see it is: this PR's lock (closes the race outright; costs a private API and serialises imports at two call sites) versus narrowing (no private API, no lock, fixes the reported symptom, leaves the ancestor-package case open). If you prefer the lock I think the patch here is correct and my only real objection is the maintenance risk. Happy to write up either one — say which. |
|
It's getting to be hard to follow all of the threads here. I want the reproducer in #2247 turned into a test in the test suite, with enough explanation that it can be understood. It's using exotic import tools, so some commentary would be good. Then we can discuss the best solution. Claude suggested using audit hooks to track exactly what modules we were importing:
Since the hook fires globally for all threads, filter by thread identity so you only see imports your call caused, not a concurrent one: import sys
import threading
import contextlib
_local = threading.local()
def _import_audit_hook(event, args):
if event == "import" and getattr(_local, "stack", None):
_local.stack[-1].append(args[0]) # args[0] is the module name
sys.addaudithook(_import_audit_hook) # install once, at module load
@contextlib.contextmanager
def sys_modules_saved():
if not hasattr(_local, "stack"):
_local.stack = []
imported = []
_local.stack.append(imported)
try:
yield
finally:
_local.stack.pop()
for name in imported:
sys.modules.pop(name, None)This is fully public API, and it sidesteps the lock entirely: you're not diffing shared state or racing another thread's import — you only ever remove names that the audit hook attributed to your own thread during your own with block. Caveats:
|
|
I worked with Claude to turn #2247's reproducer into a test that could live in the test suite: def test_2247(self) -> None:
# Coverage.py sometimes imports modules for its own purposes, and
# afterward removes anything new from sys.modules so the import leaves
# no trace. But sys.modules is process-wide: a module that another
# thread imported during that window is removed too, and that thread's
# import then fails.
#
# The window is normally microseconds wide. Three Events hold it open
# and force one exact interleaving of the two threads:
#
# 1. main enters coverage's window, sets `window`, and waits.
# 2. the victim thread wakes and imports victim_slow. importlib
# registers the module in sys.modules and calls exec_module,
# which sets `in_exec` and waits.
# 3. main wakes and leaves the window, so coverage deletes
# victim_slow.
# 4. main sets `release`. The victim wakes, exec_module returns, and
# importlib looks victim_slow back up in sys.modules -> KeyError.
#
# `release` is what makes the failure deterministic: it holds the
# victim inside exec_module until the delete has already happened.
window, in_exec, release = (threading.Event() for _ in range(3))
victim_exc: BaseException | None = None
orig = coverage.inorout.file_and_path_for_module
def hooked(name: str) -> tuple[str | None, list[str]]:
# Coverage calls this from inside the window, so this is step 1.
window.set()
in_exec.wait(10)
return orig(name)
# The loader and finder import a fake module named "victim_slow",
# standing in for any real import slow enough to overlap coverage's
# window. The loader stalls in exec_module to make it slow;
# create_module returns None so importlib builds the module itself.
class VictimLoader(importlib.abc.Loader):
"""Loads victim_slow, slowly."""
def create_module(
self, spec_unused: importlib.machinery.ModuleSpec
) -> ModuleType | None:
"""Let importlib make the module object itself."""
return None
def exec_module(self, module_unused: ModuleType) -> None:
"""Stall, so the import overlaps coverage's window."""
# Step 2: importlib has already put the module in sys.modules.
in_exec.set()
release.wait(10)
class VictimFinder(importlib.abc.MetaPathFinder):
"""Finds victim_slow, and nothing else."""
def find_spec(
self,
fullname: str,
path_unused: Sequence[str] | None = None,
target_unused: ModuleType | None = None,
) -> importlib.machinery.ModuleSpec | None:
"""Claim victim_slow, and nothing else."""
if fullname == "victim_slow":
return importlib.util.spec_from_loader(fullname, VictimLoader())
return None
def victim_import() -> None:
nonlocal victim_exc
window.wait(10)
try:
import victim_slow # pylint: disable=import-error, unused-import
except Exception as exc:
# The threading machinery would only print this to stderr,
# failing nothing, so hand it back to the main thread instead.
victim_exc = exc
finder = VictimFinder()
old_path = list(sys.path)
victim = threading.Thread(target=victim_import, daemon=True)
coverage.inorout.file_and_path_for_module = hooked # type: ignore[assignment]
sys.meta_path.insert(0, finder)
try:
victim.start()
# The package doesn't have to exist: the damage is done by the
# window, not by what coverage imports inside it.
cov = coverage.Coverage(source_pkgs=["some_pkg_that_does_not_exist"])
try:
# Opening the window, with step 3 happening as it closes.
cov.start()
cov.stop()
# Changing sys.path makes coverage open the window again while
# measuring, so this isn't only a start-up hazard.
sys.path.append("/nonexistent")
assert cov._inorout is not None
cov._inorout.should_trace("/some/module.py", None)
finally:
cov.stop()
in_modules = "victim_slow" in sys.modules
finally:
release.set() # step 4
victim.join(10)
coverage.inorout.file_and_path_for_module = orig
sys.meta_path.remove(finder)
sys.path[:] = old_path
sys.modules.pop("victim_slow", None)
# Both of these fail today. The KeyError is what users actually
# report; the missing sys.modules entry is the cause, checked
# separately so a partial fix can't quietly pass.
assert victim_exc is None, f"victim thread's import raised {victim_exc!r}"
assert in_modules, "coverage deleted the victim thread's module" |
|
I ran the test you posted, so this isn't a "looks right to me". Three findings. 1. It is a valid regression testOn unpatched With #2250's 2. The 10-second waits are dead weight, and steps 2-4 describe the unfixed behaviour onlyI logged every opening of the window. Unpatched: The victim reaches With #2250: The first window waited the entire timeout and That is the fix working. But it also means that with any lock-based fix no interleaving ever occurs, so Dropping the three waits to 1 s / 5 s / 5 s brings it to 1.4 s under the patch, and it still fails on unpatched 3. On the audit-hook alternativeI was suspicious of it, so I checked the part that worried me. The risk would be that
So recorded names are always freshly-loaded modules, and the thread-local filter correctly ignores the victim thread (confirmed: events raised on another thread see an empty stack). The design is sound, and it also confirms what I found earlier — Cost is not a problem either. One audited event goes from 33 ns to 94 ns on 3.13 here (~60 ns per event), and importing The thing that would decide it for me is PEP 578's API Availability section:
The failure mode is silent: on an implementation where the hook is a no-op, If you'd rather have the public API, that risk is cheap to close with a one-time self-test at install, falling back to the lock: probe = "<any module not yet in sys.modules>"
sys.modules.pop(probe, None)
recorded = []
_local.stack.append(recorded)
try:
__import__(probe)
finally:
_local.stack.pop()
hook_works = bool(recorded)I verified this on CPython 3.13: a fresh import is recorded, and a cached re-import records nothing — so it is not a false positive. My own read: the lock is three lines, already green on every interpreter your CI covers including free-threading, and fails loudly if the private API ever disappears. The hook is public API but needs the self-test above before I'd trust it on PyPy. Either way, the test is the piece worth landing first — it fails for the right reason and doesn't care which fix you choose. |
| # Importing installs a module in sys.modules before executing it. Hold the | ||
| # import lock while saving and restoring so that we can't delete a module | ||
| # another thread is still importing. | ||
| with importlib._bootstrap._ImportLockContext(): # type: ignore[attr-defined] |
There was a problem hiding this comment.
This is going to hold the import lock for the entire duration of the sys_modules_saved context. That seems longer than we want, no?
|
It depends on the call site, and there are two:
#2247 only needs the first. So I'd take the lock out of (The lock is re-entrant per thread, so nested imports on the same thread are unaffected.) I can't push to this branch — say the word and I'll paste the diff. |
|
The new test has two problems in CI, both fixable. 1. 2. The test is flaky — it failed on the 3.12 and 3.15 macOS jobs with: That combination doesn't describe the bug. I instrumented the interleaving and ran it locally (Python 3.14, Windows). With the lock in place the victim can never get inside the window, so The victim reaches The fix is to take the reading after the victim has definitely finished: finally:
release.set()
victim.join(10)
in_modules = "victim_slow" in sys.modules
coverage.inorout.file_and_path_for_module = orig
sys.meta_path.remove(finder)
sys.path[:] = old_path
sys.modules.pop("victim_slow", None)Both directions then hold deterministically: unpatched I can't push to this branch — say the word and I'll paste the full diff. |
|
So it isn't a coin flip. Under PyTracer the victim thread is slow enough that it has not imported The read is the problem, not the fix: finally:
cov.stop()
- in_modules = "victim_slow" in sys.modules
finally:
release.set()
victim.join(10)
+ in_modules = "victim_slow" in sys.modulesReading it after Two smaller things while you're in there:
|
|
That job passed on the previous head |
Summary
Prevent
sys_modules_saved()from removing a module while another thread is still importing it. The context now holds Python's import lock through the save/restore window, with a regression test covering the interleaving.Fixes #2247
Verification
main; it fails before the fix and printsno failurewith the branch.uv run --with-editable . --with-requirements requirements/pytest.pip --with-requirements requirements/pip.pip pytest tests/test_misc.py -quv run --with ruff ruff format --check coverage/misc.py tests/test_misc.pygit diff --check