Skip to content

Commit 934d246

Browse files
fix(locks): cross-platform file locking — fix Windows fcntl crash (#99)
* fix(locks): make file locking cross-platform (Windows support) openkb/locks.py and openkb/config.py hard-imported fcntl and called os.fchmod / directory os.fsync unconditionally — all Unix-only — so OpenKB crashed at import on Windows (ModuleNotFoundError: No module named 'fcntl'), surfaced in #93 once the Copilot extra_headers fix (#98) unblocked that user. - locks.flock/funlock: advisory-lock helpers — fcntl on POSIX, msvcrt byte-range locks on Windows (exclusive-only; shared degrades to exclusive, fcntl's blocking acquire emulated via non-blocking retry). - guard os.fchmod with hasattr; skip directory fsync on Windows (os.replace is already atomic on NTFS). - config.py drops its direct fcntl import and uses locks.flock/funlock. Adds tests/test_cross_platform_locks.py: simulates the no-fcntl (Windows) path on POSIX via subprocess + a faked msvcrt. Refs #93 * fix(locks): harden Windows lock-acquire loop (code review) Addresses review findings on the msvcrt fallback: - flock's Windows retry loop no longer spins forever: bound the wait by _WINDOWS_LOCK_TIMEOUT (default 3600s, OPENKB_LOCK_TIMEOUT override) so a stuck/never-released lock or a non-contention OSError surfaces as an error instead of an infinite, silent busy-loop. Add exponential backoff (was a fixed 100ms spin) and a one-time 'still waiting' warning. - Document that shared locks degrade to exclusive on Windows (msvcrt has no shared mode), so concurrent in-process readers serialise there. - Correct the _fsync_directory comment to not conflate NTFS atomicity with crash durability. Tests: cover the retry-until-available and raise-after-timeout paths (the previously-uncovered msvcrt contention logic), runnable on POSIX via a faked msvcrt. Full suite 752 passed.
1 parent 06a65c5 commit 934d246

3 files changed

Lines changed: 199 additions & 10 deletions

File tree

openkb/config.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
from __future__ import annotations
22

33
import contextlib
4-
import fcntl
54
import logging
65
import re
76
from pathlib import Path
87
from typing import Any, Iterator
98

109
import yaml
1110

12-
from openkb.locks import atomic_write_text
11+
from openkb.locks import atomic_write_text, flock, funlock
1312

1413
logger = logging.getLogger(__name__)
1514

@@ -34,11 +33,11 @@
3433
def _with_global_config_lock() -> Iterator[None]:
3534
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
3635
with GLOBAL_CONFIG_LOCK_PATH.open("a+", encoding="utf-8") as fh:
37-
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
36+
flock(fh, exclusive=True)
3837
try:
3938
yield
4039
finally:
41-
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
40+
funlock(fh)
4241

4342

4443
def _atomic_yaml_dump(path: Path, config: dict[str, Any]) -> None:

openkb/locks.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,78 @@
77
from __future__ import annotations
88

99
import contextlib
10-
import fcntl
1110
import json
11+
import logging
1212
import os
1313
import tempfile
1414
import threading
15+
import time
1516
from pathlib import Path
16-
from typing import Iterator
17+
from typing import IO, Iterator
18+
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"))
32+
33+
34+
def flock(fh: IO, *, exclusive: bool) -> None:
35+
"""Acquire an advisory lock on an open file handle (cross-platform).
36+
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.
46+
"""
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)
71+
72+
73+
def funlock(fh: IO) -> None:
74+
"""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)
81+
1782

1883
_LOCKS_GUARD = threading.Lock()
1984
_LOCAL_LOCKS: dict[Path, "_LocalRwLock"] = {}
@@ -106,14 +171,13 @@ def kb_lock(openkb_dir: Path, *, exclusive: bool) -> Iterator[None]:
106171
local_context = local_lock.write() if exclusive else local_lock.read()
107172
with local_context:
108173
with lock_path.open("a+", encoding="utf-8") as fh:
109-
mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
110-
fcntl.flock(fh.fileno(), mode)
174+
flock(fh, exclusive=exclusive)
111175
held[resolved] = (1, 0) if exclusive else (0, 1)
112176
try:
113177
yield
114178
finally:
115179
held.pop(resolved, None)
116-
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
180+
funlock(fh)
117181

118182

119183
def kb_ingest_lock(openkb_dir: Path):
@@ -127,6 +191,11 @@ def kb_read_lock(openkb_dir: Path):
127191

128192

129193
def _fsync_directory(path: Path) -> None:
194+
if os.name == "nt":
195+
# Windows cannot open a directory handle to fsync it. os.replace is
196+
# atomic on NTFS (no torn/partial state), though without the dir flush
197+
# the rename's durability across a crash is weaker than on POSIX.
198+
return
130199
fd = os.open(path, os.O_RDONLY)
131200
try:
132201
os.fsync(fd)
@@ -154,7 +223,8 @@ def atomic_write_bytes(path: Path, content: bytes) -> None:
154223
tmp_path = Path(tmp_name)
155224
try:
156225
with os.fdopen(fd, "wb") as fh:
157-
os.fchmod(fh.fileno(), _target_mode(path))
226+
if hasattr(os, "fchmod"): # not available on Windows
227+
os.fchmod(fh.fileno(), _target_mode(path))
158228
fh.write(content)
159229
fh.flush()
160230
os.fsync(fh.fileno())

tests/test_cross_platform_locks.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Cross-platform behaviour for openkb.locks / openkb.config.
2+
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.
8+
"""
9+
from __future__ import annotations
10+
11+
import os
12+
import subprocess
13+
import sys
14+
import types
15+
16+
import pytest
17+
18+
from openkb import locks
19+
20+
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+
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
43+
44+
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+
55+
lock_path = tmp_path / "test.lock"
56+
with lock_path.open("a+", encoding="utf-8") as fh:
57+
locks.flock(fh, exclusive=True)
58+
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)
102+
103+
104+
def test_atomic_write_bytes_without_fchmod(monkeypatch, tmp_path):
105+
"""atomic_write_bytes must still work where os.fchmod is missing (Windows)."""
106+
monkeypatch.delattr(os, "fchmod", raising=False)
107+
target = tmp_path / "data.bin"
108+
locks.atomic_write_bytes(target, b"hello")
109+
assert target.read_bytes() == b"hello"
110+
111+
112+
def test_fsync_directory_skipped_on_windows(monkeypatch, tmp_path):
113+
"""Directory fsync (unsupported on Windows) must be skipped, not attempted."""
114+
monkeypatch.setattr(os, "name", "nt")
115+
116+
def _no_open(*args, **kwargs):
117+
raise AssertionError("os.open must not be called for dir fsync on Windows")
118+
119+
monkeypatch.setattr(os, "open", _no_open)
120+
locks._fsync_directory(tmp_path) # must return without touching os.open

0 commit comments

Comments
 (0)