Repository navigation
Description
Describe the bug
A parallel data file (data_suffix=True) gets the hash of its data added to its file name at its first save. Later saves add more data to the same file but keep the name. coverage combine treats files with the same hash in their names as duplicates, so it combines one and skips the other, and the skipped file is deleted along with the rest of the input files.
So if two processes had the same data at their first save and then recorded different lines, one process's later data is lost. The only sign is Combined 1 file, skipped 1.
This started with 160ad3b ("perf: data files record their hash in the file name"), released in 7.13.1. It needs a process that calls save() more than once, which the API allows (tests/test_api.py::test_start_save_nostop saves while still collecting). I found it reading the code, not from a failing CI job.
To Reproduce
- Python 3.12.14 (macOS 15.7.3, arm64).
- coverage 7.16.2 from PyPI.
coverage debug sys(trimmed):coverage_version: 7.16.2,CTracer: available,core: -none-,implementation: CPython,platform: macOS-15.7.3-arm64-arm-64bit. pip freeze:coverage==7.16.2, nothing else.- The code is the five files below; no repo needed.
- Commands, in an empty directory:
python3.12 -m venv .venv
.venv/bin/pip install coverage==7.16.2
printf 'X = 1\nY = 2\n' > startup.py
printf 'A1 = 1\nA2 = 2\nA3 = 3\n' > work_a.py
printf 'B1 = 1\nB2 = 2\nB3 = 3\n' > work_b.py
cat > worker.py <<'PY'
import sys
import coverage
cov = coverage.Coverage(data_suffix=True, source=["."])
cov.start()
import startup # identical in every worker
cov.save() # a save while still collecting
if sys.argv[1] == "a":
import work_a
else:
import work_b
cov.stop()
cov.save()
PY
.venv/bin/python worker.py a
.venv/bin/python worker.py b
ls -1a | grep '^\.coverage'
.venv/bin/python -m coverage combine
.venv/bin/python -m coverage report --show-missing --include='startup.py,work_*.py'
Output (host name replaced):
.coverage.<host>.pid87319.XjSDTmxx.HxIZbDTxUHJh
.coverage.<host>.pid87320.XmsUrL6x.HxIZbDTxUHJh
Combined 1 file, skipped 1
Name Stmts Miss Cover Missing
------------------------------------------
startup.py 2 0 100%
work_a.py 3 0 100%
work_b.py 3 3 0% 1-3
------------------------------------------
TOTAL 8 3 62%
Both data files carry the hash from their first save (.HxIZbDTxUHJh), so the second is skipped and removed, and work_b.py shows 0% although it ran.
Expected behavior
Both files are combined and work_b.py is 100%. That is what 7.13.0 does with the same commands:
Combined data file .coverage.<host>.87374.XHPzJFwx
Combined data file .coverage.<host>.87375.XVYUSVex
...
work_b.py 3 0 100%
TOTAL 8 0 100%
Additional context
I have a small fix ready if you'd like a PR: when a parallel data file is saved again with new data, rename it to the new hash (and update the stored hash), so combine compares current data. The first save, saves with no new data, true duplicates and the forked-child path stay as they are. It comes with tests that fail on main and pass with the change. Happy to adjust or drop it if you'd rather handle this differently.
Activity
nedbat commentedon Oct 21, 2026
Thanks for the report. A fix would be appreciated.
DSeaStar commentedon Oct 21, 2026
I reproduced this independently on Windows 11 / CPython 3.14.3 with coverage 7.16.2 — same result (
Combined 1 file, skipped 1,work_b.py0%), so it isn't macOS/3.12 specific.The root cause is a bit narrower than "the hash goes stale":
CoverageData.write()finalizes the hash only once, guarded byself._wrote_hash(sqldata.py:911-924). After the first save that flag is true, so every laterwrite()is a no-op and the file name keeps the first hash while the file keeps gaining data.combinethen dedupes purely on the hash parsed out of the file name (data.py:99-108+DataFileClassifier), never on contents.Your fix is the right one. Two details I hit while validating it against 7.16.2:
filename_matchisre.search(SUFFIX_PATTERN + "$")with a single optional hash group (sqldata.py:1189-1199), so...Haaaah.Hbbbbhmatches nothing and silently falls back to hashing the whole file. Strip the old.H<10>hfirst.self._filename != new_name(instead of on_wrote_hash) makes "no new data" a no-op for free, so true duplicates still dedupe.I patched 7.16.2 that way and got: your repro →
Combined 2 files,work_b.py100%; two identicalaruns → stillCombined 1 file, skipped 1; single-save runs unchanged. Happy to leave the PR to you — just wanted to confirm it and flag the double-suffix trap.nedbat commentedon Oct 21, 2026
Fixed in commit 6033660
nedbat commentedon Oct 22, 2026
This is now released as part of coverage 7.17.0.