Skip to content

Commit 8cba8c1

Browse files
committed
refactor(locks): delegate file locking to portalocker
Replace the hand-rolled fcntl/msvcrt flock/funlock (merged in #99) with portalocker, which is fcntl-backed on POSIX and msvcrt/Win32-backed on Windows with maintained, cross-platform-tested behaviour. Removes the hand-written Windows retry/timeout loop that could not be exercised on POSIX. - flock/funlock now delegate to portalocker.lock/unlock. - Drop the guarded 'import fcntl', the msvcrt fallback, and _WINDOWS_LOCK_TIMEOUT. - Keep the os.fchmod guard and Windows directory-fsync skip (atomic writes, which portalocker does not cover). - Pin portalocker==3.2.0 (BSD-3) to match the exact-pin dependency policy. Note: true shared (reader) locks on Windows would still need pywin32; without it portalocker uses msvcrt (exclusive). Not added — in-process concurrent KB reads are rare. Refs #93. Tests: swap the msvcrt-internals tests for a cross-process exclusion test that verifies flock takes a real OS lock, plus the retained atomic-write/fsync-skip guards. Full suite 749 passed.
1 parent 934d246 commit 8cba8c1

4 files changed

Lines changed: 54 additions & 133 deletions

File tree

openkb/locks.py

Lines changed: 10 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,82 +2,37 @@
22
33
The lock protocol is advisory and intended for local filesystem access by
44
OpenKB processes. It does not guarantee cross-host coordination on networked
5-
or synced filesystems where ``fcntl.flock`` may be unavailable or inconsistent.
5+
or synced filesystems where the underlying OS lock may be unavailable or
6+
inconsistent.
67
"""
78
from __future__ import annotations
89

910
import contextlib
1011
import json
11-
import logging
1212
import os
1313
import tempfile
1414
import threading
15-
import time
1615
from pathlib import Path
1716
from typing import IO, Iterator
1817

19-
logger = logging.getLogger(__name__)
20-
21-
try:
22-
import fcntl
23-
except ImportError: # pragma: no cover - Windows has no fcntl (simulated in tests)
24-
fcntl = None
25-
26-
# Upper bound (seconds) on the Windows lock-acquire wait. fcntl.flock blocks in
27-
# the kernel indefinitely; the msvcrt fallback polls, so without a cap a genuine
28-
# error (or a never-released lock) would hang the process forever. Generous by
29-
# default so it never trips on a lock legitimately held through a long compile;
30-
# override via OPENKB_LOCK_TIMEOUT for constrained environments.
31-
_WINDOWS_LOCK_TIMEOUT = float(os.getenv("OPENKB_LOCK_TIMEOUT", "3600"))
18+
import portalocker
3219

3320

3421
def flock(fh: IO, *, exclusive: bool) -> None:
3522
"""Acquire an advisory lock on an open file handle (cross-platform).
3623
37-
Uses ``fcntl.flock`` on POSIX. On Windows (no ``fcntl``) it falls back to
38-
``msvcrt.locking``, which provides only **exclusive** byte-range locks: a
39-
shared (``exclusive=False``) request is taken exclusively. Over-locking is
40-
safe for correctness but does not allow concurrent readers on Windows — and
41-
because the in-process :class:`_LocalRwLock` admits multiple readers, truly
42-
concurrent in-process readers serialise (and wait) on Windows. The blocking
43-
acquire of ``fcntl.flock`` is emulated by retrying the non-blocking lock
44-
with backoff, bounded by ``_WINDOWS_LOCK_TIMEOUT`` so a stuck lock raises
45-
instead of hanging forever.
24+
Delegates to :mod:`portalocker`, which is fcntl-backed on POSIX and
25+
msvcrt/Win32-backed on Windows. The call blocks until the lock is acquired,
26+
matching ``fcntl.flock``. Shared (``exclusive=False``) locks are honoured
27+
where the platform supports them; on Windows they may be taken exclusively
28+
(portalocker handles the platform differences).
4629
"""
47-
if fcntl is not None:
48-
fcntl.flock(fh.fileno(), fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
49-
return
50-
import msvcrt
51-
fh.seek(0)
52-
start = time.monotonic()
53-
delay = 0.05
54-
warned = False
55-
while True:
56-
try:
57-
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
58-
return
59-
except OSError:
60-
elapsed = time.monotonic() - start
61-
if elapsed >= _WINDOWS_LOCK_TIMEOUT:
62-
raise # surface a stuck/never-released lock instead of hanging
63-
if not warned and elapsed >= 5:
64-
logger.warning(
65-
"Still waiting for file lock on %s ...",
66-
getattr(fh, "name", "<lock>"),
67-
)
68-
warned = True
69-
time.sleep(delay)
70-
delay = min(delay * 2, 1.0)
30+
portalocker.lock(fh, portalocker.LOCK_EX if exclusive else portalocker.LOCK_SH)
7131

7232

7333
def funlock(fh: IO) -> None:
7434
"""Release a lock previously acquired with :func:`flock`."""
75-
if fcntl is not None:
76-
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
77-
return
78-
import msvcrt
79-
fh.seek(0)
80-
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
35+
portalocker.unlock(fh)
8136

8237

8338
_LOCKS_GUARD = threading.Lock()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies = [
4444
"json-repair==0.59.10",
4545
"prompt_toolkit==3.0.52",
4646
"rich==15.0.0",
47+
"portalocker==3.2.0",
4748
]
4849

4950
[project.urls]

tests/test_cross_platform_locks.py

Lines changed: 29 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,55 @@
11
"""Cross-platform behaviour for openkb.locks / openkb.config.
22
3-
The locking layer (#86) originally hard-imported ``fcntl`` and called
4-
``os.fchmod`` / directory ``os.fsync`` unconditionally — all Unix-only — which
5-
crashed OpenKB at import time on Windows (``ModuleNotFoundError: No module
6-
named 'fcntl'``, reported in VectifyAI/OpenKB#93). These tests pin the
7-
platform-neutral behaviour and simulate the Windows path on this host.
3+
File locking is delegated to :mod:`portalocker` (fcntl on POSIX, msvcrt/Win32
4+
on Windows), so OpenKB no longer hard-imports the Unix-only ``fcntl``. The
5+
atomic-write path still special-cases the Unix-only ``os.fchmod`` and directory
6+
``os.fsync``. These tests pin the platform-neutral behaviour that is verifiable
7+
on POSIX; portalocker carries its own Windows test coverage.
88
"""
99
from __future__ import annotations
1010

1111
import os
1212
import subprocess
1313
import sys
14-
import types
1514

15+
import portalocker
1616
import pytest
1717

1818
from openkb import locks
1919

2020

21-
def test_config_and_locks_import_without_fcntl():
22-
"""openkb.config / openkb.locks must import on a host without fcntl (Windows)."""
23-
code = (
24-
"import sys\n"
25-
"sys.modules['fcntl'] = None\n" # make `import fcntl` raise ImportError
26-
"import openkb.locks, openkb.config\n"
27-
"assert openkb.locks.fcntl is None\n"
28-
"print('OK')\n"
29-
)
30-
result = subprocess.run(
31-
[sys.executable, "-c", code], capture_output=True, text=True
32-
)
33-
assert result.returncode == 0, result.stderr
34-
assert "OK" in result.stdout
35-
36-
3721
def test_flock_funlock_roundtrip(tmp_path):
38-
"""flock/funlock acquire and release an advisory lock on the real platform."""
22+
"""flock/funlock acquire and release both exclusive and shared locks."""
3923
lock_path = tmp_path / "test.lock"
4024
with lock_path.open("a+", encoding="utf-8") as fh:
4125
locks.flock(fh, exclusive=True)
26+
locks.funlock(fh)
27+
locks.flock(fh, exclusive=False)
4228
locks.funlock(fh) # must not raise
4329

4430

45-
def test_flock_uses_msvcrt_when_fcntl_absent(monkeypatch, tmp_path):
46-
"""When fcntl is unavailable (Windows), locking is delegated to msvcrt."""
47-
calls = []
48-
fake_msvcrt = types.SimpleNamespace(
49-
LK_LOCK=1, LK_NBLCK=2, LK_UNLCK=0,
50-
locking=lambda fd, mode, nbytes: calls.append((mode, nbytes)),
51-
)
52-
monkeypatch.setattr(locks, "fcntl", None)
53-
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
54-
31+
def test_flock_exclusive_blocks_other_process(tmp_path):
32+
"""An exclusive flock is a real OS lock that excludes another process."""
5533
lock_path = tmp_path / "test.lock"
56-
with lock_path.open("a+", encoding="utf-8") as fh:
57-
locks.flock(fh, exclusive=True)
34+
fh = lock_path.open("a+", encoding="utf-8")
35+
locks.flock(fh, exclusive=True)
36+
try:
37+
probe = (
38+
"import portalocker\n"
39+
f"fh = open({str(lock_path)!r}, 'a+')\n"
40+
"try:\n"
41+
" portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB)\n"
42+
" print('ACQUIRED')\n"
43+
"except portalocker.LockException:\n"
44+
" print('BLOCKED')\n"
45+
)
46+
result = subprocess.run(
47+
[sys.executable, "-c", probe], capture_output=True, text=True
48+
)
49+
assert "BLOCKED" in result.stdout, result.stdout + result.stderr
50+
finally:
5851
locks.funlock(fh)
59-
60-
modes = [mode for mode, _ in calls]
61-
assert fake_msvcrt.LK_NBLCK in modes # acquire used the non-blocking lock
62-
assert fake_msvcrt.LK_UNLCK in modes # release unlocked
63-
64-
65-
def test_flock_retries_until_lock_available(monkeypatch, tmp_path):
66-
"""The Windows fallback retries the non-blocking lock until it succeeds."""
67-
attempts = {"n": 0}
68-
69-
def fake_locking(fd, mode, nbytes):
70-
attempts["n"] += 1
71-
if attempts["n"] < 3:
72-
raise OSError("locked") # contention on the first two tries
73-
74-
fake_msvcrt = types.SimpleNamespace(
75-
LK_LOCK=1, LK_NBLCK=2, LK_UNLCK=0, locking=fake_locking
76-
)
77-
monkeypatch.setattr(locks, "fcntl", None)
78-
monkeypatch.setattr(locks, "_WINDOWS_LOCK_TIMEOUT", 5.0)
79-
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
80-
81-
with (tmp_path / "test.lock").open("a+", encoding="utf-8") as fh:
82-
locks.flock(fh, exclusive=True)
83-
84-
assert attempts["n"] == 3 # retried twice, succeeded on the third
85-
86-
87-
def test_flock_raises_after_timeout(monkeypatch, tmp_path):
88-
"""A never-released Windows lock surfaces an error instead of hanging forever."""
89-
def always_locked(fd, mode, nbytes):
90-
raise OSError("locked")
91-
92-
fake_msvcrt = types.SimpleNamespace(
93-
LK_LOCK=1, LK_NBLCK=2, LK_UNLCK=0, locking=always_locked
94-
)
95-
monkeypatch.setattr(locks, "fcntl", None)
96-
monkeypatch.setattr(locks, "_WINDOWS_LOCK_TIMEOUT", 0.2)
97-
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
98-
99-
with (tmp_path / "test.lock").open("a+", encoding="utf-8") as fh:
100-
with pytest.raises(OSError):
101-
locks.flock(fh, exclusive=True)
52+
fh.close()
10253

10354

10455
def test_atomic_write_bytes_without_fchmod(monkeypatch, tmp_path):

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)