-
-
Notifications
You must be signed in to change notification settings - Fork 477
Description
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
- Python 3.13.7
- coverage.py 7.13.4
- No other packages needed — standalone reproducer.
- No repo needed — self-contained script below.
- Steps:
pip install coverage==7.13.4
python repro.pyrepro.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:
-
CoverageData._reset()(sqldata.py) skipsclose()whenno_disk=True:def _reset(self) -> None: if not self._no_disk: self.close() # skipped for no_disk
-
SqliteDb.close()(sqlitedb.py) is a no-op for no-disk connections unlessforce=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
-
Coverage._init_data()appends each newCoverageDatatoself._data_to_close, and those objects are only force-closed in_atexit.
The chain of events per cycle:
cov.erase()callsCoverageData._reset()which skipsclose()(no_disk), then setsself._data = Nonecov.erase()setsself._inited_for_start = Falsecov.start()sees_inited_for_start == False, calls_init_for_start()_init_for_start()calls_init_data()which creates a newCoverageDatawith a new in-memory SQLite connection and appends it toself._data_to_close- The old
CoverageDatainstances remain reachable viaself._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.