Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
.PHONY: help test test-unit test-unit-llms test-unit-proxy-guardrails test-unit-proxy-core test-unit-proxy-misc \
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
info lint lint-dev lint-checks format \
info lint lint-inner lint-dev lint-checks format \
lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \
lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety check pre-commit \
lint-install lint-fetch-base bootstrap
install-helm-unittest check-circular-imports check-import-safety check check-inner pre-commit \
lint-install lint-fetch-base bootstrap bootstrap-inner

# Default target
help:
Expand Down Expand Up @@ -52,10 +52,17 @@ help:
@echo " make test-proxy-unit-b - Run proxy_unit_tests (p-z, ~28 files)"
@echo " make test-integration - Run integration tests"
@echo " make test-unit-helm - Run helm unit tests"
@echo ""
@echo "Heavy targets (check, bootstrap, lint) queue for LITELLM_GATE_SLOTS machine-wide"
@echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine."

UV := uv
UV_RUN := $(UV) run --no-sync

# Machine-wide slot queue for the heavy targets below; python3 + stdlib only, so
# it runs before any venv exists. See scripts/gate_slot_lock.py.
GATE_SLOT_LOCK := python3 scripts/gate_slot_lock.py

LINT_DEP_INSTALL ?= install-dev
LINT_E2E_DEP_INSTALL ?= lint-install
LINT_DEP_BASE ?= lint-fetch-base
Expand All @@ -74,6 +81,9 @@ install-dev:
$(UV) sync --inexact --frozen

bootstrap:
@$(GATE_SLOT_LOCK) $(MAKE) bootstrap-inner

