Ctrl K
ComunidadGuías…

tests/test_lock_diagnostics.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The "database is locked" CI flake did not reproduce locally, so the only way
to catch it was to dump evidence inside the run that fails (see the
pytest_exception_interact hook in conftest). These pin the pieces that dump
depends on — if one silently starts returning nothing, the next red build
teaches us nothing again.

**It worked.** On 2026-08-09 the flake fired on PR #188 and the dump named the
cause outright; the last test here pins the fix."""

from __future__ import annotations

import sqlite3
import threading
from pathlib import Path

import conftest
import pytest


def test_exception_chain_finds_the_phrase_through_a_wrapper():
    """reader raises StorageError *from* the sqlite3 error; the phrase is a
    link down the chain, not in the exception the test sees."""
    try:
        try:
            raise sqlite3.OperationalError("database is locked")
        except sqlite3.OperationalError as exc:
            raise RuntimeError("while opening database") from exc
    except RuntimeError:
        excinfo = pytest.ExceptionInfo.from_current()

    assert "database is locked" in conftest._exception_chain_text(excinfo)


def test_exception_chain_of_an_unrelated_failure_does_not_match():
    try:
        raise AssertionError("assert 1 == 2")
    except AssertionError:
        excinfo = pytest.ExceptionInfo.from_current()

    assert "database is locked" not in conftest._exception_chain_text(excinfo)


def test_probe_reports_a_held_write_lock(tmp_path):
    path = str(tmp_path / "held.sqlite")
    holder = sqlite3.connect(path)
    holder.execute("CREATE TABLE t (x)")
    holder.execute("BEGIN IMMEDIATE")
    holder.execute("INSERT INTO t VALUES (1)")
    try:
        assert "STILL LOCKED" in conftest._probe_lock(path)
    finally:
        holder.rollback()
        holder.close()

    assert "writable now" in conftest._probe_lock(path)


def test_the_probe_says_so_when_it_cannot_open_the_file(tmp_path):
    """A probe that failed silently would read as "no problem here", which is
    the one thing this dump must never do — every line of it gets trusted in a
    post-mortem. (Raised in review on PR #186: the happy paths were covered and
    the error path was not.)"""
    unopenable = str(tmp_path / "no-such-dir" / "db.sqlite")
    message = conftest._probe_lock(unopenable)

    assert message, "the probe must never return an empty verdict"
    assert "could not open" in message or "probe failed" in message, message
    # And it must not be mistaken for a clean result.
    assert "writable now" not in message
    assert "STILL LOCKED" not in message


def test_the_dump_names_a_live_background_thread(tmp_path):
    release = threading.Event()
    started = threading.Event()

    def wait():
        started.set()
        release.wait(5)

    worker = threading.Thread(target=wait, name="lectio-test-suspect", daemon=True)
    worker.start()
    started.wait(5)
    try:
        text = conftest._lock_diagnostics_text()
    finally:
        release.set()
        worker.join(5)

    assert "lectio-test-suspect" in text
    # The stack is the point: a stray daemon mid-write is the leading suspect.
    assert "release.wait(5)" in text or "in wait" in text


def test_the_list_render_backfill_daemon_is_gated_in_tests():
    """The flake's second source, named by these diagnostics on 2026-08-09.

    The failing run's thread dump held one `_run_in_user_context` thread inside
    `backfill_entry_list` -> `_fetch_source_lead_image` -> `is_safe_outbound_url`
    -> `socket.getaddrinfo` — a DNS lookup — while the test's own fixture was
    opening the same per-user meta DB. Rendering a post list spawns that daemon,
    so any test that renders a list could lose the race. That is exactly why it
    kept failing in tests with nothing to do with the branch under review.

    A source assertion because the spawn lives inside the home handler, and
    "no thread was started" cannot be observed without racing the thing itself.
    """
    src = (Path(__file__).resolve().parents[1] / "main.py").read_text(encoding="utf-8")
    marker = 'uncached_posts = [p for p in posts if not p.get("thumbnail_url")]'
    start = src.find(marker)
    assert start != -1, "the list-render backfill spawn should still be here"
    spawn = src.find("threading.Thread(", start)
    assert spawn != -1
    assert "LECTIO_DISABLE_STARTUP_BACKFILL" in src[start:spawn], (
        "this daemon must honour the same switch as the startup and media-scan daemons, or it races the test's own DB"
    )