Skip to content

Memory leak in no_disk mode: repeated erase()/start()/stop() leaks in-memory SQLite connections #2138

@cyberthirst

Description

@cyberthirst

Describe the bug

When using Coverage(data_file=None) (no-disk mode) with repeated erase()/start()/stop()/get_data() cycles, each cycle retains an additional in-memory SQLite connection. RSS grows linearly and unboundedly (~130 KB per cycle with branch tracing).

The root cause is that CoverageData._reset() skips close() when no_disk=True, and SqliteDb.close() is a no-op for no-disk connections unless force=True. Each erase() cycle creates a new CoverageData via _init_data(), appends it to Coverage._data_to_close, and leaves prior no-disk connections open until _atexit calls close(force=True). In long-running processes, this behaves like a leak.

To Reproduce

  1. Python 3.13.7
  2. coverage.py 7.13.4
  3. No other packages needed — standalone reproducer.
  4. No repo needed — self-contained script below.
  5. Steps:
pip install coverage==7.13.4
python repro.py

repro.py:

import coverage, gc, os

def rss_mb():
    return int(open("/proc/self/statm").read().split()[1]) * os.sysconf("SC_PAGE_SIZE") / 1024 / 1024

cov = coverage.Coverage(branch=True, data_file=None, config_file=False)

# Warm up
cov.start()
_ = sum(range(100))
cov.stop()
cov.get_data()

gc.collect()
rss0 = rss_mb()

for i in range(300):
    cov.erase()
    cov.start()
    _ = sum(range(100))
    cov.stop()
    cov.get_data()

gc.collect()
rss1 = rss_mb()
print(f"RSS growth after 300 erase/start/stop/get_data cycles: {rss1 - rss0:.1f} MB")

# Confirming the fix: manually closing the SQLite connection before erase()
cov2 = coverage.Coverage(branch=True, data_file=None, config_file=False)
cov2.start()
_ = sum(range(100))
cov2.stop()
cov2.get_data()

gc.collect()
rss0 = rss_mb()

for i in range(300):
    if cov2._data is not None:
        cov2._data.close(force=True)  # <-- this prevents the leak
    cov2.erase()
    cov2.start()
    _ = sum(range(100))
    cov2.stop()
    cov2.get_data()

gc.collect()
rss2 = rss_mb()
print(f"RSS growth with close(force=True) fix: {rss2 - rss0:.1f} MB")

Expected output:

RSS growth after 300 erase/start/stop/get_data cycles: ~40.0 MB
RSS growth with close(force=True) fix: ~0.2 MB

Expected behavior

erase() should fully release the old in-memory SQLite connection so that repeated erase/start/stop cycles do not grow memory.

Additional context

Three things combine to cause the leak-like memory growth:

  1. CoverageData._reset() (sqldata.py) skips close() when no_disk=True:

    def _reset(self) -> None:
        if not self._no_disk:
            self.close()        # skipped for no_disk
  2. SqliteDb.close() (sqlitedb.py) is a no-op for no-disk connections unless force=True:

    def close(self, force=False) -> None:
        if self.con is not None:
            if force or not self.no_disk:  # skips close for no_disk
  3. Coverage._init_data() appends each new CoverageData to self._data_to_close, and those objects are only force-closed in _atexit.

The chain of events per cycle:

  1. cov.erase() calls CoverageData._reset() which skips close() (no_disk), then sets self._data = None
  2. cov.erase() sets self._inited_for_start = False
  3. cov.start() sees _inited_for_start == False, calls _init_for_start()
  4. _init_for_start() calls _init_data() which creates a new CoverageData with a new in-memory SQLite connection and appends it to self._data_to_close
  5. The old CoverageData instances remain reachable via self._data_to_close, but their no-disk SQLite connections stay open until process shutdown (_atexit)

The simplest fix would be for _reset() to call self.close(force=True) unconditionally, since there's no reason to keep an in-memory SQLite connection alive after a reset.

Our use case is a fuzzer that reuses a single Coverage instance across thousands of iterations in no-disk mode to collect branch/arc coverage.

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