Compare commits

...
Author SHA1 Message Date
Vito Sansevero e1e3bbc691 fix(system): make retiring a library connection race-free and cheap
Review round for the cross-thread close fix:

- cursor() now records its cursor under the lock _close_now copies the
  cursor set under, and refuses once the connection is retired; a cursor
  opened while the connection closed raised "Set changed size during
  iteration" out of retire() on Python 3.10-3.12. retire() never raises,
  retire_connections keeps going when one connection fails, and the mover
  never reports a completed switch as failed because retiring or awaiting
  old connections did.
- The restore's backup target is marked in use (ManagedConnection.using),
  and in_transaction and create_function are guarded.
- Iterating a cursor marks the connection in use once per fetchmany(128)
  chunk instead of once per row: a 1000-row scan costs 1.10x a plain
  cursor instead of 2.7x (3.14) / 3.8x (3.10).
- Document what an interrupt does not end, the statements a retired
  connection cannot hold back, close() from another thread returning
  before the file is closed, and the await timeout ending as old-in-use.
2026-10-24 18:42:14 -07:00
Vito Sansevero cd6fad6c60 test(tagging): wait for in-flight pages before reading the loaded range
The scroll-position test read the loaded count right after scrolling the
continuation out of view, while a listing page the continuation had
already requested could still be in flight. When it landed the count
grew (80 -> 120 in CI) and the stability poll failed. The test now waits
a frame for any scheduled load to start and then for every listing
request to finish before it reads the count.
2026-10-24 17:14:19 -07:00
Vito Sansevero 1dcebd640c fix(system): never close another thread's library connection during a switch
Move library closed every thread's old connection from the mover thread,
including one whose owner was inside a statement waiting for the old
file's write lock. Closing a sqlite3 connection under a running
sqlite3_step is a use-after-free: Windows CI died with "access violation"
in record_prompt_use, and on Linux the writer got a MemoryError once its
busy timeout ran out.

