From 3ba896273851013c392d244c8a32a95177860a57 Mon Sep 17 00:00:00 2001 From: Squiddy Date: Tue, 26 May 2026 15:21:45 -0400 Subject: [PATCH] fix(kanban): add Windows init lock guard --- hermes_cli/kanban_db.py | 25 +++++++++++++++++++++++-- tests/hermes_cli/test_kanban_db.py | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 858abeca6b2..9930a6aa51a 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1033,14 +1033,35 @@ def _cross_process_init_lock(path: Path): lock_path = path.with_name(path.name + ".init.lock") handle = lock_path.open("a+b") try: - if not _IS_WINDOWS: + if _IS_WINDOWS: + import msvcrt + + # Lock a single byte in the sidecar file. ``msvcrt.locking`` starts + # at the current file position, so seek explicitly before both + # lock and unlock. The file is opened in append/read binary mode so + # it always exists but the byte-range lock is the synchronization + # primitive; no payload needs to be written. + handle.seek(0) + locking = getattr(msvcrt, "locking") + lock_mode = getattr(msvcrt, "LK_LOCK") + locking(handle.fileno(), lock_mode, 1) + else: import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) yield finally: try: - if not _IS_WINDOWS: + if _IS_WINDOWS: + import msvcrt + + handle.seek(0) + locking = getattr(msvcrt, "locking") + unlock_mode = getattr(msvcrt, "LK_UNLCK") + locking(handle.fileno(), unlock_mode, 1) + else: import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) finally: handle.close() diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 92ac3b7a4a6..9e80fa1c96e 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -5,7 +5,9 @@ from __future__ import annotations import concurrent.futures import os import sqlite3 +import sys import time +import types import unittest.mock from pathlib import Path @@ -65,6 +67,27 @@ def test_connect_honors_kanban_busy_timeout_env(kanban_home, monkeypatch): assert row[0] == 123456 +def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypatch): + """Windows must use a real process lock, not a no-op sidecar open.""" + calls: list[tuple[int, int, int]] = [] + fake_msvcrt = types.SimpleNamespace( + LK_LOCK=1, + LK_UNLCK=2, + locking=lambda fd, mode, nbytes: calls.append((fd, mode, nbytes)), + ) + monkeypatch.setattr(kb, "_IS_WINDOWS", True) + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + + db_path = tmp_path / "kanban.db" + with kb._cross_process_init_lock(db_path): + assert calls == [(calls[0][0], fake_msvcrt.LK_LOCK, 1)] + + assert [call[1:] for call in calls] == [ + (fake_msvcrt.LK_LOCK, 1), + (fake_msvcrt.LK_UNLCK, 1), + ] + + def test_connect_rejects_tls_record_in_sqlite_header(tmp_path, monkeypatch): """Kanban should classify TLS-looking page-0 clobbers before WAL setup.""" home = tmp_path / ".hermes"