Skip to content

Commit 6348764

Browse files
committed
fix(db): stop closing a read connection out from under a live query
Found by smoke-testing 2.1.4 and reading the log - which is only possible now that #263 stopped one call producing 98.5% of it. Closing the Monitor logged: WidgetState.get_hardware_history - Cannot operate on a closed database sqlite3.ProgrammingError: Cannot operate on a closed database Read connections are per-thread, and stale ones were evicted by checking the owning thread id against threading.enumerate(). Qt worker threads never appear there: enumerate() only lists threads the threading module knows about, and a QThread that calls threading.get_ident() - which is all ours do - is never registered. So the Monitor's graph worker was permanently "dead" by that test, and the next _get_read_conn() from any other thread closed its connection while it was still querying. The obvious fix is wrong, and I verified rather than assumed: calling threading.current_thread() does register the thread, but on CPython 3.11 the resulting _DummyThread never leaves enumerate() when the native thread dies - so nothing would ever be pruned and the connection leak that pruning exists to prevent comes straight back. Pruning is now by idle time. A thread mid-query has just fetched its connection, so it cannot trip a threshold; a finished worker ages out and is reclaimed as before. The threshold is deliberately generous - it only has to exceed a live worker's refresh interval, and erring high costs one idle file handle while erring low reintroduces the bug. The existing leak test is kept and joined by the race it could not see. Verified by reverting: the race test fails on the old logic.
1 parent a64faaf commit 6348764

2 files changed

Lines changed: 92 additions & 19 deletions

File tree

src/netspeedtray/core/widget_state.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def __init__(self, config: Dict[str, Any], read_only: bool = False) -> None:
120120
self.maintenance_timer.start(60 * 60 * 1000) # Run maintenance every hour
121121
self.trigger_maintenance()
122122

123-
self._read_conns: Dict[int, sqlite3.Connection] = {}
123+
self._read_conns: Dict[int, Tuple[sqlite3.Connection, float]] = {} # tid -> (conn, last_used)
124124
self._read_conns_lock = threading.Lock()
125125

126126
# Data-usage odometer (data-cap feature). Lazily loaded from the DB on first
@@ -135,21 +135,44 @@ def __init__(self, config: Dict[str, Any], read_only: bool = False) -> None:
135135
self.logger.debug("WidgetState initialized with threaded database worker.")
136136

137137

