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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions