mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
250 lines
9.2 KiB
Python
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
|