138+
# How long a read connection may sit untouched before another thread may close it. Generous on
139+
# purpose: it only has to exceed the interval at which a live worker re-fetches its connection
140+
# (the Monitor refreshes every few seconds), and erring high costs one idle file handle while
141+
# erring low would close a connection out from under a slow query.
142+
_READ_CONN_IDLE_SEC: float = 120.0
143+
138144
def _get_read_conn(self) -> sqlite3.Connection:
139145
"""Returns a thread-local read connection, pruning ones left behind by dead threads.
140146
141147
Each worker thread (notably the Monitor's GraphDataWorker, recreated on every Monitor open) opens
142148
its own read connection here, removed otherwise only in cleanup() at exit. Without pruning, every
143149
Monitor open/close leaked a connection (a file handle + a WAL reader slot) for the whole session,
144150
and a recycled thread id could even hand a new worker a dead thread's stale connection. So evict
145-
entries whose owning thread has exited (safe to close cross-thread: check_same_thread=False)."""
151+
entries whose owning thread has stopped using them.
152+
153+
**Pruning is by IDLE TIME, not by thread liveness, and that is not a stylistic choice.** This
154+
used to evict any connection whose thread id was missing from ``threading.enumerate()`` - but
155+
Qt worker threads (``QThread``) never appear there unless they happen to call
156+
``threading.current_thread()``, and ours only call ``get_ident()``. So every Monitor graph
157+
worker was invisible, and the next caller from any other thread closed its connection **while
158+
it was still querying** - "Cannot operate on a closed database", caught and logged, once per
159+
Monitor session.
160+
161+
Registering the thread instead (calling ``current_thread()``) is worse: on CPython 3.11 the
162+
resulting ``_DummyThread`` never leaves ``enumerate()`` when the native thread dies, so
163+
nothing would ever be pruned and the leak this exists to prevent comes straight back
164+
(verified, not assumed).
165+
166+
A thread mid-query has just fetched its connection, so an idle threshold it cannot trip is
167+
both simpler and correct. Closing cross-thread stays safe: check_same_thread=False."""
146168
thread_id = threading.get_ident()
169+
now = time.monotonic()
147170
with self._read_conns_lock:
148171
if self._read_conns:
149-
live = {t.ident for t in threading.enumerate()}
150-
for dead in [tid for tid in self._read_conns if tid not in live and tid != thread_id]:
172+
for stale in [tid for tid, (_c, seen) in self._read_conns.items()
173+
if tid != thread_id and (now - seen) > self._READ_CONN_IDLE_SEC]:
151174
try:
152-
self._read_conns.pop(dead).close()
175+
self._read_conns.pop(stale)[0].close()
153176
except Exception:
154177
pass
155178
if thread_id not in self._read_conns:
@@ -164,8 +187,11 @@ def _get_read_conn(self) -> sqlite3.Connection:
164187
conn.execute("PRAGMA cache_size = -8000;")
165188
conn.execute("PRAGMA temp_store = MEMORY;")
166189
conn.execute("PRAGMA mmap_size = 268435456;")
167-
self._read_conns[thread_id] = conn
168-
return self._read_conns[thread_id]
190+
self._read_conns[thread_id] = (conn, now)
191+
return conn
192+
conn, _seen = self._read_conns[thread_id]
193+
self._read_conns[thread_id] = (conn, now) # touch: this thread is demonstrably alive
194+
return conn
169195

170196
def add_speed_data(self, speed_data: Dict[str, Tuple[float, float]], now: Optional[datetime] = None, aggregated_up: Optional[float] = None, aggregated_down: Optional[float] = None) -> None:
171197
"""Adds new per-interface speed data."""
@@ -923,7 +949,7 @@ def cleanup(self) -> None:
923949

924950
# Close persistent read connections
925951
with self._read_conns_lock:
926-
for tid, conn in self._read_conns.items():
952+
for tid, (conn, _seen) in self._read_conns.items():
927953
try:
928954
conn.close()
929955
except:

src/netspeedtray/tests/unit/test_reliability_fixes.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import sqlite3
1010
import threading
11+
import time
1112
from pathlib import Path
1213
from unittest.mock import patch
1314

@@ -39,26 +40,72 @@ def test_update_config_flags_instead_of_closing_pdh_handles(q_app):
3940

4041
# --- M2: read-connection leak ------------------------------------------------
4142

42-
def test_get_read_conn_prunes_dead_thread_connections(q_app, tmp_path: Path):
43+
def _make_state(tmp_path):
4344
cfg = dict(constants.config.defaults.DEFAULT_CONFIG)
44-
with patch.object(QThread, "start", lambda self: None), \
45-
patch("netspeedtray.core.widget_state.get_app_data_path", return_value=tmp_path):
45+
with patch.object(QThread, "start", lambda self: None), patch("netspeedtray.core.widget_state.get_app_data_path", return_value=tmp_path):
4646
ws = WidgetState(cfg)
4747
ws._db_path = tmp_path / "speed_history.db"
4848
ws.db_worker.db_path = ws._db_path
4949
ws.db_worker._initialize_connection()
5050
ws.db_worker._check_and_create_schema()
51+
return ws
5152

52-
# Inject a connection as if a now-dead worker thread had opened it (a fake, non-live thread id).
53-
dead_id = max(t.ident for t in threading.enumerate()) + 999_999
54-
real = sqlite3.connect(":memory:")
55-
ws._read_conns[dead_id] = real
5653

57-
ws._get_read_conn() # any call prunes dead-thread entries
54+
def test_get_read_conn_reclaims_idle_connections(q_app, tmp_path: Path):
55+
"""M2: a connection left behind by a finished worker must not leak for the whole session."""
56+
ws = _make_state(tmp_path)
57+
stale_id = max(t.ident for t in threading.enumerate()) + 999_999
58+
leaked = sqlite3.connect(":memory:")
59+
# Last used well beyond the idle threshold - i.e. nobody is querying on it.
60+
ws._read_conns[stale_id] = (leaked, time.monotonic() - (ws._READ_CONN_IDLE_SEC + 60))
5861

59-
assert dead_id not in ws._read_conns, "stale dead-thread connection was not evicted"
60-
with pytest.raises(sqlite3.ProgrammingError): # the leaked connection was closed
61-
real.execute("SELECT 1")
62+
ws._get_read_conn()
63+
64+
assert stale_id not in ws._read_conns, "the idle connection was not evicted"
65+
with pytest.raises(sqlite3.ProgrammingError):
66+
leaked.execute("SELECT 1") # and it really was closed
67+
ws.cleanup()
68+
69+
70+
def test_get_read_conn_never_closes_a_connection_still_in_use(q_app, tmp_path: Path):
71+
"""The race this replaced liveness-checking to fix.
72+
73+
Pruning used to evict any connection whose thread id was absent from `threading.enumerate()`.
74+
Qt worker threads never appear there - they call `threading.get_ident()`, not
75+
`threading.current_thread()` - so the Monitor's graph worker was permanently invisible, and the
76+
next call from any other thread closed its connection *mid-query*:
77+
78+
WidgetState.get_hardware_history - Cannot operate on a closed database
79+
80+
Registering the thread instead is worse: on CPython 3.11 the `_DummyThread` never leaves
81+
`enumerate()` once the native thread dies, so nothing would ever be pruned and the leak above
82+
comes back. Idle time is the one signal a busy thread cannot trip.
83+
"""
84+
ws = _make_state(tmp_path)
85+
worker_id = max(t.ident for t in threading.enumerate()) + 999_999 # invisible, like a QThread
86+
in_use = sqlite3.connect(":memory:")
87+
ws._read_conns[worker_id] = (in_use, time.monotonic()) # just used it
88+
89+
ws._get_read_conn() # a call from another thread
90+
91+
assert worker_id in ws._read_conns, "an in-use connection was evicted"
92+
assert in_use.execute("SELECT 1").fetchone() == (1,), (
93+
"the connection was closed while its thread was still querying - this is the bug")
94+
ws.cleanup()
95+
96+
97+
def test_reusing_a_connection_refreshes_its_idle_clock(q_app, tmp_path: Path):
98+
"""A long-lived worker must not age out just because it has been running a while."""
99+
ws = _make_state(tmp_path)
100+
first = ws._get_read_conn()
101+
tid = threading.get_ident()
102+
ws._read_conns[tid] = (first, time.monotonic() - (ws._READ_CONN_IDLE_SEC + 60))
103+
104+
again = ws._get_read_conn()
105+
106+
assert again is first, "the same thread should keep its own connection"
107+
_conn, seen = ws._read_conns[tid]
108+
assert (time.monotonic() - seen) < 1.0, "fetching a connection did not refresh its idle clock"
62109
ws.cleanup()
63110

64111

0 commit comments

Comments
 (0)