mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
333 lines
11 KiB
Python
333 lines
11 KiB
Python
import fcntl
|
|
import importlib.util
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable, Sequence
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HELPER = ROOT / "scripts" / "gate_slot_lock.py"
|
|
|
|
_spec = importlib.util.spec_from_file_location("gate_slot_lock", HELPER)
|
|
assert _spec is not None and _spec.loader is not None
|
|
gate_slot_lock = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(gate_slot_lock)
|
|
|
|
START_THEN_WAIT_FOR = (
|
|
"import pathlib, sys, time\n"
|
|
"pathlib.Path(sys.argv[1]).touch()\n"
|
|
"deadline = time.monotonic() + 20\n"
|
|
"while not pathlib.Path(sys.argv[2]).exists():\n"
|
|
" if time.monotonic() > deadline:\n"
|
|
" sys.exit(3)\n"
|
|
" time.sleep(0.05)\n"
|
|
)
|
|
|
|
TOUCH_TARGET = "import pathlib, sys\npathlib.Path(sys.argv[1]).touch()\n"
|
|
|
|
RECORD_INTERVAL = (
|
|
"import sys, time\n"
|
|
"with open(sys.argv[1], 'a') as events:\n"
|
|
" events.write(f'start {time.monotonic()}\\n')\n"
|
|
" events.flush()\n"
|
|
" time.sleep(0.6)\n"
|
|
" events.write(f'end {time.monotonic()}\\n')\n"
|
|
" events.flush()\n"
|
|
)
|
|
|
|
|
|
def _env(lock_dir: Path, slots: str) -> dict[str, str]:
|
|
return {
|
|
"PATH": os.environ["PATH"],
|
|
"HOME": str(lock_dir.parent),
|
|
"LITELLM_GATE_SLOT_DIR": str(lock_dir),
|
|
"LITELLM_GATE_SLOTS": slots,
|
|
}
|
|
|
|
|
|
def _wrapped(payload: Sequence[str]) -> list[str]:
|
|
return [sys.executable, str(HELPER), sys.executable, "-c", *payload]
|
|
|
|
|
|
def _wait_until(predicate: Callable[[], bool], timeout_seconds: float) -> bool:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.05)
|
|
return predicate()
|
|
|
|
|
|
def _terminate_group(process: subprocess.Popen[bytes]) -> None:
|
|
with suppress(ProcessLookupError, PermissionError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
|
|
|
|
def _reap(process: subprocess.Popen[bytes]) -> None:
|
|
with suppress(subprocess.TimeoutExpired):
|
|
process.wait(timeout=10)
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
def test_six_contenders_never_exceed_two_slots_and_all_complete(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
events_file = tmp_path / "events.log"
|
|
env = _env(lock_dir, "2")
|
|
procs = [
|
|
subprocess.Popen(
|
|
_wrapped([RECORD_INTERVAL, str(events_file)]),
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
for _ in range(6)
|
|
]
|
|
try:
|
|
assert [proc.wait(timeout=60) for proc in procs] == [0] * 6
|
|
finally:
|
|
for proc in procs:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
events = sorted(
|
|
(float(stamp), 1 if kind == "start" else -1)
|
|
for kind, stamp in (line.split() for line in events_file.read_text().splitlines())
|
|
)
|
|
assert len(events) == 12
|
|
concurrency_peaks = []
|
|
running = 0
|
|
for _, delta in events:
|
|
running += delta
|
|
concurrency_peaks.append(running)
|
|
assert max(concurrency_peaks) <= 2
|
|
|
|
|
|
def test_two_slots_admit_two_holders_at_once(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
first_started = tmp_path / "first.started"
|
|
second_started = tmp_path / "second.started"
|
|
env = _env(lock_dir, "2")
|
|
first = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(first_started), str(second_started)]), env=env)
|
|
second = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(second_started), str(first_started)]), env=env)
|
|
assert first.wait(timeout=30) == 0
|
|
assert second.wait(timeout=30) == 0
|
|
|
|
|
|
def test_contender_beyond_capacity_queues_until_the_slot_frees(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
holder_started = tmp_path / "holder.started"
|
|
release = tmp_path / "release"
|
|
done = tmp_path / "done"
|
|
env = _env(lock_dir, "1")
|
|
holder = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(holder_started), str(release)]), env=env)
|
|
try:
|
|
assert _wait_until(holder_started.exists, 10)
|
|
contender = subprocess.Popen(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=env,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
try:
|
|
time.sleep(1.5)
|
|
assert not done.exists()
|
|
release.touch()
|
|
assert holder.wait(timeout=10) == 0
|
|
assert contender.wait(timeout=30) == 0
|
|
assert done.exists()
|
|
assert contender.stderr is not None
|
|
assert b"queueing" in contender.stderr.read()
|
|
finally:
|
|
release.touch()
|
|
_reap(contender)
|
|
finally:
|
|
release.touch()
|
|
_reap(holder)
|
|
|
|
|
|
def test_nested_wrapping_reenters_instead_of_deadlocking(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
nested = [
|
|
sys.executable,
|
|
str(HELPER),
|
|
sys.executable,
|
|
str(HELPER),
|
|
sys.executable,
|
|
"-c",
|
|
"print('nested ok')",
|
|
]
|
|
proc = subprocess.Popen(
|
|
nested,
|
|
env=_env(lock_dir, "1"),
|
|
stdout=subprocess.PIPE,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
stdout, _ = proc.communicate(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
_terminate_group(proc)
|
|
pytest.fail("nested gate_slot_lock invocations deadlocked")
|
|
assert proc.returncode == 0
|
|
assert b"nested ok" in stdout
|
|
|
|
|
|
def test_wrapped_command_exit_code_is_propagated(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "raise SystemExit(7)"],
|
|
env=_env(tmp_path / "locks", "2"),
|
|
)
|
|
assert proc.returncode == 7
|
|
|
|
|
|
def test_missing_command_exits_127_and_no_command_exits_2(tmp_path: Path) -> None:
|
|
env = _env(tmp_path / "locks", "2")
|
|
missing = subprocess.run(
|
|
[sys.executable, str(HELPER), str(tmp_path / "no-such-binary")],
|
|
env=env,
|
|
capture_output=True,
|
|
)
|
|
assert missing.returncode == 127
|
|
bare = subprocess.run([sys.executable, str(HELPER)], env=env, capture_output=True)
|
|
assert bare.returncode == 2
|
|
|
|
|
|
def test_wrapped_command_killed_by_signal_maps_to_128_plus_signal(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
_wrapped(["import os, signal\nos.kill(os.getpid(), signal.SIGTERM)\n"]),
|
|
env=_env(tmp_path / "locks", "2"),
|
|
)
|
|
assert proc.returncode == 128 + signal.SIGTERM
|
|
|
|
|
|
def test_unusable_lock_dir_fails_open_and_still_runs_the_command(tmp_path: Path) -> None:
|
|
blocker = tmp_path / "blocker"
|
|
blocker.write_text("")
|
|
done = tmp_path / "done"
|
|
proc = subprocess.run(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=_env(blocker / "locks", "2"),
|
|
capture_output=True,
|
|
)
|
|
assert proc.returncode == 0
|
|
assert done.exists()
|
|
assert b"running unlocked" in proc.stderr
|
|
|
|
|
|
def test_zero_slots_disables_locking_entirely(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
done = tmp_path / "done"
|
|
proc = subprocess.run(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=_env(lock_dir, "0"),
|
|
)
|
|
assert proc.returncode == 0
|
|
assert done.exists()
|
|
assert not lock_dir.exists()
|
|
|
|
|
|
def test_non_integer_slot_count_warns_and_falls_back_to_default(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "print('ran')"],
|
|
env=_env(tmp_path / "locks", "lots"),
|
|
capture_output=True,
|
|
)
|
|
assert proc.returncode == 0
|
|
assert b"ran" in proc.stdout
|
|
assert b"LITELLM_GATE_SLOTS" in proc.stderr
|
|
|
|
|
|
def test_killed_holder_releases_its_slot_for_the_next_contender(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
holder_started = tmp_path / "holder.started"
|
|
never = tmp_path / "never"
|
|
env = _env(lock_dir, "1")
|
|
holder = subprocess.Popen(
|
|
_wrapped([START_THEN_WAIT_FOR, str(holder_started), str(never)]),
|
|
env=env,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
assert _wait_until(holder_started.exists, 10)
|
|
finally:
|
|
_terminate_group(holder)
|
|
holder.wait(timeout=10)
|
|
after = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "print('freed')"],
|
|
env=env,
|
|
capture_output=True,
|
|
timeout=20,
|
|
)
|
|
assert after.returncode == 0
|
|
assert b"freed" in after.stdout
|
|
|
|
|
|
def test_acquire_slot_holds_marks_and_releases_in_process(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_HELD", "")
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_DIR", str(lock_dir))
|
|
monkeypatch.setenv("LITELLM_GATE_SLOTS", "1")
|
|
handle = gate_slot_lock.acquire_slot()
|
|
assert handle is not None
|
|
assert os.environ["LITELLM_GATE_SLOT_HELD"] == "1"
|
|
assert gate_slot_lock.acquire_slot() is None
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
with pytest.raises(BlockingIOError):
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
handle.close()
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(probe, fcntl.LOCK_UN)
|
|
|
|
|
|
def test_held_slot_context_manager_releases_on_exit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_HELD", "")
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_DIR", str(lock_dir))
|
|
monkeypatch.setenv("LITELLM_GATE_SLOTS", "1")
|
|
with gate_slot_lock.held_slot():
|
|
assert os.environ["LITELLM_GATE_SLOT_HELD"] == "1"
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
with pytest.raises(BlockingIOError):
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
assert not os.environ.get("LITELLM_GATE_SLOT_HELD")
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(probe, fcntl.LOCK_UN)
|
|
|
|
|
|
def _make_rule(target: str) -> tuple[list[str], list[str]]:
|
|
database = subprocess.run(
|
|
["make", "--dry-run", "--print-data-base", "info"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout
|
|
lines = database.splitlines()
|
|
for index, line in enumerate(lines):
|
|
if line != f"{target}:" and not line.startswith(f"{target}: "):
|
|
continue
|
|
recipe: list[str] = []
|
|
for follower in lines[index + 1 :]:
|
|
if follower.startswith("#"):
|
|
continue
|
|
if not follower.startswith("\t"):
|
|
break
|
|
recipe.append(follower.strip())
|
|
return line.split(":", 1)[1].split(), recipe
|
|
raise AssertionError(f"target {target} not found in make database")
|
|
|
|
|
|
def test_direct_make_lint_takes_a_slot_before_any_setup() -> None:
|
|
lint_prerequisites, lint_recipe = _make_rule("lint")
|
|
assert lint_prerequisites == []
|
|
assert any("$(GATE_SLOT_LOCK)" in line for line in lint_recipe)
|
|
inner_prerequisites, _ = _make_rule("lint-inner")
|
|
assert "lint-install" in inner_prerequisites
|
|
assert "lint-fetch-base" in inner_prerequisites
|