Skip to content

Combining parallel data files loses a process's data when it saved more than once #2338

Closed

Description

@ericdelorefice

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

  1. Python 3.12.14 (macOS 15.7.3, arm64).
  2. 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.
  3. pip freeze: coverage==7.16.2, nothing else.
  4. The code is the five files below; no repo needed.
  5. 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

  1. nedbat commented on Oct 21, 2026

    @nedbat
    Member

    Thanks for the report. A fix would be appreciated.

  2. DSeaStar commented on Oct 21, 2026

    @DSeaStar
    Contributor

    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.py 0%), 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 by self._wrote_hash (sqldata.py:911-924). After the first save that flag is true, so every later write() is a no-op and the file name keeps the first hash while the file keeps gaining data. combine then 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:

    1. Don't just append the new hash — filename_match is re.search(SUFFIX_PATTERN + "$") with a single optional hash group (sqldata.py:1189-1199), so ...Haaaah.Hbbbbh matches nothing and silently falls back to hashing the whole file. Strip the old .H<10>h first.
    2. Gating the rename on 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.py 100%; two identical a runs → still Combined 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.

  3. nedbat commented on Oct 21, 2026

    @nedbat
    Member

    Fixed in commit 6033660

  4. nedbat commented on Oct 22, 2026

    @nedbat
    Member

    This is now released as part of coverage 7.17.0.

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

    bugSomething isn't workingfixed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions