deer-flow/backend/tests/test_channel_runtime_config_store_lock.py
Felix Shi 737d50f625
fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows (#6888)
* fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows

The runtime channel config sidecar lock (#6844) used Windows' blocking msvcrt LK_LOCK mode, which gives up after roughly ten seconds and surfaces cross-worker contention as Permission denied instead of serializing the read-modify-write cycles it exists to serialize, while fcntl.flock(LOCK_EX) waits on POSIX. Retry the non-blocking acquisition (LK_NBLCK + 0.2s) on the Windows fallback, mirroring the extensions manager's lock (#6020), and bind fcntl/msvcrt at module level so the fallback branch is testable.

Tests: new backend/tests/test_channel_runtime_config_store_lock.py (5 tests: retry cadence via fake msvcrt, sidecar byte creation, POSIX flock preservation, direct Windows regression through the sys.modules seam, real cross-thread contention). 3 of them fail on the unmodified base (one with OSError 36 raised by the blocking call itself) and all pass with the fix. Related suites: store peers + connections router 86 passed / 1 skipped; blocking_io + encoding + channels 498 passed / 2 skipped. Ruff format and check clean on both files. Windows 11, Python 3.12 (uv-managed venv).

* fix(channels): share contention retries and diagnose prolonged lock waits

* docs(channels): shorten guidance to satisfy lint budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 11:05:04 +08:00

250 lines
9.2 KiB
Python

"""Behavior tests for the cross-process file lock in the runtime config store.
Windows' blocking lock mode (``msvcrt.LK_LOCK``) gives up after roughly ten
seconds and surfaces contention as ``Permission denied``, while the whole
point of the sidecar lock is to serialize concurrent Gateway workers'
read-modify-write cycles of one store file. The Windows fallback must retry
the non-blocking acquisition until the region frees, mirroring the extensions
manager's lock.
"""
from __future__ import annotations
import errno
import logging
import os
import sys
import threading
import time
from types import SimpleNamespace
import pytest
from app.channels import runtime_config_store
from app.channels.runtime_config_store import ChannelRuntimeConfigStore, _runtime_config_file_lock
TELEGRAM = {"enabled": True, "bot_token": "tg-token"}
class _ContendedFakeMsvcrt:
"""Fake ``msvcrt`` whose lock region stays busy for the first N attempts.
Records every ``locking`` call so tests can assert the retry cadence and
the final unlock, independently of the real Windows semantics.
"""
LK_UNLCK = 0
LK_LOCK = 1
LK_NBLCK = 2
def __init__(self, fail_times: int, error_number: int = errno.EDEADLK) -> None:
self.fail_times = fail_times
self.error_number = error_number
self.attempts: list[int] = []
self.unlocked = 0
def locking(self, fd: int, mode: int, nbytes: int) -> None:
self.attempts.append(mode)
if mode == self.LK_UNLCK:
self.unlocked += 1
return None
blocking_attempts = len(self.attempts) - self.unlocked
if blocking_attempts <= self.fail_times:
raise OSError(self.error_number, "Lock contended")
return None
class _BlockingModeFakeMsvcrt:
"""Fake ``msvcrt`` that fails only the blocking ``LK_LOCK`` mode.
Models a region contended past the blocking mode's patience: the
unpatched module calls ``LK_LOCK`` once and fails, while a retry loop
succeeds on its first non-blocking attempt.
"""
LK_UNLCK = 0
LK_LOCK = 1
LK_NBLCK = 2
def __init__(self) -> None:
self.attempts: list[int] = []
self.unlocked = 0
def locking(self, fd: int, mode: int, nbytes: int) -> None:
self.attempts.append(mode)
if mode == self.LK_UNLCK:
self.unlocked += 1
return None
if mode == self.LK_LOCK:
raise OSError(errno.EDEADLK, "Resource deadlock avoided")
return None
@pytest.mark.parametrize("error_number", [errno.EACCES, errno.EAGAIN, errno.EDEADLK])
def test_file_lock_retries_until_contention_clears(tmp_path, monkeypatch, caplog, error_number) -> None:
"""Contention is retried on the Windows fallback instead of failing."""
fake = _ContendedFakeMsvcrt(fail_times=3, error_number=error_number)
monkeypatch.setattr(runtime_config_store, "fcntl", None, raising=False)
monkeypatch.setattr(runtime_config_store, "msvcrt", fake, raising=False)
sleeps: list[float] = []
monkeypatch.setattr(runtime_config_store, "time", SimpleNamespace(sleep=sleeps.append))
entered = False
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
entered = True
assert entered
assert fake.attempts == [fake.LK_NBLCK] * 4 + [fake.LK_UNLCK]
assert fake.unlocked == 1
assert sleeps == [0.2] * 3
assert not caplog.records
@pytest.mark.parametrize("error_number", [errno.EBADF, errno.EINVAL, errno.EIO, errno.EPERM, errno.ENOSYS, None])
def test_file_lock_propagates_permanent_errors_without_waiting_or_unlocking(tmp_path, monkeypatch, error_number) -> None:
primary = OSError(error_number, "Lock unavailable")
calls = []
sleeps = []
def locking(fd, mode, size):
calls.append(mode)
if len(calls) > 1:
pytest.fail("A failed acquisition must not be retried or unlocked")
raise primary
monkeypatch.setattr(runtime_config_store, "fcntl", None)
monkeypatch.setattr(runtime_config_store, "msvcrt", SimpleNamespace(LK_NBLCK=2, LK_UNLCK=0, locking=locking), raising=False)
monkeypatch.setattr(runtime_config_store, "time", SimpleNamespace(sleep=sleeps.append))
with pytest.raises(OSError) as caught:
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
pytest.fail("A failed acquisition must not enter the protected section")
assert caught.value is primary
assert calls == [2]
assert sleeps == []
@pytest.mark.parametrize("fail_times", [49, 50, 100])
def test_file_lock_warns_once_for_prolonged_contention(tmp_path, monkeypatch, caplog, fail_times) -> None:
fake = _ContendedFakeMsvcrt(fail_times=fail_times)
sleeps = []
monkeypatch.setattr(runtime_config_store, "fcntl", None)
monkeypatch.setattr(runtime_config_store, "msvcrt", fake, raising=False)
monkeypatch.setattr(runtime_config_store, "time", SimpleNamespace(sleep=sleeps.append))
with caplog.at_level(logging.WARNING, logger=runtime_config_store.__name__):
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
pass
warnings = [record for record in caplog.records if record.name == runtime_config_store.__name__]
assert len(warnings) == (1 if fail_times >= 50 else 0)
if warnings:
assert str(tmp_path / ".runtime-config.json.lock") in warnings[0].getMessage()
assert "50" in warnings[0].getMessage()
assert sleeps == [0.2] * fail_times
assert fake.unlocked == 1
def test_file_lock_diagnostic_failure_does_not_abort_the_wait(tmp_path, monkeypatch) -> None:
fake = _ContendedFakeMsvcrt(fail_times=51)
warnings = []
sleeps = []
def failing_warning(*args, **kwargs):
warnings.append(args)
raise RuntimeError("Log handler failed")
monkeypatch.setattr(runtime_config_store, "fcntl", None)
monkeypatch.setattr(runtime_config_store, "msvcrt", fake, raising=False)
monkeypatch.setattr(runtime_config_store, "time", SimpleNamespace(sleep=sleeps.append))
monkeypatch.setattr(runtime_config_store.logger, "warning", failing_warning)
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
pass
assert len(warnings) == 1
assert sleeps == [0.2] * 51
assert fake.unlocked == 1
def test_file_lock_creates_the_sidecar_byte_on_the_windows_fallback(tmp_path, monkeypatch) -> None:
"""The empty sidecar file still gets its lockable byte before locking."""
fake = _ContendedFakeMsvcrt(fail_times=0)
monkeypatch.setattr(runtime_config_store, "fcntl", None, raising=False)
monkeypatch.setattr(runtime_config_store, "msvcrt", fake, raising=False)
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
pass
assert (tmp_path / ".runtime-config.json.lock").read_bytes() == b"\0"
def test_file_lock_posix_branch_still_flocks(tmp_path, monkeypatch) -> None:
"""The POSIX branch keeps using ``fcntl.flock`` unchanged."""
class _FakeFcntl:
LOCK_EX = 2
LOCK_UN = 8
def __init__(self) -> None:
self.ops: list[tuple[int, int]] = []
def flock(self, fd: int, operation: int) -> None:
self.ops.append((fd, operation))
fake_fcntl = _FakeFcntl()
monkeypatch.setattr(runtime_config_store, "fcntl", fake_fcntl, raising=False)
entered = False
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
entered = True
assert entered
assert [operation for _, operation in fake_fcntl.ops] == [_FakeFcntl.LOCK_EX, _FakeFcntl.LOCK_UN]
@pytest.mark.skipif(os.name != "nt", reason="exercises the Windows import path directly")
def test_windows_fallback_survives_contention_the_blocking_mode_cannot(tmp_path, monkeypatch) -> None:
"""Direct regression: the fallback retries contention instead of failing.
``sys.modules`` catches the function-local ``import msvcrt`` the unpatched
module performs; the module attribute covers the fixed module's global
binding, so this test demonstrates the base failure mode (one ``LK_LOCK``
call raising ``OSError``) and the fixed retry behavior on either structure.
"""
fake = _BlockingModeFakeMsvcrt()
monkeypatch.setitem(sys.modules, "msvcrt", fake)
monkeypatch.setattr(runtime_config_store, "msvcrt", fake, raising=False)
with _runtime_config_file_lock(tmp_path / "runtime-config.json"):
pass
assert fake.attempts == [fake.LK_NBLCK, fake.LK_UNLCK]
assert fake.unlocked == 1
def test_save_serializes_with_a_peer_holding_the_real_lock(tmp_path) -> None:
"""A real peer holding the sidecar lock delays the save instead of failing it.
The advisory file lock is per open file, so two lock contexts on one path
exclude each other inside this process, as the peer tests for #6844 note.
"""
store_path = tmp_path / "channels" / "runtime-config.json"
store_path.parent.mkdir(parents=True, exist_ok=True)
holder_ready = threading.Event()
def hold():
with _runtime_config_file_lock(store_path):
holder_ready.set()
time.sleep(0.6)
holder = threading.Thread(target=hold)
holder.start()
assert holder_ready.wait(timeout=5)
ChannelRuntimeConfigStore(store_path).set_provider_config("telegram", TELEGRAM)
holder.join(timeout=5)
assert ChannelRuntimeConfigStore(store_path).get_provider_config("telegram") == TELEGRAM