Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

357 lines
13 KiB
Python

"""Tests for the HTTP server sidecar (``tokenspeed.runtime.entrypoints.http_server``).
These run entirely against a mock smg backend — no engine, smg, or GPU needed.
They are written as regression guards for the bugs found while building the
sidecar (PR #305):
1. Streaming passthrough closed the upstream aiohttp session before FastAPI
consumed the body iterator, breaking SSE mid-stream. The original mock was
too fast to catch it (it buffered the whole body before the session
closed), so the streaming mock here deliberately sleeps between chunks.
2. Non-streaming passthrough double-encoded the body (JSONResponse(str) wraps
already-serialized JSON in quotes). The fix returns a raw Response; we
assert the body is byte-faithful JSON.
3. gRPC channel was recreated per request (fd/socket leak). The stub must be
a reused singleton.
4. gRPC errors surfaced as unhandled 500s. They must map to a clean 503.
5. --control-port must be parsed as an orchestrator flag, not forwarded to
the engine or gateway.
"""
import asyncio
import threading
import time
import unittest
import requests
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
# Token chunks the streaming mock emits, one SSE event each, with a delay
# between them so a prematurely-closed upstream session would truncate.
_STREAM_TOKENS = ["1", "2", "3", "4", "5"]
_STREAM_DELAY = 0.1
def _build_mock_smg() -> FastAPI:
mock = FastAPI()
@mock.get("/health")
async def health():
# smg returns plain-text "OK", not JSON — exercises non-JSON passthrough.
return PlainTextResponse("OK")
@mock.post("/v1/completions")
async def completions(request: Request):
body = await request.json()
if body.get("stream"):
async def _gen():
for tok in _STREAM_TOKENS:
await asyncio.sleep(_STREAM_DELAY)
yield f'data: {{"choices":[{{"text":"{tok}"}}]}}\n\n'
yield "data: [DONE]\n\n"
return StreamingResponse(_gen(), media_type="text/event-stream")
# Non-streaming: a JSON object the sidecar must relay byte-faithfully.
return JSONResponse(
{
"choices": [{"text": "paris", "index": 0}],
"usage": {"completion_tokens": 1},
}
)
@mock.post("/flush_cache")
async def flush_cache():
return JSONResponse({"status": "flushed"})
@mock.api_route("/start_profile", methods=["GET", "POST"])
async def start_profile():
return JSONResponse({"status": "profiling"})
return mock
def _wait(port, path, timeout=15):
deadline = time.time() + timeout
while time.time() < deadline:
try:
requests.get(f"http://127.0.0.1:{port}{path}", timeout=1)
return True
except Exception:
time.sleep(0.2)
return False
class TestProxyPassthrough(unittest.TestCase):
"""Integration tests of the smg-passthrough path against a mock backend."""
MOCK_PORT = 28310
SIDECAR_PORT = 28311
@classmethod
def setUpClass(cls):
from tokenspeed.runtime.entrypoints import http_server as hs
cls.hs = hs
hs._gateway_url = f"http://127.0.0.1:{cls.MOCK_PORT}"
hs._engine_grpc_addr = "127.0.0.1:1" # dead — only gRPC tests touch it
cls._mock_server = uvicorn.Server(
uvicorn.Config(
_build_mock_smg(),
host="127.0.0.1",
port=cls.MOCK_PORT,
log_level="error",
)
)
cls._sidecar_server = uvicorn.Server(
uvicorn.Config(
hs.app, host="127.0.0.1", port=cls.SIDECAR_PORT, log_level="error"
)
)
cls._t_mock = threading.Thread(target=cls._mock_server.run, daemon=True)
cls._t_side = threading.Thread(target=cls._sidecar_server.run, daemon=True)
cls._t_mock.start()
cls._t_side.start()
assert _wait(cls.MOCK_PORT, "/health"), "mock smg failed to start"
assert _wait(cls.SIDECAR_PORT, "/health"), "sidecar failed to start"
@classmethod
def tearDownClass(cls):
cls._mock_server.should_exit = True
cls._sidecar_server.should_exit = True
def _url(self, path):
return f"http://127.0.0.1:{self.SIDECAR_PORT}{path}"
# -- bug 1: streaming session lifetime ----------------------------------
def test_streaming_completes_all_chunks(self):
"""Regression: SSE must stream all delayed chunks without the upstream
session closing mid-stream."""
chunks = []
with requests.post(
self._url("/v1/completions"),
json={"model": "m", "prompt": "hi", "max_tokens": 5, "stream": True},
stream=True,
timeout=15,
) as r:
self.assertEqual(r.status_code, 200)
self.assertIn("text/event-stream", r.headers.get("content-type", ""))
for raw in r.iter_lines():
if raw:
chunks.append(raw.decode())
# Every token must arrive, in order, followed by [DONE].
for tok in _STREAM_TOKENS:
self.assertTrue(
any(f'"text":"{tok}"' in c for c in chunks),
f"missing streamed token {tok!r}; got {chunks}",
)
self.assertTrue(any("[DONE]" in c for c in chunks), f"no [DONE]: {chunks}")
def test_streaming_is_incremental(self):
"""The response must arrive incrementally, not buffered all-at-once —
proves the streaming path isn't accidentally reading the full body."""
first_byte_t = None
last_byte_t = None
with requests.post(
self._url("/v1/completions"),
json={"model": "m", "prompt": "hi", "max_tokens": 5, "stream": True},
stream=True,
timeout=15,
) as r:
for raw in r.iter_lines():
if raw:
now = time.monotonic()
if first_byte_t is None:
first_byte_t = now
last_byte_t = now
# With 5 chunks * 0.1s delay, spread between first and last must be
# clearly non-zero if streaming is truly incremental.
self.assertIsNotNone(first_byte_t)
self.assertGreater(
last_byte_t - first_byte_t,
_STREAM_DELAY,
"stream did not arrive incrementally",
)
# -- bug 2: non-streaming double-encoding -------------------------------
def test_non_streaming_body_is_faithful_json(self):
"""Regression: body must be a JSON object, not a double-encoded JSON
string (the JSONResponse(str) bug wrapped it in quotes)."""
r = requests.post(
self._url("/v1/completions"),
json={"model": "m", "prompt": "hi", "max_tokens": 4},
timeout=10,
)
self.assertEqual(r.status_code, 200)
# Raw body must be a JSON object literal.
self.assertTrue(
r.text.lstrip().startswith("{"), f"body looks double-encoded: {r.text[:80]}"
)
data = r.json()
self.assertIsInstance(data, dict)
self.assertEqual(data["choices"][0]["text"], "paris")
# -- health proxy (plain-text passthrough) ------------------------------
def test_health_proxies_plaintext(self):
r = requests.get(self._url("/health"), timeout=10)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.text, "OK")
# -- other passthrough routes -------------------------------------------
def test_flush_cache_passthrough(self):
r = requests.post(self._url("/flush_cache"), timeout=10)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["status"], "flushed")
def test_start_profile_passthrough(self):
r = requests.post(self._url("/start_profile"), timeout=10)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["status"], "profiling")
def test_status_code_relayed(self):
"""A non-200 from smg must be relayed, not masked as 200."""
r = requests.get(self._url("/v1/models"), timeout=10) # mock has no such route
self.assertEqual(r.status_code, 404)
class TestGrpcDirect(unittest.TestCase):
"""Unit tests for the gRPC-direct path (no live engine)."""
def setUp(self):
from tokenspeed.runtime.entrypoints import http_server as hs
self.hs = hs
# Reset the cached channel/stub between tests.
hs._grpc_channel = None
hs._grpc_stub = None
hs._engine_grpc_addr = "127.0.0.1:1"
# -- bug 3: gRPC channel reuse ------------------------------------------
def test_stub_is_reused(self):
"""Regression: _stub() must return the same instance across calls
(creating a new channel per request leaks fds/sockets)."""
s1 = self.hs._stub()
s2 = self.hs._stub()
self.assertIs(s1, s2)
self.assertIsNotNone(self.hs._grpc_channel)
# -- bug 4: gRPC error -> 503 -------------------------------------------
def test_grpc_error_maps_to_503(self):
"""Regression: a failed engine RPC must yield a clean 503, not a 500."""
port = 28321
server = uvicorn.Server(
uvicorn.Config(self.hs.app, host="127.0.0.1", port=port, log_level="error")
)
t = threading.Thread(target=server.run, daemon=True)
t.start()
try:
assert _wait(port, "/get_server_info")
r = requests.get(f"http://127.0.0.1:{port}/get_server_info", timeout=10)
self.assertEqual(r.status_code, 503)
body = r.json()
self.assertEqual(body["error"], "engine unavailable")
self.assertIn("detail", body)
finally:
server.should_exit = True
class TestProxyTimeout(unittest.TestCase):
"""The proxy must not impose a wall-clock `total` timeout, which would cut
off long but healthy streaming generations (P2 review fix)."""
def test_no_total_cap_but_inactivity_bounded(self):
from tokenspeed.runtime.entrypoints import http_server as hs
self.assertIsNone(
hs._PROXY_TIMEOUT.total,
"a total timeout would abort long streams mid-flight",
)
# Inactivity / connect bounds must still be set so a hung upstream is
# detected.
self.assertIsNotNone(hs._PROXY_TIMEOUT.sock_read)
self.assertIsNotNone(hs._PROXY_TIMEOUT.sock_connect)
class TestControlServerReadiness(unittest.TestCase):
"""`_start_control_server` must wait for an actual bind before reporting
ready, and report failure when the port is unavailable (P2 review fix)."""
def test_reports_ready_after_bind(self):
from tokenspeed.cli.serve_smg import _start_control_server
port = 28330
ok = asyncio.run(
_start_control_server(
gateway_url="http://127.0.0.1:1",
engine_grpc_addr="127.0.0.1:1",
host="127.0.0.1",
port=port,
timeout=15,
)
)
self.assertTrue(ok)
# The socket must really be accepting connections now.
self.assertTrue(_wait(port, "/health", timeout=2))
def test_reports_failure_when_port_in_use(self):
from tokenspeed.cli.serve_smg import _start_control_server
port = 28331
# Occupy the port first.
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", port))
sock.listen(1)
try:
ok = asyncio.run(
_start_control_server(
gateway_url="http://127.0.0.1:1",
engine_grpc_addr="127.0.0.1:1",
host="127.0.0.1",
port=port,
timeout=5,
)
)
self.assertFalse(ok, "should report failure when port is occupied")
finally:
sock.close()
class TestControlPortArg(unittest.TestCase):
"""--control-port orchestrator-flag parsing (bug 5)."""
def test_control_port_parsed(self):
from tokenspeed.cli._argsplit import split_argv
result = split_argv(["--model", "m", "--control-port", "8081"])
self.assertEqual(result.opts.control_port, 8081)
def test_control_port_not_forwarded(self):
from tokenspeed.cli._argsplit import split_argv
result = split_argv(["--model", "m", "--control-port", "8081"])
self.assertNotIn("--control-port", result.engine)
self.assertNotIn("--control-port", result.gateway)
def test_control_port_defaults_none(self):
from tokenspeed.cli._argsplit import split_argv
result = split_argv(["--model", "m"])
self.assertIsNone(result.opts.control_port)
if __name__ == "__main__":
unittest.main()