litellm/tests/integration/_support/redis_process.py
yuneng-jiang b602209ca6
test(integration): hand every harness server a port from one per-worker block (#46761)
* test(integration): hand every harness server a port from one per-worker block

About 45 places in the integration harness picked a port by binding port 0,
reading the number back, closing the socket and starting a server on that
number later. Another process could take the port in between, which surfaced
as "Address already in use" flakes. Earlier fixes retried around the race in
single call sites without removing it.

tests/integration/_support/ports.py now owns every port the harness picks
ahead of a server. Each pytest process claims a 200-port block between 10000
and 32767 by taking an exclusive flock on a per-block lock file in the system
temp directory, so parallel runs on one machine never share a block and the
lock goes away with the process. The range is above the fixed service ports
and below the Linux and macOS ephemeral ranges. Ports go out in order, are
never reused in a run, skip anything an outside process already holds, and
a process that exhausts its block claims the next free one.

The three _free_port copies, worker_endpoints' port-0 binds,
free_loopback_port, the owned proxy's lost-port-race retry and the owned
Redis retry loop are gone. Tests that restart a server on the same port now
allocate that port up front instead of reading it back from the first
server. TQ012 in scripts/check_test_quality.py rejects a port-0 bind read
back with getsockname anywhere under tests/integration.

* ci(integration): record listening sockets and the ephemeral port range

* Revert "ci(integration): record listening sockets and the ephemeral port range"

This reverts commit d3fafc3da2.
2026-10-15 16:56:40 -07:00

188 lines
6.9 KiB
Python

import os
import shutil
import signal
import subprocess
import time
import uuid
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final, TextIO
from redis import Redis
from redis.exceptions import ConnectionError as RedisConnectionError
from tests.integration._support.ports import allocate_port
@dataclass
class OwnedRedis:
host: str
port: int
command: tuple[str, ...]
log: TextIO
pid_file: str
process: subprocess.Popen | None = None
server_pid: int | None = None
def serves(self) -> bool:
with Redis(host=self.host, port=self.port, socket_connect_timeout=0.2, socket_timeout=0.2) as client:
try:
return bool(client.ping())
except RedisConnectionError:
return False
def owned_pid(self) -> int | None:
assert self.process is not None
if self.command[0] != "docker":
return self.process.pid
result: Final = subprocess.run(
["docker", "exec", "redis-cache", "cat", self.pid_file],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode != 0:
return None
return int(result.stdout)
def start(self) -> None:
assert self.process is None
assert not self.serves(), f"Another Redis already serves {self.host}:{self.port} before the owned one starts"
process: Final = subprocess.Popen(
self.command, stdout=self.log, stderr=subprocess.STDOUT, start_new_session=True
)
self.process = process
deadline: Final = time.monotonic() + 8
with Redis(host=self.host, port=self.port, socket_connect_timeout=0.2, socket_timeout=0.2) as client:
while True:
code: Final = process.poll()
if code is not None:
self.process = None
raise AssertionError(
f"Owned Redis exited with code {code} before readiness on {self.host}:{self.port}"
)
try:
if client.ping():
actual: Final = int(client.info("server")["process_id"])
expected: Final = self.owned_pid()
if actual == expected:
self.server_pid = actual
return
try:
process.wait(timeout=max(deadline - time.monotonic(), 0))
except subprocess.TimeoutExpired as error:
raise AssertionError("Redis readiness reached a different process") from error
self.process = None
raise AssertionError(f"A different Redis answered on {self.host}:{self.port} during startup")
except RedisConnectionError:
pass
assert time.monotonic() < deadline, "Owned Redis readiness deadline exceeded"
time.sleep(0.05)
def stop(self) -> None:
assert self.process is not None
failure = None
forced = False
try:
if self.process.poll() is None:
with Redis(host=self.host, port=self.port, socket_connect_timeout=1, socket_timeout=1) as client:
assert int(client.info("server")["process_id"]) == self.server_pid, (
"Redis ownership changed before shutdown"
)
client.shutdown(nosave=True)
except Exception as error:
failure = error
finally:
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
forced = True
self.signal(signal.SIGTERM)
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.signal(signal.SIGKILL)
self.process.wait(timeout=3)
self.process = None
self.server_pid = None
assert not self.serves(), "Owned Redis still serves after shutdown"
assert failure is None and not forced, f"Owned Redis required shutdown recovery: {failure!r}"
def signal(self, action: signal.Signals) -> None:
assert self.process is not None
if self.command[0] != "docker":
self.process.send_signal(action)
return
pid: Final = int(subprocess.check_output(["docker", "exec", "redis-cache", "cat", self.pid_file], timeout=2))
command: Final = subprocess.check_output(
["docker", "exec", "redis-cache", "cat", f"/proc/{pid}/cmdline"], timeout=2
)
assert self.pid_file.encode() in command, "Redis process ownership changed"
subprocess.run(["docker", "exec", "redis-cache", "kill", f"-{int(action)}", str(pid)], check=True, timeout=2)
@contextmanager
def paused(self) -> Iterator[None]:
self.signal(signal.SIGSTOP)
try:
yield
finally:
self.signal(signal.SIGCONT)
@contextmanager
def owned_redis(
directory: Path,
choose_port: Callable[[], int] = allocate_port,
inherited: Mapping[str, str] = os.environ,
) -> Iterator[OwnedRedis]:
binary: Final = shutil.which("redis-server")
if binary:
host: Final = "127.0.0.1"
prefix: Final = (binary,)
else:
host: Final = subprocess.check_output(
[
"docker",
"inspect",
"--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
"redis-cache",
],
text=True,
).strip()
assert host, "CircleCI owned Redis container has no address"
prefix: Final = ("docker", "exec", "redis-cache", "redis-server")
output: Final = Path(inherited.get("INTEGRATION_RESULTS_DIR", str(directory)))
output.mkdir(parents=True, exist_ok=True)
directory.mkdir(parents=True, exist_ok=True)
with (output / f"owned-redis-{uuid.uuid4().hex}.log").open("w") as log:
port: Final = choose_port()
pid_file: Final = (
str(directory / f"owned-redis-{port}.pid") if binary else f"/tmp/integration-redis-{uuid.uuid4().hex}.pid"
)
command: Final = (
*prefix,
"--port",
str(port),
"--set-proc-title",
"no",
"--pidfile",
pid_file,
"--bind",
"0.0.0.0" if not binary else "127.0.0.1",
"--protected-mode",
"no",
"--save",
"",
"--appendonly",
"no",
)
server: Final = OwnedRedis(host, port, command, log, pid_file)
try:
server.start()
yield server
finally:
if server.process is not None:
server.stop()