Skip to content

Commit 293783b

Browse files
refactor(locks): delegate cross-platform file locking to portalocker (#100)
* 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. * fix(locks): review fixes — accurate docs, fcntl import guard, test hardening Addresses /code-review findings on the portalocker refactor: - flock docstring corrected: portalocker uses Win32 LockFileEx (pywin32, pulled in automatically on Windows) for SHARED locks, so concurrent readers ARE honoured; EXCLUSIVE uses msvcrt (retries ~10s then raises, not an infinite block); failures raise portalocker.LockException, not OSError. - Re-add the issue #93 regression guard: assert no openkb module hard-imports the Unix-only fcntl at module level (replaces the dropped import-without-fcntl test without depending on portalocker internals). - Strengthen the cross-process lock test: assert both BLOCKED (while held) and ACQUIRED (after release), and check the probe's exit code so an ImportError surfaces clearly instead of an empty-stdout false failure. - Drop the now-dead 'import pytest' / 'import portalocker' from the test module. * test(locks): resolve code-quality bot nits — single openkb import style Drop the unused 'import openkb' and derive the package dir from locks.__file__ instead, so the test module uses one import style for openkb (github-code-quality bot). The unused 'import portalocker' was already removed in the prior commit.
1 parent 934d246 commit 293783b

4 files changed

Lines changed: 86 additions & 132 deletions

File tree

openkb/locks.py

Lines changed: 15 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,82 +2,42 @@
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`:
25+
26+
- **POSIX** — ``fcntl.flock``; the call blocks indefinitely until acquired.
27+
- **Windows** — shared locks use the Win32 ``LockFileEx`` API (``pywin32``,
28+
which portalocker pulls in automatically on Windows), so concurrent
29+
readers are honoured; exclusive locks use ``msvcrt.locking``, which
30+
retries for ~10s and then raises rather than blocking indefinitely.
31+
32+
On failure portalocker raises :class:`portalocker.LockException` — note this
33+
is *not* an ``OSError`` (e.g. on filesystems without working lock support).
4634
"""
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)
35+
portalocker.lock(fh, portalocker.LOCK_EX if exclusive else portalocker.LOCK_SH)
7136

7237

7338
def funlock(fh: IO) -> None:
7439
"""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)
40+
portalocker.unlock(fh)
8141

8242

8343
_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: 56 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,83 @@
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 verifiable on
7+
POSIX; portalocker carries its own Windows test coverage.
88
"""
99
from __future__ import annotations
1010

11+
import ast
1112
import os
1213
import subprocess
1314
import sys
14-
import types
15-
16-
import pytest
15+
from pathlib import Path
1716

1817
from openkb import locks
1918

2019

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-
20+
def _module_level_imports_fcntl(path: Path) -> bool:
21+
"""True if the module has a top-level ``import fcntl`` / ``from fcntl import``."""
22+
tree = ast.parse(path.read_text(encoding="utf-8"))
23+
for node in tree.body: # module-level statements only (import-time crash risk)
24+
if isinstance(node, ast.Import) and any(a.name == "fcntl" for a in node.names):
25+
return True
26+
if isinstance(node, ast.ImportFrom) and node.module == "fcntl":
27+
return True
28+
return False
3629

37-
def test_flock_funlock_roundtrip(tmp_path):
38-
"""flock/funlock acquire and release an advisory lock on the real platform."""
39-
lock_path = tmp_path / "test.lock"
40-
with lock_path.open("a+", encoding="utf-8") as fh:
41-
locks.flock(fh, exclusive=True)
42-
locks.funlock(fh) # must not raise
4330

31+
def test_openkb_modules_do_not_hard_import_fcntl():
32+
"""Guards issue #93: OpenKB's own modules must import on Windows (no bare fcntl)."""
33+
pkg_dir = Path(locks.__file__).parent # locks.py lives in the openkb package
34+
offenders = [
35+
str(py.relative_to(pkg_dir))
36+
for py in pkg_dir.rglob("*.py")
37+
if _module_level_imports_fcntl(py)
38+
]
39+
assert not offenders, f"Unix-only fcntl hard-imported at module level in: {offenders}"
4440

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)
5441

42+
def test_flock_funlock_roundtrip(tmp_path):
43+
"""flock/funlock acquire and release both exclusive and shared locks."""
5544
lock_path = tmp_path / "test.lock"
5645
with lock_path.open("a+", encoding="utf-8") as fh:
5746
locks.flock(fh, exclusive=True)
5847
locks.funlock(fh)
48+
locks.flock(fh, exclusive=False)
49+
locks.funlock(fh) # must not raise
5950

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")
9151

92-
fake_msvcrt = types.SimpleNamespace(
93-
LK_LOCK=1, LK_NBLCK=2, LK_UNLCK=0, locking=always_locked
52+
def test_flock_exclusive_excludes_other_process(tmp_path):
53+
"""An exclusive flock is a real OS lock: it excludes another process while
54+
held, and the lock is acquirable again once released."""
55+
lock_path = tmp_path / "test.lock"
56+
probe = (
57+
"import portalocker\n"
58+
f"fh = open({str(lock_path)!r}, 'a+')\n"
59+
"try:\n"
60+
" portalocker.lock(fh, portalocker.LOCK_EX | portalocker.LOCK_NB)\n"
61+
" print('ACQUIRED')\n"
62+
"except portalocker.LockException:\n"
63+
" print('BLOCKED')\n"
9464
)
95-
monkeypatch.setattr(locks, "fcntl", None)
96-
monkeypatch.setattr(locks, "_WINDOWS_LOCK_TIMEOUT", 0.2)
97-
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
9865

99-
with (tmp_path / "test.lock").open("a+", encoding="utf-8") as fh:
100-
with pytest.raises(OSError):
101-
locks.flock(fh, exclusive=True)
66+
def run_probe() -> str:
67+
result = subprocess.run(
68+
[sys.executable, "-c", probe], capture_output=True, text=True
69+
)
70+
assert result.returncode == 0, result.stderr # probe itself ran cleanly
71+
return result.stdout.strip()
72+
73+
fh = lock_path.open("a+", encoding="utf-8")
74+
locks.flock(fh, exclusive=True)
75+
try:
76+
assert run_probe() == "BLOCKED" # held → other process is excluded
77+
finally:
78+
locks.funlock(fh)
79+
fh.close()
80+
assert run_probe() == "ACQUIRED" # released → other process can acquire
10281

10382

10483
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)