Skip to content

fix(sqlite): close connection before untracking on failure (#75629) - #75709

Closed
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix-75629-close-before-untrack
Closed

fix(sqlite): close connection before untracking on failure (#75629)#75709
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix-75629-close-before-untrack

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

_TrackingMixin.close() in hermes_cli/sqlite_safe_read.py removed a
connection's entry from the live-connection registry before calling the
real close(). When the real close failed — e.g. cross-thread use under
SQLite's default check_same_thread=True — the registry entry was already
gone, so has_live_connection() reported False for a file descriptor that
was still open. That let read_header_bytes_preopen() byte-probe a live
database, which on POSIX cancels every advisory lock this process holds on
that file (including a running VACUUM's exclusive lock), reopening the
exact corruption path this module exists to prevent
("database disk image is malformed").

Fixes #75629.

Root cause

# hermes_cli/sqlite_safe_read.py — _TrackingMixin.close(), before
def close(self) -> None:
    with _live_lock:
        path = getattr(self, "_hermes_tracked_path", None)
        if path is not None:
            self._hermes_tracked_path = None
            untrack_connection(path)   # registry entry gone...
        super().close()                # ...even if this raises

Fix

Close first, untrack only on success — a failed close() now fails
closed, leaving the registry (and therefore the byte-probe guard) honest:

# hermes_cli/sqlite_safe_read.py — _TrackingMixin.close(), after
def close(self) -> None:
    with _live_lock:
        path = getattr(self, "_hermes_tracked_path", None)
        super().close()                # attempt the real close first
        if path is not None:
            self._hermes_tracked_path = None
            untrack_connection(path)   # only on success

Behavior on a successful close is unchanged. On a failed close, the
exception still propagates to the caller exactly as before — the only
change is that the registry entry survives, so has_live_connection() and
read_header_bytes_preopen() keep telling the truth.

Regression test

tests/test_sqlite_lock_safe_inspection.py::test_close_failure_keeps_connection_tracked:

  1. A connection is opened via connect_tracked() on a worker thread that
    then blocks (stays alive).
  2. The main thread calls close() on it — SQLite raises
    ProgrammingError (cross-thread, default check_same_thread=True).
  3. Assert has_live_connection() is still True and
    read_header_bytes_preopen() still returns None (refused).
  4. The owning worker thread closes it for real — assert both release.

The same assertion fails on main before this change: step 3 sees
has_live_connection() == False while the descriptor is still open.

Test plan

  • pytest tests/test_sqlite_lock_safe_inspection.py — 7/7 passed
    (6 pre-existing + 1 new regression test)
  • tests/hermes_cli/test_session_recovery.py,
    tests/hermes_cli/test_kanban_db.py — no new failures. Two failures
    present in test_kanban_db.py
    (test_rate_limit_exit_requeues_without_counting_failure,
    test_worktree_workspace_explicit_target_materializes_linked_worktree)
    are pre-existing on main (confirmed via git stash + rerun before
    this diff), unrelated to sqlite_safe_read.py.

Scope

Only two files touched: hermes_cli/sqlite_safe_read.py (5-line reorder +
one docstring clarification) and the test file above. hermes_state.py is
untouched.

Not fixed here — related, complementary, tracked separately

This PR closes the registry's lie; it does not address why a close can
fail in the first place, or reap connections nobody ever retries closing:

All three are independent fixes for independent bugs; this one stands on
its own and should merge regardless of the other two.

Infographic

Guild Quest #75629 — Safe SQLite Connection Closing

(Rendered locally and hosted on an isolated, never-merged asset branch —
per .gitignore's PR-infographic policy, the binary never enters this
PR's history or main.)

…rch#75629)

_TrackingMixin.close() removed the registry entry before calling the
real close(), so a failed close() (e.g. cross-thread use under
check_same_thread=True) left the fd open while has_live_connection()
already reported false -- reopening the window for
read_header_bytes_preopen() to byte-probe a live database and cancel
its POSIX advisory locks.

Close first, untrack only on success, so a failed close() stays
tracked and the byte-probe guard fails closed.
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard area/sessions Session lifecycle, resume, persistence, history P1 High — major feature broken, no workaround duplicate This issue or pull request already exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 31, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #75699: both current diffs move SQLite untracking after a successful close and cover the same failed cross-thread-close registry invariant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard duplicate This issue or pull request already exists P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: sqlite_safe_read untracks a connection before closing it, so a failed close leaves the byte-probe guard believing nothing is live

2 participants