From 2faf05fd73560af069944a3ba6bf0facb335c58f Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:20:42 +0800 Subject: [PATCH] fix: close SQLite handles before staged replacement --- src/codex_usage_tracker/kernel/content.py | 12 ++++- src/codex_usage_tracker/kernel/database.py | 11 +++- src/codex_usage_tracker/kernel/operational.py | 6 ++- tests/kernel/test_database_lifecycle.py | 51 ++++++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/codex_usage_tracker/kernel/content.py b/src/codex_usage_tracker/kernel/content.py index aefdd073..97084c31 100644 --- a/src/codex_usage_tracker/kernel/content.py +++ b/src/codex_usage_tracker/kernel/content.py @@ -354,15 +354,23 @@ def _initialize_content_database(path: Path) -> None: target.parent.mkdir(parents=True, exist_ok=True) staging = target.with_name(f".{target.name}.building-{os.getpid()}") try: - with sqlite3.connect(staging) as connection: + connection = sqlite3.connect(staging) + try: connection.execute(f"PRAGMA application_id = {CONTENT_APPLICATION_ID}") connection.execute(f"PRAGMA user_version = {CONTENT_SCHEMA_VERSION}") connection.execute("PRAGMA foreign_keys = ON") connection.executescript(_SCHEMA_SQL) + connection.commit() + finally: + connection.close() staging.chmod(0o600) os.replace(staging, target) - with sqlite3.connect(target) as connection: + connection = sqlite3.connect(target) + try: connection.execute("PRAGMA journal_mode = WAL") + connection.commit() + finally: + connection.close() target.chmod(0o600) finally: staging.unlink(missing_ok=True) diff --git a/src/codex_usage_tracker/kernel/database.py b/src/codex_usage_tracker/kernel/database.py index 18e279a2..9de01397 100644 --- a/src/codex_usage_tracker/kernel/database.py +++ b/src/codex_usage_tracker/kernel/database.py @@ -40,10 +40,14 @@ def initialize_analytical_database( target.parent.mkdir(parents=True, exist_ok=True) staging = target.with_name(f".{target.name}.building-{uuid.uuid4().hex}") try: - with sqlite3.connect(staging) as connection: + connection = sqlite3.connect(staging) + try: connection.execute("PRAGMA foreign_keys = ON") connection.execute("PRAGMA journal_mode = DELETE") create_schema(connection) + connection.commit() + finally: + connection.close() _owner_only(staging) failures = validate_analytical_database(staging) if failures: @@ -148,7 +152,8 @@ def validate_analytical_database(path: Path) -> list[str]: return [f"analytical database does not exist: {path.name}"] failures: list[str] = [] try: - with sqlite3.connect(path) as connection: + connection = sqlite3.connect(path) + try: connection.execute("PRAGMA foreign_keys = ON") if connection.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION: failures.append( @@ -185,6 +190,8 @@ def validate_analytical_database(path: Path) -> list[str]: failures.append(f"analytical quick_check failed: {integrity}") if connection.execute("PRAGMA foreign_key_check").fetchone() is not None: failures.append("analytical foreign-key check failed") + finally: + connection.close() except sqlite3.DatabaseError as exc: failures.append(f"analytical database is unreadable: {exc}") return failures diff --git a/src/codex_usage_tracker/kernel/operational.py b/src/codex_usage_tracker/kernel/operational.py index 269e3ac7..9e4e94a0 100644 --- a/src/codex_usage_tracker/kernel/operational.py +++ b/src/codex_usage_tracker/kernel/operational.py @@ -192,11 +192,15 @@ def initialize_operational_database(path: Path) -> Path: target.parent.mkdir(parents=True, exist_ok=True) staging = target.with_name(f".{target.name}.building-{uuid.uuid4().hex}") try: - with sqlite3.connect(staging) as connection: + connection = sqlite3.connect(staging) + try: connection.execute("PRAGMA foreign_keys = ON") connection.execute(f"PRAGMA user_version = {OPERATIONAL_SCHEMA_VERSION}") connection.executescript(_OPERATIONAL_SQL) connection.execute("INSERT INTO cutover_control(singleton, state) VALUES (1, 'absent')") + connection.commit() + finally: + connection.close() staging.chmod(0o600) _validate_operational(staging) os.replace(staging, target) diff --git a/tests/kernel/test_database_lifecycle.py b/tests/kernel/test_database_lifecycle.py index 7bae291d..141e5f8a 100644 --- a/tests/kernel/test_database_lifecycle.py +++ b/tests/kernel/test_database_lifecycle.py @@ -2,13 +2,14 @@ import os import sqlite3 +from collections.abc import Callable from contextlib import contextmanager from pathlib import Path from typing import Any import pytest -from codex_usage_tracker.kernel import database +from codex_usage_tracker.kernel import content, database, operational from codex_usage_tracker.kernel.database import ( initialize_analytical_database, open_read_snapshot, @@ -132,6 +133,54 @@ def fail_before_replace(_source: Path, _target: Path) -> None: assert validate_analytical_database(path) == [] +@pytest.mark.parametrize( + ("initializer", "name"), + ( + (initialize_analytical_database, "analytical.sqlite3"), + (operational.initialize_operational_database, "operational.sqlite3"), + (content._initialize_content_database, "content.sqlite3"), + ), +) +def test_database_initializers_close_connections_before_atomic_replace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + initializer: Callable[[Path], Any], + name: str, +) -> None: + real_connect = sqlite3.connect + real_replace = os.replace + connections: list[Any] = [] + + class TrackingConnection: + def __init__(self, connection: sqlite3.Connection) -> None: + self._connection = connection + self.closed = False + + def close(self) -> None: + self.closed = True + self._connection.close() + + def __getattr__(self, attribute: str) -> Any: + return getattr(self._connection, attribute) + + def connect(*args: Any, **kwargs: Any) -> TrackingConnection: + connection = TrackingConnection(real_connect(*args, **kwargs)) + connections.append(connection) + return connection + + def replace(source: Path, target: Path) -> None: + assert connections + assert all(connection.closed for connection in connections) + real_replace(source, target) + + monkeypatch.setattr(sqlite3, "connect", connect) + monkeypatch.setattr(os, "replace", replace) + + initializer(tmp_path / name) + + assert all(connection.closed for connection in connections) + + def test_kernel_creation_never_opens_legacy_database(tmp_path: Path) -> None: legacy = tmp_path / "codex-usage.sqlite3" legacy.write_bytes(b"legacy-schema-39-sentinel")