Managed connections now count the calls their owner is inside.
Retiring one from another thread closes it at once only when idle;
otherwise it is interrupted (sqlite3_interrupt is thread-safe) and its
owner closes it as the call ends. A retired connection refuses new work
and never commits, so a write that waited for the lock is rolled back
and survive_library_switch runs it again on the new library. The mover
retires connections while it holds the locks and waits for the owners
to close them only after releasing them, before the old files are
renamed or a failed copy is removed. Cursors are closed before the
connection so Python 3.10/3.11 release the file handle too.
2026-10-24 16:42:29 -07:00
7 changed files with 958 additions and 93 deletions
+47 -10
View File
@@ -438,8 +438,8 @@ def switch_process(old_db: str, location, detached: list) -> None:
"""Point this process at ``location``: resolver, every model, observers.
Adds the old file's connections, detached and still open, to
``detached`` as soon as they are detached, so the mover closes them
(``PromptModel.close_detached``) whether or not the rest succeeds.
``detached`` as soon as they are detached, so the mover retires them
(``PromptModel.retire_detached``) whether or not the rest succeeds.
"""
from .execution_history import rekey_history_restore
from .review_changes import rekey_review_changes
@@ -474,6 +474,8 @@ class _Move:
self.published: List[str] = []
self.marker: Optional[str] = None
self.moved = False
# Retired connections still in use; awaited once the locks are free.
self.retiring: list = []
def temp(self, suffix: str) -> str:
path = os.path.join(
@@ -704,7 +706,9 @@ def _switch_guarded(move: _Move, location) -> list:
The copy's write lock keeps every repointed thread from writing to it
until the switch is whole, so undoing a partial switch loses nothing.
Returns the old file's detached connections (still open).
Returns the old file's detached connections (still open). On failure
every detached connection is retired; those still in use are added to
``move.retiring`` for the caller to await once the locks are released.
"""
plan = move.plan
guard = sqlite3.connect(
@@ -718,15 +722,17 @@ def _switch_guarded(move: _Move, location) -> list:
except Exception as error:
logger.exception("Library move: switching failed; undoing it")
move.moved = False # the copy and its marker are removed
# Retire, never wait, here: a connection still in use may be
# waiting on the very locks this thread holds.
try:
PromptModel.close_detached(
move.unswitch(plan.db_path, location.db_path), logger
)
undone = move.unswitch(plan.db_path, location.db_path)
except Exception:
logger.exception("Library move: undoing the switch failed")
else:
_retire(move, undone)
# The old file's connections a partial switch detached: the
# models reopen it on next use, so close them now, not at GC.
PromptModel.close_detached(detached, logger)
_retire(move, detached)
raise MoveFailed("switch-failed") from error
move.checkpoint("switched")
return detached
@@ -737,6 +743,36 @@ def _switch_guarded(move: _Move, location) -> list:
def _copy_and_switch(move: _Move, location, attempts: int):
"""Copy, then switch under both locks; retired connections are awaited.
A connection that was inside a statement when it was retired is closed
by its own thread once the statement ends, which may need the locks
released first; the old files are renamed (and a failed copy removed)
only after that, since Windows refuses to rename or delete an open file.
"""
try:
return _copy_and_switch_locked(move, location, attempts)
finally:
try:
PromptModel.await_detached(move.retiring, logger)
except Exception:
logger.exception("Library move: waiting for old connections failed")
def _retire(move: _Move, connections: list) -> None:
"""Retire ``connections``, adding those still in use to ``move.retiring``.
Never raises: a switch that completed must not be reported as failed
because one of the old connections could not be retired (logged; the
rename then reports the old files as still in use).
"""
try:
move.retiring.extend(PromptModel.retire_detached(connections, logger))
except Exception:
logger.exception("Library move: retiring the old connections failed")
def _copy_and_switch_locked(move: _Move, location, attempts: int):
plan = move.plan
for attempt in range(1, attempts + 1):
source = sqlite3.connect(
@@ -756,9 +792,10 @@ def _copy_and_switch(move: _Move, location, attempts: int):
files = move.publish(temp_db, copies)
detached = _switch_guarded(move, location)
# Still holding the old file's lock: a write that was waiting
# on it fails when its connection closes (and runs again on
# the new library) instead of landing in the old file.
PromptModel.close_detached(detached, logger)
# on it is interrupted and cannot commit, so it fails (and
# runs again on the new library) instead of landing in the
# old file. Its own thread closes the connection.
_retire(move, detached)
finally:
if source.in_transaction:
source.execute("ROLLBACK")
+353
View File
@@ -0,0 +1,353 @@
"""A SQLite connection that is never closed under the thread using it.
``PromptModel`` gives each thread its own connection, but Move library and
``close_all`` must retire every thread's connection from one thread. Closing
a ``sqlite3.Connection`` while its owner is inside a statement (Python runs
``sqlite3_step`` without the GIL) frees memory the owner then reads: on
Windows the process dies with "access violation", elsewhere the owner sees
garbage such as a ``MemoryError``.
``ManagedConnection`` counts the calls its owner is inside. ``retire()`` is
safe from any thread and never raises: an idle connection is closed at once;
one in use is closed by its owner as the call ends. A retired connection
refuses new work (cursors, statements, commits) with the error a closed
connection raises.
What retiring a connection in use does and does not do:
- It calls ``sqlite3_interrupt`` (thread-safe). That ends a running
statement at its next step, but it does not wake a statement that is
waiting for another connection's lock: that one waits until the lock is
released (or the busy timeout ends it), and only then is it interrupted.
An interrupt that arrives between two statements, for example between the
implicit ``BEGIN`` and the DML or between ``executemany`` rows, is lost.
- Writes are kept out of a file the process has switched away from because
a retired connection never commits: legacy-mode DML runs inside a
transaction that only ``commit()`` (refused) or ``close()`` (rolls back)
ends. A statement that commits by itself is not covered: DDL or a PRAGMA
write outside a transaction, or an explicit ``COMMIT`` passed to
``execute``/``executescript`` (``executescript`` also commits first, in C,
on Python before 3.12). Managed connections must never be used that way
for library writes.
"""
from __future__ import annotations
import contextlib
import logging
import threading
import time
import weakref
from typing import Iterable, Iterator, List, Optional
import sqlite3
CLOSED_MESSAGE = "Cannot operate on a closed database."
# Rows fetched per step when a cursor is iterated: the connection is marked
# in use once per chunk instead of once per row (iteration costs about what
# a plain cursor costs). A loop that stops early leaves up to this many rows
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
ITER_CHUNK = 128
class ManagedCursor(sqlite3.Cursor):
"""A cursor whose calls into SQLite count as its connection being in use."""
def execute(self, sql, parameters=(), /):
conn = self.connection
conn._enter()
try:
return super().execute(sql, parameters)
finally:
conn._leave()
def executemany(self, sql, seq_of_parameters, /):
conn = self.connection
conn._enter()
try:
return super().executemany(sql, seq_of_parameters)
finally:
conn._leave()
def executescript(self, sql_script, /):
conn = self.connection
conn._enter()
try:
return super().executescript(sql_script)
finally:
conn._leave()
def fetchone(self):
conn = self.connection
conn._enter()
try:
return super().fetchone()
finally:
conn._leave()
def fetchmany(self, *args, **kwargs):
conn = self.connection
conn._enter()
try:
return super().fetchmany(*args, **kwargs)
finally:
conn._leave()
def fetchall(self):
conn = self.connection
conn._enter()
try:
return super().fetchall()
finally:
conn._leave()
def __iter__(self) -> Iterator:
return self._chunks()
def _chunks(self) -> Iterator:
while True:
rows = self.fetchmany(ITER_CHUNK)
if not rows:
return
yield from rows
def __next__(self):
conn = self.connection
conn._enter()
try:
return super().__next__()
finally:
conn._leave()
def close(self):
conn = self.connection
try:
conn._enter()
except sqlite3.ProgrammingError:
return super().close() # the connection is closed or closing
try:
return super().close()
finally:
conn._leave()
class ManagedConnection(sqlite3.Connection):
"""A connection only its owner closes while a call into it is running."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Guards _depth, _retired, _closing and _cursors. Re-entrant so that
# code running in the closing thread while it holds the lock (a
# cursor() during the cursor-set copy) is refused instead of hanging.
self._use_lock = threading.RLock()
self._depth = 0
self._retired = False
self._closing = False
self._released = threading.Event()
# Python before 3.12 keeps the file open after close() while a cursor
# still holds a statement; these are closed first (see _close_now).
self._cursors: "weakref.WeakSet[sqlite3.Cursor]" = weakref.WeakSet()
self.close_logger: Optional[logging.Logger] = None
# ── in use ───────────────────────────────────────────────────────
def _enter(self) -> None:
with self._use_lock:
if self._retired:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
self._depth += 1
def _leave(self) -> None:
with self._use_lock:
self._depth -= 1
close_now = self._retired and self._depth == 0
if close_now:
self._close_now()
@contextlib.contextmanager
def using(self):
"""Mark the connection in use for a call this class cannot wrap.
For example as the *target* of another connection's ``backup()``.
"""
self._enter()
try:
yield self
finally:
self._leave()
def cursor(self, factory=None):
with self._use_lock:
if self._retired:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
cursor = super().cursor(factory or ManagedCursor)
self._cursors.add(cursor)
return cursor
def execute(self, sql, parameters=(), /):
return self.cursor().execute(sql, parameters)
def executemany(self, sql, seq_of_parameters, /):
return self.cursor().executemany(sql, seq_of_parameters)
def executescript(self, sql_script, /):
return self.cursor().executescript(sql_script)
def commit(self):
self._enter()
try:
return super().commit()
finally:
self._leave()
def rollback(self):
self._enter() # a closed connection raises, as sqlite3's does
try:
return super().rollback()
finally:
self._leave()
def backup(self, target, **kwargs):
self._enter()
try:
return super().backup(target, **kwargs)
finally:
self._leave()
def create_function(self, *args, **kwargs):
self._enter()
try:
return super().create_function(*args, **kwargs)
finally:
self._leave()
@property
def in_transaction(self) -> bool:
self._enter()
try:
return super().in_transaction
finally:
self._leave()
def __exit__(self, exc_type, exc_value, traceback):
# The C implementation calls its own commit/rollback, not these.
if exc_type is None:
try:
self.commit()
except BaseException:
with contextlib.suppress(sqlite3.Error):
self.rollback()
raise
else:
self.rollback()
return False
# ── closing ──────────────────────────────────────────────────────
def close(self) -> None:
"""Close now if idle, else as soon as the running call ends.
Unlike ``sqlite3.Connection.close``, a call from another thread while
the owner is inside a call returns before the file is closed. A caller
that must know the file is closed (before renaming or deleting it)
waits for ``wait_closed`` (``PromptModel.close_detached`` does).
"""
self.retire()
def retire(self) -> bool:
"""Refuse new work and close; True when the connection is closed already.
Safe from any thread, and never raises: failures are logged.
"""
try:
with self._use_lock:
if not self._retired:
self._retired = True
if self._depth:
# Ends the running statement (see the module docstring
# for what it does not end). The owner cannot start
# closing the connection while this lock is held.
self.interrupt()
return False
elif self._depth:
return False
else:
return self._released.wait(0)
except Exception:
self._log_exception("Error retiring database connection")
return self._released.wait(0)
self._close_now()
return True
def wait_closed(self, timeout: float) -> bool:
return self._released.wait(timeout)
def _close_now(self) -> None:
# Only ever called with no call into the connection running, and a
# retired connection refuses new calls and cursors: its cursors are
# idle, and the set is copied under the lock that guards it.
with self._use_lock:
if self._closing:
return # another thread closes it and sets _released
self._closing = True
cursors = list(self._cursors)
try:
for cursor in cursors:
with contextlib.suppress(sqlite3.Error):
sqlite3.Cursor.close(cursor)
super().close()
except Exception:
self._log_exception("Error closing database connection")
finally:
self._released.set()
def _log_exception(self, message: str) -> None:
if self.close_logger is not None:
self.close_logger.exception(message)
def retire_connections(
connections: Iterable[sqlite3.Connection], logger=None
) -> List[ManagedConnection]:
"""Retire every connection; return those whose owner has yet to close them.
Safe while holding a lock the owners wait on: nothing here waits for them.
One connection failing to retire is logged and never skips the rest.
"""
pending = []
for conn in connections:
try:
if isinstance(conn, ManagedConnection):
if logger is not None:
conn.close_logger = logger
if not conn.retire():
pending.append(conn)
else:
conn.close()
except Exception:
if logger is not None:
logger.exception("Error closing database connection")
return pending
def await_closed(
connections: Iterable[ManagedConnection], timeout: float, logger=None
) -> bool:
"""Wait until the owners closed ``connections`` (retired); False on timeout.
A connection still open after ``timeout`` (its owner is blocked, for
example on another process's lock after a lost interrupt) is logged and
left to close when its call ends; a Move library then reports the old
files as still in use instead of renaming them.
"""
deadline = time.monotonic() + timeout
still_open = 0
for conn in connections:
if not conn.wait_closed(max(0.0, deadline - time.monotonic())):
still_open += 1
if still_open and logger is not None:
logger.warning(
f"{still_open} database connection(s) still in use after "
f"{timeout:g}s; they close when their statement ends"
)
return not still_open
+47 -19
View File
@@ -15,6 +15,11 @@ from contextlib import closing
from pathlib import Path
from typing import Optional, Set, Tuple
from .managed_connection import (
ManagedConnection,
await_closed,
retire_connections,
)
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .annotation_schema import annotation_schema_error, migrate_annotation_state
@@ -615,10 +620,15 @@ class PromptModel:
# meanwhile: otherwise it would write to the old file.
with self._conn_lock:
path, generation = self.db_path, self._generation
# check_same_thread=False only so close_all can clean up other
# threads' connections; each connection is still used by one thread.
# check_same_thread=False only so close_all and Move library can
# retire other threads' connections; each connection is still
# used by one thread, and ManagedConnection never closes it under
# a running statement (see managed_connection).
conn = sqlite3.connect(
path, check_same_thread=False, timeout=BUSY_TIMEOUT_SECONDS
path,
check_same_thread=False,
timeout=BUSY_TIMEOUT_SECONDS,
factory=ManagedConnection,
)
try:
conn.row_factory = sqlite3.Row
@@ -654,9 +664,9 @@ class PromptModel:
Every thread opens the new file on its next ``get_connection``. The
old connections are detached, not closed, and returned: the caller
closes them once nothing can block on the new file any more. Closing
a connection another thread is using waits for its statement; a write
waiting on the mover's lock then fails (and, through
retires them (``retire_detached``) while it still holds the old
file's write lock. A write waiting on that lock is then interrupted
and can never commit, so it fails (and, through
``PromptDatabase.survive_library_switch``, runs again on the new file).
"""
old_key = os.path.normcase(os.path.realpath(old_path))
@@ -671,14 +681,31 @@ class PromptModel:
return detached
@staticmethod
def close_detached(connections, logger=None) -> None:
"""Close connections ``repoint_instances`` detached."""
for conn in connections:
try:
conn.close()
except sqlite3.Error as e:
if logger is not None:
logger.warning(f"Error closing database connection: {e}")
def retire_detached(connections, logger=None) -> list:
"""Retire detached connections without waiting for their owners.
Idle ones close now. One whose owner thread is inside a statement is
never closed from here (that frees memory the owner is using: an
access violation on Windows); it is interrupted, refuses to commit and
is closed by its owner as the statement ends. Returns those still
open, for ``await_detached`` once no lock the owners wait on is held.
"""
return retire_connections(connections, logger)
@staticmethod
def await_detached(connections, logger=None) -> bool:
"""Wait until retired connections are closed; False if some are not."""
return await_closed(connections, BUSY_TIMEOUT_SECONDS, logger)
@staticmethod
def close_detached(connections, logger=None) -> bool:
"""Retire connections ``repoint_instances`` detached and await them.
Do not call it while holding a lock their owners may wait on; retire
them there (``retire_detached``) and await them after releasing it.
"""
pending = retire_connections(connections, logger)
return await_closed(pending, BUSY_TIMEOUT_SECONDS, logger)
def close_all(self) -> None:
"""Close every thread's connection; they reopen lazily on next use.
@@ -714,10 +741,7 @@ class PromptModel:
self._local.conn = None
with self._conn_lock:
self._connections.discard(conn)
try:
conn.close()
except sqlite3.Error as e:
self.logger.warning(f"Error closing database connection: {e}")
self.close_detached([conn], self.logger)
def _migrate_workflow_name_removal(self, conn: sqlite3.Connection) -> None:
"""
@@ -1223,7 +1247,11 @@ class PromptModel:
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
try:
source.backup(self.get_connection())
target = self.get_connection()
# The target is written under backup's step too: mark it in use
# so a concurrent close_all retires it instead of closing it.
with target.using():
source.backup(target)
finally:
source.close()
+7
View File
@@ -602,6 +602,10 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
test('a save under a search keeps the scroll position and the loaded range', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 140 } })).toBeOK();
// Listing pages still in flight: one that lands after the scroll below would grow the loaded range.
const inFlight = new Set();
page.on('request', item => { if (item.url().includes('/images/output?')) inFlight.add(item); });
for (const done of ['requestfinished', 'requestfailed']) page.on(done, item => inFlight.delete(item));
await page.goto(images);
const searched = listingOf(page, query => query.get('q') === 'bulk');
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('bulk');
@@ -615,6 +619,9 @@ test('a save under a search keeps the scroll position and the loaded range', asy
await expect.poll(async () => { await continuation.scrollIntoViewIfNeeded(); return loadedCount(); }).toBeGreaterThanOrEqual(80);
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight / 3)));
await expect(continuation).not.toBeInViewport();
// A load the continuation scheduled for the next frame has started by now; wait for every page to land.
await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect.poll(() => inFlight.size).toBe(0);
const loaded = await continuation.textContent();
await expect.poll(() => continuation.textContent()).toBe(loaded);
const y = await page.evaluate(() => window.scrollY);
+2
View File
@@ -293,6 +293,8 @@ class TestRestoreFromFile(BackupTestCase):
super().backup(target, *args, **kwargs)
def connect_with_failure(*args, **kwargs) -> sqlite3.Connection:
if "factory" in kwargs: # the model's own managed connections
return connect(*args, **kwargs)
return connect(*args, **kwargs, factory=FailingLiveBackup)
with patch("database.models.sqlite3.connect", side_effect=connect_with_failure):
+170 -64
View File
@@ -7,6 +7,9 @@
because Move library switched runs once more, on the new library; a write
racing the switch on a real connection (a run count, an image link) lands
in the new library only.
- The switching thread never closes a connection another thread is inside
a statement on (a use-after-free in SQLite: "Windows fatal exception:
access violation"); it interrupts it and the owner closes it.
- M2: the copy is verified through a file URI that survives ``#`` and ``%``.
- M6: a restart after a crash between the switch and the rename renames the
old files, never deletes them.
@@ -46,6 +49,39 @@ def _run_count(path, text):
return row.fetchone()[0]
class _CrossThreadCloseProbe:
"""Record every ``close()`` this thread calls on a connection in use elsewhere.
The writer marks its connection with ``using`` when its statement starts.
Closing that connection from the switching thread while the writer is
inside the statement frees memory SQLite is still using; on Windows the
process dies with an access violation, on Linux the writer sees garbage.
"""
def __init__(self):
self.in_use = set()
self.violations = []
def using(self, conn):
self.in_use.add(conn)
def _profile(self, _frame, event, arg):
if event != "c_call" or getattr(arg, "__name__", "") != "close":
return
target = getattr(arg, "__self__", None)
if isinstance(target, sqlite3.Connection) and target in self.in_use:
self.violations.append(threading.current_thread().name)
@contextlib.contextmanager
def watching(self):
previous = sys.getprofile()
sys.setprofile(self._profile)
try:
yield self
finally:
sys.setprofile(previous)
class TestFailedSwitchIsUndone(_MoveCase):
"""I1: whatever part of the switch fails, nothing was moved."""
@@ -166,44 +202,46 @@ class TestBackgroundWritesSurviveTheSwitch(_MoveCase):
self.db.model.close_all()
image = os.path.join(self.comfy, "output", "late.png")
# A short busy timeout so the blocked writer gives up in seconds.
with mock.patch.object(models, "BUSY_TIMEOUT_SECONDS", 2):
writer_db = PromptDatabase(self.old_db)
handler = ImageGenerationHandler(writer_db, mock.MagicMock())
started = threading.Event()
outcome = {}
writer_db = PromptDatabase(self.old_db)
handler = ImageGenerationHandler(writer_db, mock.MagicMock())
started = threading.Event()
probe = _CrossThreadCloseProbe()
outcome = {}
def write():
conn = writer_db.model.get_connection()
conn.set_trace_callback(
lambda sql: (
started.set()
if sql.lstrip().upper().startswith(("BEGIN", "INSERT"))
else None
)
)
try:
# No metadata: the fallback link, not the resolved pair.
handler.link_image_to_prompt(image, {"id": 1}, {})
outcome["ok"] = True
except Exception as error: # pragma: no cover - reported below
outcome["error"] = error
finally:
writer_db.model.close()
def write():
conn = writer_db.model.get_connection()
thread = threading.Thread(target=write)
def trace(sql):
if sql.lstrip().upper().startswith(("BEGIN", "INSERT")):
probe.using(conn)
started.set()
def checkpoint(step):
if step == "locked":
# The old file is write-locked now: start the writer and
# wait until its first write is running (it then waits on us).
thread.start()
self.assertTrue(started.wait(WAIT))
conn.set_trace_callback(trace)
try:
# No metadata: the fallback link, not the resolved pair.
handler.link_image_to_prompt(image, {"id": 1}, {})
outcome["ok"] = True
except Exception as error: # pragma: no cover - reported below
outcome["error"] = error
finally:
writer_db.model.close()
self.move(checkpoint=checkpoint)
thread.join(WAIT)
thread = threading.Thread(target=write)
def checkpoint(step):
if step == "locked":
# The old file is write-locked now: start the writer and
# wait until its first write is running (it then waits on us).
thread.start()
self.assertTrue(started.wait(WAIT))
with probe.watching():
result = self.move(checkpoint=checkpoint)
thread.join(WAIT)
self.assertFalse(thread.is_alive())
self.assertEqual(probe.violations, [])
self.assertEqual(outcome, {"ok": True})
self.assertIsNone(result.retire_problem)
moved = [
row
for row in _rows(self.new_db)["generated_images"]
@@ -229,45 +267,113 @@ class TestBackgroundWritesSurviveTheSwitch(_MoveCase):
text = "a racing prompt"
self.db.save_prompt(text, prompt_hash=generate_prompt_hash(text))
self.db.model.close_all()
# A short busy timeout so the blocked writer gives up in seconds.
with mock.patch.object(models, "BUSY_TIMEOUT_SECONDS", 2):
writer_db = PromptDatabase(self.old_db)
started = threading.Event()
outcome = {}
writer_db = PromptDatabase(self.old_db)
thread, started, probe, outcome = self._racing_run_count(writer_db, text)
def write():
conn = writer_db.model.get_connection()
conn.set_trace_callback(
lambda sql: started.set() if sql.startswith("UPDATE") else None
)
try:
usage_tracking.count_runs(
[(generate_prompt_hash(text), text)],
writer_db,
usage_tracking.PendingFirstUse(),
)
outcome["ok"] = True
except Exception as error: # pragma: no cover - reported below
outcome["error"] = error
finally:
writer_db.model.close()
def checkpoint(step):
if step == "locked":
# The old file is write-locked now: start the writer and
# wait until its UPDATE is running (it then waits on us).
thread.start()
self.assertTrue(started.wait(WAIT))
thread = threading.Thread(target=write)
def checkpoint(step):
if step == "locked":
# The old file is write-locked now: start the writer and
# wait until its UPDATE is running (it then waits on us).
thread.start()
self.assertTrue(started.wait(WAIT))
self.move(checkpoint=checkpoint)
thread.join(WAIT)
with probe.watching():
result = self.move(checkpoint=checkpoint)
# The writer's old connection was closed before the old file was
# renamed: Windows refuses to rename an open file.
self.assertIsNone(result.retire_problem)
thread.join(WAIT)
self.assertFalse(thread.is_alive())
self.assertEqual(probe.violations, [])
self.assertEqual(outcome, {"ok": True})
self.assertEqual(_run_count(self.new_db, text), 1)
renamed = os.path.join(self.ext, "prompts.db.moved-to-__promptmanager")
self.assertEqual(_run_count(renamed, text), 0)
assert_closed(self, renamed)
def test_a_write_racing_a_failed_switch_lands_in_the_old_library(self):
text = "a prompt racing a failed switch"
self.db.save_prompt(text, prompt_hash=generate_prompt_hash(text))
self.db.model.close_all()
writer_db = PromptDatabase(self.old_db)
thread, started, probe, outcome = self._racing_run_count(writer_db, text)
def checkpoint(step):
if step == "locked":
thread.start()
self.assertTrue(started.wait(WAIT))
def switch(old_db, location, detached):
lm.switch_process(old_db, location, detached)
raise RuntimeError("switch failed at its very end")
with probe.watching():
with self.assertRaises(lm.MoveFailed):
self.move(checkpoint=checkpoint, switch=switch)
# The copy was removed: no connection was left open on it.
self.assert_target_clean()
assert_closed(self, self.target)
thread.join(WAIT)
self.assertFalse(thread.is_alive())
self.assertEqual(probe.violations, [])
self.assertEqual(outcome, {"ok": True})
self.assertEqual(_run_count(self.old_db, text), 1)
def test_a_retire_failure_after_a_complete_switch_does_not_fail_the_move(self):
node = PromptDatabase(self.old_db)
node.get_prompt_by_id(1)
real = models.PromptModel.retire_detached
calls = []
def failing(connections, logger=None):
calls.append(len(connections))
real(connections, logger) # closed, but the call still fails
raise RuntimeError("retiring failed")
with mock.patch.object(
models.PromptModel, "retire_detached", staticmethod(failing)
):
with self.assertLogs("prompt_manager.database.library_move", "ERROR"):
result = self.move()
self.assertEqual(len(calls), 1)
self.assertGreater(calls[0], 0)
self.assertEqual(node.model.db_path, self.new_db)
self.assertEqual(result.location.db_path, self.new_db)
self.assertTrue(
os.path.isfile(
os.path.join(self.ext, "prompts.db.moved-to-__promptmanager")
)
)
@staticmethod
def _racing_run_count(writer_db, text):
"""A run count whose UPDATE marks its connection in use for the probe."""
started = threading.Event()
probe = _CrossThreadCloseProbe()
outcome = {}
def write():
conn = writer_db.model.get_connection()
def trace(sql):
if sql.startswith("UPDATE"):
probe.using(conn)
started.set()
conn.set_trace_callback(trace)
try:
usage_tracking.count_runs(
[(generate_prompt_hash(text), text)],
writer_db,
usage_tracking.PendingFirstUse(),
)
outcome["ok"] = True
except Exception as error: # pragma: no cover - reported below
outcome["error"] = error
finally:
writer_db.model.close()
return threading.Thread(target=write), started, probe, outcome
class TestVerifyUri(unittest.TestCase):
+332
View File
@@ -0,0 +1,332 @@
"""ManagedConnection: never closed under the thread using it.
Closing a sqlite3 connection from one thread while another is inside a
statement on it is a use-after-free in SQLite ("Windows fatal exception:
access violation" in CI). A retired connection is closed at once when idle,
otherwise interrupted and closed by its owner as the call ends; it never
commits again.
"""
import contextlib
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
import weakref
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.managed_connection import ( # noqa: E402
ManagedConnection,
ManagedCursor,
await_closed,
retire_connections,
)
from database.models import PromptModel # noqa: E402
from tests.open_handles import assert_closed # noqa: E402
WAIT = 30
class _Case(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self._tmp.name, "managed.db")
with contextlib.closing(sqlite3.connect(self.path)) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE t(x INTEGER)")
conn.execute("INSERT INTO t VALUES (0)")
conn.commit()
self.opened = []
def tearDown(self):
for conn in self.opened:
conn.close()
try:
assert_closed(self, self._tmp.name)
finally:
self._tmp.cleanup()
def connect(self, **kwargs):
conn = sqlite3.connect(
self.path,
check_same_thread=False,
timeout=WAIT,
factory=ManagedConnection,
**kwargs,
)
self.opened.append(conn)
return conn
def value(self):
with contextlib.closing(sqlite3.connect(self.path)) as conn:
return conn.execute("SELECT x FROM t").fetchone()[0]
class TestRetire(_Case):
def test_an_idle_connection_retired_from_another_thread_closes_at_once(self):
conn = self.connect()
conn.execute("SELECT 1").fetchone()
closed = []
thread = threading.Thread(target=lambda: closed.append(conn.retire()))
thread.start()
thread.join(WAIT)
self.assertEqual(closed, [True])
self.assertTrue(conn.wait_closed(0))
with self.assertRaises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
self.assertTrue(conn.retire()) # retiring again is harmless
def test_a_connection_in_use_is_closed_by_its_owner_when_the_call_ends(self):
conn = self.connect()
holder = sqlite3.connect(self.path, isolation_level=None)
self.opened.append(holder)
holder.execute("BEGIN IMMEDIATE")
started = threading.Event()
outcome = {}
conn.set_trace_callback(
lambda sql: started.set() if sql.startswith("UPDATE") else None
)
def write():
try:
with conn:
conn.execute("UPDATE t SET x = 1")
outcome["ok"] = True
except sqlite3.Error as error:
outcome["error"] = error
thread = threading.Thread(target=write)
thread.start()
self.assertTrue(started.wait(WAIT))
# The owner is waiting on our lock inside its UPDATE: not closed here.
self.assertFalse(conn.retire())
self.assertFalse(conn.retire())
self.assertFalse(conn.wait_closed(0))
self.assertEqual(retire_connections([conn]), [conn])
holder.execute("ROLLBACK")
thread.join(WAIT)
self.assertFalse(thread.is_alive())
self.assertTrue(conn.wait_closed(WAIT))
self.assertIsInstance(outcome.get("error"), sqlite3.Error)
self.assertEqual(self.value(), 0)
def test_a_write_retired_before_its_commit_is_rolled_back(self):
conn = self.connect()
conn.execute("UPDATE t SET x = 2") # an implicit transaction is open
thread = threading.Thread(target=conn.retire)
thread.start()
thread.join(WAIT)
with self.assertRaises(sqlite3.ProgrammingError):
conn.commit()
with self.assertRaises(sqlite3.ProgrammingError):
with conn:
pass
with self.assertRaises(sqlite3.ProgrammingError):
conn.rollback()
self.assertEqual(self.value(), 0)
def test_close_is_retire(self):
conn = self.connect()
conn.close()
self.assertTrue(conn.wait_closed(0))
with self.assertRaises(sqlite3.ProgrammingError):
conn.commit()
def test_plain_connections_are_closed_directly(self):
plain = sqlite3.connect(self.path)
self.assertEqual(retire_connections([plain]), [])
with self.assertRaises(sqlite3.ProgrammingError):
plain.execute("SELECT 1")
def test_awaiting_a_connection_still_in_use_times_out_with_a_warning(self):
conn = self.connect()
conn._enter() # its owner is inside a call
self.assertEqual(retire_connections([conn]), [conn])
logger = mock.Mock()
self.assertFalse(await_closed([conn], 0, logger))
logger.warning.assert_called_once()
conn._leave()
self.assertTrue(await_closed([conn], WAIT, logger))
class TestCalls(_Case):
def test_every_cursor_call_works_and_is_refused_once_retired(self):
conn = self.connect()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
self.assertIsInstance(cursor, ManagedCursor)
conn.executemany("INSERT INTO t VALUES (?)", [(1,), (2,)])
conn.executescript("INSERT INTO t VALUES (3);")
self.assertEqual(
[row["x"] for row in conn.execute("SELECT x FROM t ORDER BY x")],
[0, 1, 2, 3],
)
cursor.execute("SELECT x FROM t ORDER BY x")
self.assertEqual(cursor.fetchone()["x"], 0)
self.assertEqual([r["x"] for r in cursor.fetchmany(2)], [1, 2])
self.assertEqual([r["x"] for r in cursor.fetchall()], [3])
cursor.executemany("UPDATE t SET x = x WHERE x = ?", [(1,)])
cursor.executescript("SELECT 1;")
cursor.close()
conn.commit()
conn.rollback()
with conn:
conn.execute("DELETE FROM t WHERE x = 3")
with self.assertRaises(ZeroDivisionError):
with conn:
conn.execute("DELETE FROM t")
raise ZeroDivisionError
self.assertEqual(self.value(), 0)
copy = sqlite3.connect(":memory:")
self.opened.append(copy)
conn.backup(copy)
self.assertEqual(copy.execute("SELECT COUNT(*) FROM t").fetchone()[0], 3)
reader = conn.execute("SELECT x FROM t")
conn.retire()
for call in (
lambda: reader.fetchone(),
lambda: reader.fetchmany(1),
lambda: reader.fetchall(),
lambda: next(reader),
lambda: reader.execute("SELECT 1"),
lambda: reader.executemany("SELECT ?", [(1,)]),
lambda: reader.executescript("SELECT 1;"),
lambda: conn.backup(copy),
):
with self.assertRaises(sqlite3.ProgrammingError):
call()
# As with sqlite3's own cursors (the result varies by Python version).
with contextlib.suppress(sqlite3.ProgrammingError):
reader.close()
class _CursorOpenedDuringClose(weakref.WeakSet):
"""The cursor set of a connection being closed; a cursor opens mid-iteration.
``hook`` runs after the first cursor is yielded, where a thread switch
could let the owner call ``conn.cursor()`` (Python 3.10-3.12 copy a
WeakSet one item at a time). It runs in the closing thread itself, so
the result does not depend on scheduling.
"""
def __init__(self, items, hook):
super().__init__(items)
self.hook = hook
def __iter__(self):
hooked = False
for item in super().__iter__():
yield item
if not hooked:
hooked = True
self.hook()
class TestReviewRound(_Case):
"""PR #321 review: I1 (cursor set race), M1 and M2 (unguarded paths)."""
def test_a_cursor_opened_while_the_connection_closes_cannot_break_retire(self):
conn = self.connect()
kept = [conn.cursor() for _ in range(3)]
refused = []
def open_cursor():
try:
kept.append(conn.cursor())
except sqlite3.ProgrammingError as error:
refused.append(error)
conn._cursors = _CursorOpenedDuringClose(conn._cursors, open_cursor)
self.assertTrue(conn.retire()) # no "Set changed size during iteration"
self.assertTrue(conn.wait_closed(0))
self.assertEqual(len(refused), 1)
self.assertEqual(len(kept), 3)
def test_a_retired_connection_refuses_new_cursors_and_scripts(self):
conn = self.connect()
conn._enter() # its owner is inside a call: retired, not yet closed
try:
self.assertFalse(conn.retire())
for call in (
conn.cursor,
lambda: conn.executescript("CREATE TABLE late(x);"),
lambda: conn.in_transaction,
lambda: conn.create_function("f", 0, lambda: 1),
):
with self.subTest(call=call):
with self.assertRaises(sqlite3.ProgrammingError):
call()
finally:
conn._leave()
self.assertTrue(conn.wait_closed(0))
with contextlib.closing(sqlite3.connect(self.path)) as check:
tables = check.execute(
"SELECT name FROM sqlite_master WHERE name='late'"
).fetchall()
self.assertEqual(tables, [])
def test_one_connection_failing_to_retire_does_not_skip_the_rest(self):
class Broken(ManagedConnection):
def retire(self):
raise RuntimeError("retire failed")
broken = sqlite3.connect(self.path, check_same_thread=False, factory=Broken)
try:
after = self.connect()
logger = mock.Mock()
pending = retire_connections([broken, after], logger)
self.assertEqual(pending, [])
self.assertTrue(after.wait_closed(0))
logger.exception.assert_called_once()
finally:
ManagedConnection.retire(broken)
def test_a_restore_marks_its_backup_target_in_use(self):
model = PromptModel(os.path.join(self._tmp.name, "live.db"))
try:
self._restore_marks_its_target(model)
finally:
model.close_all()
def _restore_marks_its_target(self, model):
source_path = os.path.join(self._tmp.name, "source.db")
PromptModel(source_path).close_all()
target = model.get_connection()
depths = []
connect = sqlite3.connect
class Source(sqlite3.Connection):
def backup(self, into, *args, **kwargs):
depths.append(into._depth)
return super().backup(into, *args, **kwargs)
def connect_source(*args, **kwargs):
if "factory" in kwargs:
return connect(*args, **kwargs)
return connect(*args, **kwargs, factory=Source)
with mock.patch("database.models.sqlite3.connect", connect_source):
model._replace_contents_from(source_path)
self.assertEqual(depths, [1])
self.assertEqual(target._depth, 0)
class TestModelConnections(_Case):
def test_the_model_hands_out_managed_connections_and_closes_them(self):
model = PromptModel(os.path.join(self._tmp.name, "model.db"))
conn = model.get_connection()
self.assertIsInstance(conn, ManagedConnection)
model.close()
self.assertTrue(conn.wait_closed(0))
conn = model.get_connection()
model.close_all()
self.assertTrue(conn.wait_closed(0))
if __name__ == "__main__":
unittest.main()