mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-02 08:00:48 -05:00
* server + ui: refactor resumable stream routes to query string conv_id The conversation id can embed a model name containing slashes (ggml-org/...) in router mode, which the decoded path splits before the :conv_id param is captured, so stop and resume never matched the session. Move the id to the conv_id query string on the public routes and on the internal router -> child hop, where slashes survive encoding. Handlers are unchanged since query and path params land in the same map. Add a regression test with a slashed model name. * server: move stream route docs to server-stream.h Address review: ngxson wants the main server.cpp registration code kept clean and simple, with route-level explanations living in the header. Move the query string rationale and the lookup ownership note next to the handler declarations in server-stream.h, and shorten the wiring comment to a pointer. * server: cancel a pending request when its stream is stopped during model load The conversation was registered in the conv map only after the blocking autoload wait, so a stop issued while the model loaded found nothing to cancel and the request went on to generate an orphan once the load ended. Register the conversation before the wait and give the entry a ticket: a stop erases the entry, and the parked request checks its ticket after the wait and aborts with 400 instead of starting. A newer request on the same conversation replaces the entry, so only the stopped request is cancelled. Add a regression test that stops during the load window. * server + ui: resume a stream after a page reload during model load A pending request died with the client socket when the page was reloaded while its model was loading, so no session ever existed and the conversation had nothing to recover. A session request that waited for a load now detaches from the client socket and reaches the child regardless, the session buffer receives the generation, and the resume route answers 503 while the owner is loading so the client retries instead of dropping its state. The WebUI persists the pending stream at send time, quietly polls on 503, and attaches once the session exists. Add a regression test that drops the client during the load window. * ui: show the model load progress again after a page refresh The resume wait was invisible, so a conversation refreshed while its model was loading showed nothing until the first byte. On a 503 from the resume probe, mark the conversation as loading again so the assistant row persisted at send time renders the processing info, and target the model frozen in the persisted stream state for the progress, since the row has no model yet and the dropdown may not be restored. * fix CI * fix CI bis
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
import json
|
|
import socket
|
|
import threading
|
|
import time
|
|
from urllib.parse import quote
|
|
import pytest
|
|
from utils import *
|
|
|
|
server: ServerProcess
|
|
|
|
# a model name with slashes exercises the query string routing of the stream routes: the id
|
|
# cannot travel as a path param because the decoded slash would split it before capture
|
|
MODEL = "ggml-org/tinygemma3-GGUF:Q8_0"
|
|
STREAM_ID = f"conv-stream-test::{MODEL}"
|
|
QS = "conv_id=" + quote(STREAM_ID, safe="")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def create_server():
|
|
global server
|
|
server = ServerPreset.router()
|
|
|
|
|
|
def test_stream_resume_and_stop_with_slashed_model_name():
|
|
global server
|
|
server.start()
|
|
|
|
content = ""
|
|
for data in server.make_stream_request("POST", "/chat/completions", data={
|
|
"model": MODEL,
|
|
"stream": True,
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}, headers={"X-Conversation-Id": STREAM_ID}):
|
|
if data["choices"]:
|
|
content += data["choices"][0]["delta"].get("content") or ""
|
|
assert len(content) > 0
|
|
|
|
# the finished session replays from the beginning through the router
|
|
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
|
|
assert res.status_code == 200
|
|
assert "data: " in str(res.body)
|
|
|
|
# the explicit stop reaches the owning child and evicts the session
|
|
res = server.make_request("DELETE", f"/v1/stream?{QS}")
|
|
assert res.status_code == 204
|
|
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
|
|
assert res.status_code == 404
|
|
|
|
|
|
def test_stream_stop_during_model_load():
|
|
global server
|
|
server.start()
|
|
|
|
thread_error: list[ServerError] = []
|
|
thread_done = threading.Event()
|
|
|
|
def fire_post():
|
|
try:
|
|
for _ in server.make_stream_request("POST", "/chat/completions", data={
|
|
"model": MODEL,
|
|
"stream": True,
|
|
"max_tokens": 512,
|
|
"messages": [{"role": "user", "content": "Count from 1 to 1000."}],
|
|
}, headers={"X-Conversation-Id": STREAM_ID}):
|
|
pass
|
|
except ServerError as e:
|
|
thread_error.append(e)
|
|
finally:
|
|
thread_done.set()
|
|
|
|
t = threading.Thread(target=fire_post)
|
|
t.start()
|
|
|
|
# catch the autoload window, tiny models load fast so poll aggressively
|
|
saw_loading = False
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline and not thread_done.is_set():
|
|
res = server.make_request("GET", "/models")
|
|
status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
|
|
if status == "loading":
|
|
saw_loading = True
|
|
break
|
|
time.sleep(0.002)
|
|
if not saw_loading:
|
|
t.join()
|
|
pytest.skip("load window too short to be observed on this machine") # ty: ignore[too-many-positional-arguments]
|
|
|
|
# a stop during the load cancels the parked request instead of leaving an orphan
|
|
res = server.make_request("DELETE", f"/v1/stream?{QS}")
|
|
assert res.status_code == 204
|
|
assert thread_done.wait(timeout=60)
|
|
t.join()
|
|
assert len(thread_error) == 1
|
|
assert thread_error[0].code == 400
|
|
assert "cancelled" in json.dumps(thread_error[0].body)
|
|
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
|
|
assert res.status_code == 404
|
|
|
|
|
|
def test_stream_resumes_after_reload_during_model_load():
|
|
global server
|
|
server.start()
|
|
|
|
# raw socket client so the connection can be dropped mid load like a page reload
|
|
body = json.dumps({
|
|
"model": MODEL,
|
|
"stream": True,
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
})
|
|
request = (
|
|
f"POST /v1/chat/completions HTTP/1.1\r\n"
|
|
f"Host: {server.server_host}:{server.server_port}\r\n"
|
|
f"Content-Type: application/json\r\n"
|
|
f"X-Conversation-Id: {STREAM_ID}\r\n"
|
|
f"Content-Length: {len(body)}\r\n"
|
|
f"Connection: close\r\n\r\n{body}"
|
|
)
|
|
sock = socket.create_connection((server.server_host, server.server_port))
|
|
sock.sendall(request.encode())
|
|
|
|
# drop the client while the model loads, poll aggressively to catch the window
|
|
saw_loading = False
|
|
saw_503 = False
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline:
|
|
res = server.make_request("GET", "/models")
|
|
status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
|
|
if status == "loading":
|
|
saw_loading = True
|
|
break
|
|
if status == "loaded":
|
|
break
|
|
time.sleep(0.002)
|
|
sock.close()
|
|
if not saw_loading:
|
|
pytest.skip("load window too short to be observed on this machine") # ty: ignore[too-many-positional-arguments]
|
|
|
|
# while the model loads the resume route answers retry later, then the session appears,
|
|
# receives the whole generation despite the dead client, and replays from the beginning
|
|
deadline = time.time() + 60.0
|
|
replay = None
|
|
while time.time() < deadline:
|
|
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
|
|
if res.status_code == 503:
|
|
saw_503 = True
|
|
elif res.status_code == 200 and "data: " in str(res.body):
|
|
replay = res
|
|
break
|
|
time.sleep(0.1)
|
|
assert saw_503, "resume during the load did not answer 503"
|
|
assert replay is not None, "session never became resumable after the client disconnect"
|