Files
vllm-omni/tests/edge/local/test_e2e_serve_text.py
T
tzhouamandClaude Fable 5.1 502dd7bed0 [edge-infer W8] Serving e2e launches the server itself; no module-level fcntl on the serve import path
test_e2e_serve_text.py no longer imports tests.helpers.runtime (it pulls
the media/diffusion test helpers and therefore diffusers, absent from the
Windows venv); a small Popen launcher with a psutil process-tree kill
replaces OmniServer for this one test.

vllm-omni serve died at import on Windows: entrypoints/cli imports
benchmarks.patch -> benchmarks/omniinteract.py, which did 'import fcntl'
at module scope. Its output lock is rebuilt on the fork's
_filelock_compat primitive (fcntl on POSIX, msvcrt on Windows).
engine/stage_phase_lock.py (parallel_stage_init's SH/EX device locks)
gets the same treatment: fcntl optional, modes spelled locally, and on
Windows both modes map to the exclusive byte lock -- more serialising,
never less safe. Measured: the serving e2e's server exited with
ModuleNotFoundError: No module named 'fcntl' on both Windows venvs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:05:55 +08:00

254 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""M0 serving end-to-end: the OpenAI-compatible server on the local text plan.
``test_e2e_local_text.py`` drives ``LocalTextEngine`` in-process. This module
drives the *server* path instead -- ``python -m vllm_omni.entrypoints.cli.main
serve <model> --omni`` as a subprocess, launched here with a small launcher of
its own (``tests.helpers.runtime.OmniServer`` would do, but importing it pulls
the media/diffusion test helpers and therefore ``diffusers``, which the AR/text
path and a Windows venv without the media deps do not have) -- and talks to it over HTTP
with plain ``requests``: health, model listing, non-streaming chat, streaming
chat, and a client that disconnects mid-stream after which the server must
still answer. Everything runs on one local device; no network access.
Runs on the same checkpoint rule as the engine test (``VLLM_OMNI_LOCAL_TEST_MODEL``
overrides). Marked ``local_model``: it needs the real model and a real device.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
import psutil
import pytest
import requests
from tests.edge.local.test_e2e_local_text import MAX_MODEL_LEN, _find_model
from vllm_omni.edge.local.prompts import acceptance_prompts
pytestmark = [pytest.mark.local_model]
MAX_TOKENS = 64
REQUEST_TIMEOUT = 600 # s; the first request also pays any lazy warmup
def _chat_prompts() -> list[tuple[str, str]]:
"""The three chat-shaped acceptance prompts as (name, user text).
The acceptance file stores fully rendered prompts; for the chat endpoint we
send the user turn only and let the server apply the chat template.
"""
rendered = dict(acceptance_prompts())
out = []
for name, text in rendered.items():
if not name.startswith("chat_"):
continue
# The rendered form is "...<|User|>{text}<...end...><...start...><|Bot|>..."
# Strip to the user turn; if the markers are not there, send the whole thing.
user = text.split("<|User|>", 1)[-1]
user = user.split("<|end▁of▁sentence|>", 1)[0]
out.append((name, user.strip() or text))
return out
@pytest.fixture(scope="module")
def model_dir() -> str:
return _find_model()
def _free_port(host: str = "127.0.0.1") -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, 0))
return sock.getsockname()[1]
def _kill_tree(proc: subprocess.Popen, timeout: float = 30) -> None:
"""Terminate the server and every child (engine core, workers), on any OS."""
try:
parent = psutil.Process(proc.pid)
except psutil.NoSuchProcess:
return
children = parent.children(recursive=True)
for p in [*children, parent]:
try:
p.terminate()
except psutil.NoSuchProcess:
pass
_, alive = psutil.wait_procs([*children, parent], timeout=timeout)
for p in alive:
try:
p.kill()
except psutil.NoSuchProcess:
pass
@pytest.fixture(scope="module")
def server(model_dir: str) -> Iterator[tuple[str, str]]:
"""Start the Omni server on the local text plan's settings; yield (base_url, model)."""
env = os.environ.copy()
env.update(
{
"VLLM_NO_USAGE_STATS": "1",
"DO_NOT_TRACK": "1",
"HF_HUB_OFFLINE": "1",
# The same sampler setting LocalTextEngine applies to itself
# (vllm_omni/edge/local/engine.py). Without it the serve path picks the
# flashinfer top-k/top-p sampler, whose JIT needs nvcc at runtime; a
# venv without a CUDA toolkit (the WSL reference venv) dies in
# profile_run with "Could not find nvcc". The local plan never wants a
# runtime compiler on the device it admits.
"VLLM_USE_FLASHINFER_SAMPLER": "0",
}
)
host, port = "127.0.0.1", _free_port()
cmd = [
sys.executable,
"-m",
"vllm_omni.entrypoints.cli.main",
"serve",
model_dir,
"--host",
host,
"--port",
str(port),
"--omni",
"--max-model-len",
str(MAX_MODEL_LEN),
"--max-num-seqs",
"2",
"--enforce-eager",
"--gpu-memory-utilization",
"0.55",
"--stage-init-timeout",
"600",
"--init-timeout",
"900",
"--disable-log-stats",
]
repo_root = Path(__file__).resolve().parents[3]
proc = subprocess.Popen(cmd, env=env, cwd=str(repo_root))
try:
deadline = time.time() + 1200
while True:
if proc.poll() is not None:
pytest.fail(f"server exited with code {proc.returncode} before opening its port")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
if sock.connect_ex((host, port)) == 0:
break
if time.time() > deadline:
pytest.fail("server did not open its port within 1200 s")
time.sleep(2)
yield f"http://{host}:{port}", model_dir
finally:
_kill_tree(proc)
def _wait_healthy(base_url: str, timeout: float = 300) -> None:
deadline = time.time() + timeout
last = None
while time.time() < deadline:
try:
r = requests.get(f"{base_url}/health", timeout=5)
if r.status_code == 200:
return
last = r.status_code
except requests.RequestException as e: # server socket open but app not ready
last = repr(e)
time.sleep(2)
pytest.fail(f"/health never returned 200 within {timeout}s (last: {last})")
def _chat(base_url: str, model: str, user: str, *, stream: bool, max_tokens: int = MAX_TOKENS):
body = {
"model": model,
"messages": [{"role": "user", "content": user}],
"max_tokens": max_tokens,
"temperature": 0,
"stream": stream,
}
return requests.post(
f"{base_url}/v1/chat/completions", json=body, stream=stream, timeout=REQUEST_TIMEOUT
)
def _sse_chunks(resp: requests.Response) -> Iterator[dict]:
for raw in resp.iter_lines():
if not raw:
continue
line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
if not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if payload == "[DONE]":
return
yield json.loads(payload)
def test_health_and_model_listing(server):
base_url, model = server
_wait_healthy(base_url)
r = requests.get(f"{base_url}/v1/models", timeout=30)
assert r.status_code == 200, r.text
ids = [m["id"] for m in r.json()["data"]]
assert model in ids, ids
@pytest.mark.parametrize("name,user", _chat_prompts(), ids=[n for n, _ in _chat_prompts()])
def test_chat_completion_reaches_max_tokens_or_stops(server, name, user):
base_url, model = server
_wait_healthy(base_url)
r = _chat(base_url, model, user, stream=False)
assert r.status_code == 200, r.text
out = r.json()
choice = out["choices"][0]
assert choice["finish_reason"] in ("length", "stop"), choice
assert out["usage"]["completion_tokens"] > 0, out["usage"]
assert out["usage"]["completion_tokens"] <= MAX_TOKENS, out["usage"]
assert choice["message"]["content"] or choice["message"].get("reasoning_content"), choice
def test_streaming_delivers_incremental_chunks_and_a_finish(server):
base_url, model = server
_wait_healthy(base_url)
r = _chat(base_url, model, "Count from one to twenty in words.", stream=True)
assert r.status_code == 200, r.text
n_chunks, finish, text = 0, None, []
for chunk in _sse_chunks(r):
n_chunks += 1
choice = chunk["choices"][0]
delta = choice.get("delta", {})
text.append(delta.get("content") or delta.get("reasoning_content") or "")
if choice.get("finish_reason"):
finish = choice["finish_reason"]
assert n_chunks >= 4, n_chunks # incremental, not one blob
assert finish in ("length", "stop"), finish
assert "".join(text).strip()
def test_a_client_disconnect_mid_stream_does_not_take_the_server_down(server):
base_url, model = server
_wait_healthy(base_url)
r = _chat(base_url, model, "Write a long story about a lighthouse.", stream=True, max_tokens=256)
assert r.status_code == 200, r.text
seen = 0
for _ in _sse_chunks(r):
seen += 1
if seen >= 5:
break
r.close() # drop the connection with the request still generating
assert seen >= 5
# The server must still be healthy and still answer.
_wait_healthy(base_url, timeout=60)
r2 = _chat(base_url, model, "Say hello.", stream=False, max_tokens=16)
assert r2.status_code == 200, r2.text
assert r2.json()["usage"]["completion_tokens"] > 0