bootstrap-inner:
$(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev
$(UV_RUN) python scripts/prisma_generate_if_needed.py
cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund
Expand Down Expand Up @@ -229,7 +239,10 @@ check-import-safety: $(LINT_DEP_INSTALL)
# does (merge-base with origin/litellm_internal_staging). Setup (env sync, Prisma client,
# base fetch) runs once up front; the checks themselves are independent, so a sub-make
# fans them out with -j and the fast ones finish under basedpyright's shadow.
lint: lint-install lint-fetch-base
lint:
@$(GATE_SLOT_LOCK) $(MAKE) lint-inner

lint-inner: lint-install lint-fetch-base
$(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks

lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
Expand All @@ -244,7 +257,10 @@ lint-dev: lint-format-changed check-circular-imports check-import-safety
# test-linting.yml (Python), test-litellm-ui-build.yml's frontend-lint (dashboard), and
# check-ui-api-types.yml (API-type drift), skipping any whose files aren't in scope.
# Not auto-installed as a git hook so it never slows an unrelated human commit.
check: bootstrap
check:
@$(GATE_SLOT_LOCK) $(MAKE) check-inner

check-inner: bootstrap
./scripts/pre_commit_lint.sh

pre-commit:
Expand Down
173 changes: 173 additions & 0 deletions scripts/gate_slot_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Machine-wide slot lock for this repo's heavy entrypoints.

`make check`, `make bootstrap`, `make lint`, and the standalone budget gates
(scripts/ruff_strict_gate.py, scripts/type_discipline_gate.py,
scripts/type_check_gate.py) each hold one of N machine-wide slots while they
run, so however many sessions and worktrees share one machine, at most N of
them execute a basedpyright/pytest/prettier storm at a time instead of all
thrashing it at once. Slots are fcntl.flock files (macOS ships no flock(1)
binary, hence python3 + stdlib only, runnable before any venv exists) under a
per-user cache directory shared by every worktree and session:
~/.cache/litellm/gate-slots by default, $LITELLM_GATE_SLOT_DIR to override.
A holder's lock dies with its process, so a crash leaves nothing to clean up.

$LITELLM_GATE_SLOTS sets the slot count (default 2); 0 disables locking.
Waiting is a blocking flock on a turnstile file plus a slow poll of the slots,
so contenders queue roughly first-come-first-served without busy-spinning.
A process that acquired (or deliberately skipped) a slot exports
LITELLM_GATE_SLOT_HELD, and nested acquisitions under that marker are no-ops,
so `make check` invoking the gates internally can never deadlock against
itself. Any filesystem error fails open and the command runs unlocked: the
lock is a courtesy to the machine, never a gate that may break a build (CI
runs one job per machine, so there it only ever takes the instant path).

CLI: python3 scripts/gate_slot_lock.py <command> [args...]
"""

from __future__ import annotations

import contextlib
import fcntl
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import IO, TYPE_CHECKING, Final

if TYPE_CHECKING:
from collections.abc import Iterator

HELD_MARKER_ENV: Final = "LITELLM_GATE_SLOT_HELD"
SLOT_COUNT_ENV: Final = "LITELLM_GATE_SLOTS"
SLOT_DIR_ENV: Final = "LITELLM_GATE_SLOT_DIR"
DEFAULT_SLOT_COUNT: Final = 2
POLL_SECONDS: Final = 2.0


def _slot_dir() -> Path:
override: Final = os.environ.get(SLOT_DIR_ENV)
return Path(override) if override else Path.home() / ".cache" / "litellm" / "gate-slots"


def _slot_count() -> int:
raw: Final = os.environ.get(SLOT_COUNT_ENV)
if not raw:
return DEFAULT_SLOT_COUNT
try:
return int(raw)
except ValueError:
print(
f"gate_slot_lock: ignoring non-integer {SLOT_COUNT_ENV}={raw!r}; "
f"using {DEFAULT_SLOT_COUNT} slots",
file=sys.stderr,
)
return DEFAULT_SLOT_COUNT


def _try_slot(directory: Path, index: int) -> IO[bytes] | None:
handle: Final = (directory / f"slot-{index}.lock").open("wb")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
handle.close()
return None
except OSError:
handle.close()
raise
return handle


def _wait_for_slot(directory: Path, count: int) -> IO[bytes]:
print(
f"gate_slot_lock: all {count} machine-wide slots are busy; queueing "
f"(set {SLOT_COUNT_ENV}=0 to disable)",
file=sys.stderr,
flush=True,
)
with (directory / "turnstile.lock").open("wb") as turnstile:
fcntl.flock(turnstile, fcntl.LOCK_EX)
while True:
for index in range(count):
held = _try_slot(directory, index)
if held is not None:
return held
time.sleep(POLL_SECONDS)


def _locked_handle(count: int) -> IO[bytes]:
directory: Final = _slot_dir()
directory.mkdir(parents=True, exist_ok=True)
for index in range(count):
immediate = _try_slot(directory, index)
if immediate is not None:
return immediate
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return _wait_for_slot(directory, count)


def acquire_slot() -> IO[bytes] | None:
"""Hold a machine-wide slot for the life of the returned handle.

The caller must keep the handle referenced until the process exits;
dropping it closes the file and releases the slot. Returns None without
locking when this process already runs under a held slot, when locking is
disabled, or when the filesystem refuses to cooperate."""
if os.environ.get(HELD_MARKER_ENV):
return None
count: Final = _slot_count()
if count <= 0:
os.environ[HELD_MARKER_ENV] = "1"
return None
try:
handle: Final = _locked_handle(count)
except (OSError, RuntimeError) as error:
print(f"gate_slot_lock: locking unavailable ({error}); running unlocked", file=sys.stderr)
os.environ[HELD_MARKER_ENV] = "1"
return None
os.environ[HELD_MARKER_ENV] = "1"
return handle


@contextlib.contextmanager
def held_slot() -> Iterator[None]:
"""Run the with-block while holding a machine-wide slot (or its no-op forms)."""
prior_marker: Final = os.environ.get(HELD_MARKER_ENV)
handle: Final = acquire_slot()
try:
yield
finally:
if handle is not None:
handle.close()
if not prior_marker:
os.environ.pop(HELD_MARKER_ENV, None)


def _wait_ignoring_interrupts(process: subprocess.Popen[bytes]) -> int:
while True:
try:
return process.wait()
except KeyboardInterrupt:
continue


def main() -> int:
if len(sys.argv) < 2:
print("usage: gate_slot_lock.py <command> [args...]", file=sys.stderr)
return 2
try:
held: Final = acquire_slot()
except KeyboardInterrupt:
return 130
try:
code: Final = _wait_ignoring_interrupts(subprocess.Popen(sys.argv[1:]))
except FileNotFoundError as error:
print(f"gate_slot_lock: {error}", file=sys.stderr)
return 127
if held is not None:
held.close()
return code if code >= 0 else 128 - code


if __name__ == "__main__":
sys.exit(main())
10 changes: 10 additions & 0 deletions scripts/pre_commit_lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@

set -eu

# Queue for one of the machine-wide heavy-work slots (see scripts/gate_slot_lock.py)
# before anything else, so N parallel `make check` runs across worktrees execute two
# at a time instead of thrashing the machine. The wrapper exports
# LITELLM_GATE_SLOT_HELD, so this re-exec happens exactly once and everything this
# script spawns (make lint, the budget gates) skips its own acquisition.
if [ -z "${LITELLM_GATE_SLOT_HELD:-}" ]; then
script_dir=$(python3 -c 'import os, sys; print(os.path.dirname(os.path.realpath(sys.argv[1])))' "$0")
exec python3 "$script_dir/gate_slot_lock.py" "$0" "$@"
fi

if [ -z "${PRE_COMMIT_LINT_INNER:-}" ]; then
log_file=$(git rev-parse --path-format=absolute --git-path pre_commit_lint.log)
if : > "$log_file" 2>/dev/null; then
Expand Down
5 changes: 4 additions & 1 deletion scripts/ruff_strict_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,10 @@ def main() -> None:
parser.add_argument("--base", default=DEFAULT_BASE)
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
cmd_update(args.base) if args.update else cmd_check(args.base)
from gate_slot_lock import held_slot

with held_slot():
cmd_update(args.base) if args.update else cmd_check(args.base)


if __name__ == "__main__":
Expand Down
23 changes: 13 additions & 10 deletions scripts/type_check_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,16 +670,19 @@ def main() -> None:
parser.add_argument("--update", action="store_true")
parser.add_argument("--emit-counts-dir", type=Path)
args = parser.parse_args()
ensure_typecheck_env()
head = count_basedpyright(run_basedpyright())
if args.emit_counts_dir is not None:
cmd_emit_counts(
head, args.emit_counts_dir, _run(["git", "rev-parse", "HEAD"]).strip()
)
elif args.update:
cmd_update(head, args.base)
else:
cmd_check(head, args.base)
from gate_slot_lock import held_slot

with held_slot():
ensure_typecheck_env()
head = count_basedpyright(run_basedpyright())
if args.emit_counts_dir is not None:
cmd_emit_counts(
head, args.emit_counts_dir, _run(["git", "rev-parse", "HEAD"]).strip()
)
elif args.update:
cmd_update(head, args.base)
else:
cmd_check(head, args.base)


if __name__ == "__main__":
Expand Down
5 changes: 4 additions & 1 deletion scripts/type_discipline_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,10 @@ def main() -> None:
parser.add_argument("--base", default=DEFAULT_BASE)
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
cmd_update(args.base) if args.update else cmd_check(args.base)
from gate_slot_lock import held_slot

with held_slot():
cmd_update(args.base) if args.update else cmd_check(args.base)


if __name__ == "__main__":
Expand Down
Loading
Loading