Four fixes from the latest review pass. The video gallery downloaded each clip into a blob before it could play, so playback waited on the whole file (tens to hundreds of MB), seeking was limited to what had arrived, and every viewed clip stayed pinned in the webview. The file route already streams and serves ranges; it just could not be a <video src> because it is bearer-gated. Mint a short-lived signed link instead (its own HMAC secret, 12 hour TTL, separate from the image links) and hand it to the element, which then fetches only the ranges it plays. That removes the blob budget, its LRU and every revoke on this page. The sd.cpp readiness probe accepted any process answering on the port, so a foreign server that grabbed the port between the bind check and the spawn was adopted as ours. Confirm the listener is our child before reporting ready, and stay best-effort (psutil missing, an unknown owner, or any probe error still passes) so the check can only reject a definitely foreign process. Dataset import held its lock for the extract but not for the upload path, so two concurrent uploads into the same folder interleaved; take the same lock and return 409. And reject Windows device names (CON, NUL, COM1..9, LPT1..9, with or without an extension) plus trailing periods in dataset names, which are unopenable on Windows.
561 lines
19 KiB
Python
561 lines
19 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the persistent sd-server process manager (SdCppServer).
|
|
|
|
Hermetic: subprocess.Popen and the httpx client are faked, so nothing spawns a real
|
|
binary or opens a socket beyond the free-port probe."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import threading
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from core.inference import sd_cpp_server as srv
|
|
from core.inference.sd_cpp_args import SdCppModelFiles
|
|
from core.inference.sd_cpp_engine import SdCppCancelled
|
|
from core.inference.sd_cpp_server import SdCppServer
|
|
|
|
_FILES = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/vae.sft", llm = "/m/llm.sft")
|
|
|
|
|
|
def _png_b64(shade: int) -> str:
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (1, 1), (shade, shade, shade)).save(buf, format = "PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
class _FakePopen:
|
|
"""Minimal Popen stand-in. stdout yields the scripted lines then BLOCKS until the
|
|
process is terminated/killed/exited -- mirroring a real child that holds its pipe
|
|
open for its lifetime (so the owner/drain thread stays alive, as in production)."""
|
|
|
|
def __init__(
|
|
self,
|
|
lines = (),
|
|
exit_code = None,
|
|
):
|
|
self.pid = 4242
|
|
self._lines = list(lines)
|
|
self._exit = exit_code # None == alive
|
|
self.returncode = exit_code
|
|
self.terminated = False
|
|
self.killed = False
|
|
self._done = threading.Event()
|
|
if exit_code is not None:
|
|
self._done.set()
|
|
|
|
@property
|
|
def stdout(self):
|
|
def _gen():
|
|
for ln in self._lines:
|
|
yield ln
|
|
self._done.wait() # hold the pipe open until the process ends
|
|
|
|
return _gen()
|
|
|
|
def poll(self):
|
|
return self._exit
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
self._exit = 0
|
|
self.returncode = 0
|
|
self._done.set()
|
|
|
|
def wait(self, timeout = None):
|
|
self._done.wait(timeout)
|
|
if self._exit is None:
|
|
self._exit = 0
|
|
self.returncode = 0
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
self._exit = -9
|
|
self.returncode = -9
|
|
self._done.set()
|
|
|
|
|
|
class _Resp:
|
|
def __init__(
|
|
self,
|
|
status_code,
|
|
payload = None,
|
|
text = "",
|
|
bad_json = False,
|
|
):
|
|
self.status_code = status_code
|
|
self._payload = payload if payload is not None else {}
|
|
self.text = text
|
|
self._bad_json = bad_json
|
|
|
|
def json(self):
|
|
if self._bad_json:
|
|
raise ValueError("not json")
|
|
return self._payload
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
get = None,
|
|
post = None,
|
|
):
|
|
self._get = get or (lambda url: _Resp(200, {}))
|
|
self._post = post or (lambda url, json: _Resp(202, {"id": "job1"}))
|
|
self.get_urls = []
|
|
self.post_calls = []
|
|
self.closed = False
|
|
|
|
def get(
|
|
self,
|
|
url,
|
|
timeout = None,
|
|
):
|
|
self.get_urls.append(url)
|
|
return self._get(url)
|
|
|
|
def post(
|
|
self,
|
|
url,
|
|
json = None,
|
|
timeout = None,
|
|
):
|
|
self.post_calls.append((url, json))
|
|
return self._post(url, json)
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
@pytest.fixture
|
|
def patched(monkeypatch):
|
|
"""Neutralise process-lifetime side effects for the manager under test."""
|
|
monkeypatch.setattr(srv, "adopt_pid", lambda pid: None)
|
|
monkeypatch.setattr(srv, "forget_pid", lambda pid: None)
|
|
monkeypatch.setattr(srv, "child_popen_kwargs", lambda: {})
|
|
monkeypatch.setattr(srv, "windows_hidden_subprocess_kwargs", lambda: {})
|
|
return monkeypatch
|
|
|
|
|
|
def _server_with(popen, client):
|
|
s = SdCppServer("/x/sd-server")
|
|
s._client = client
|
|
# Attach the fake process + port so generation tests can run without start().
|
|
s._process = popen
|
|
s.port = 1234
|
|
return s
|
|
|
|
|
|
# ── start / readiness ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_start_becomes_ready_when_capabilities_200(patched):
|
|
popen = _FakePopen(lines = ["loading model", "listening on: http://127.0.0.1:1"])
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
s = _server_with(
|
|
popen, _FakeClient(get = lambda url: _Resp(200, {"model": {"path": "/m/z.gguf"}}))
|
|
)
|
|
s.start(_FILES, startup_timeout = 5.0)
|
|
assert s.is_alive() is True
|
|
assert s.port is not None
|
|
|
|
|
|
def test_start_fails_fast_when_process_exits(patched):
|
|
# Model load failed, so the process exits before listening; start must raise with the tail.
|
|
popen = _FakePopen(lines = ["error: bad model"], exit_code = 1)
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
# Capabilities never answers (connection refused), so readiness relies on exit detection.
|
|
s = _server_with(
|
|
popen, _FakeClient(get = lambda url: (_ for _ in ()).throw(srv.httpx.ConnectError("refused")))
|
|
)
|
|
with pytest.raises(RuntimeError, match = "failed to become ready"):
|
|
s.start(_FILES, startup_timeout = 2.0)
|
|
|
|
|
|
# ── generation ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _completed_job(images_b64):
|
|
return _Resp(
|
|
200,
|
|
{
|
|
"status": "completed",
|
|
"result": {"images": [{"index": i, "b64_json": b} for i, b in enumerate(images_b64)]},
|
|
},
|
|
)
|
|
|
|
|
|
def test_img_gen_returns_image_bytes_in_index_order(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(
|
|
popen,
|
|
_FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobA"}),
|
|
# result images deliberately out of order -> manager must sort by index.
|
|
get = lambda url: _Resp(
|
|
200,
|
|
{
|
|
"status": "completed",
|
|
"result": {
|
|
"images": [
|
|
{"index": 1, "b64_json": _png_b64(200)},
|
|
{"index": 0, "b64_json": _png_b64(50)},
|
|
]
|
|
},
|
|
},
|
|
),
|
|
),
|
|
)
|
|
blobs = s.img_gen({"prompt": "x", "batch_count": 2, "sample_params": {"sample_steps": 4}})
|
|
assert len(blobs) == 2
|
|
first = Image.open(io.BytesIO(blobs[0])).convert("RGB").getpixel((0, 0))
|
|
assert first == (50, 50, 50) # index 0 first
|
|
|
|
|
|
def test_img_gen_failed_job_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(
|
|
popen,
|
|
_FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobF"}),
|
|
get = lambda url: _Resp(
|
|
200, {"status": "failed", "error": {"code": "x", "message": "boom"}}
|
|
),
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match = "generation failed.*boom"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_queue_full_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(429, text = "busy")))
|
|
with pytest.raises(RuntimeError, match = "queue is full"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_cancel_posts_cancel_and_raises(patched):
|
|
popen = _FakePopen()
|
|
cancel = threading.Event()
|
|
cancel.set() # already cancelled before the first poll
|
|
client = _FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobC"}),
|
|
get = lambda url: _Resp(
|
|
200, {"status": "cancelled", "error": {"code": "cancelled", "message": "c"}}
|
|
),
|
|
)
|
|
s = _server_with(popen, client)
|
|
with pytest.raises(SdCppCancelled):
|
|
s.img_gen({"prompt": "x"}, cancel_event = cancel)
|
|
assert any(url.endswith("/cancel") for url, _ in client.post_calls)
|
|
|
|
|
|
def test_img_gen_detects_server_death(patched):
|
|
popen = _FakePopen()
|
|
|
|
def _die_get(url):
|
|
popen._exit = 137 # the process died between submit and poll
|
|
return _Resp(200, {"status": "generating"})
|
|
|
|
s = _server_with(
|
|
popen, _FakeClient(post = lambda url, json: _Resp(202, {"id": "jobD"}), get = _die_get)
|
|
)
|
|
with pytest.raises(RuntimeError, match = "connection lost|process exited"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
# ── stdout routing + stop ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_drain_routes_lines_to_step_listener_and_tail(patched):
|
|
s = SdCppServer("/x/sd-server")
|
|
seen = []
|
|
s._step_listener = seen.append
|
|
# exit_code set so stdout ends after the scripted lines (a live fake would block).
|
|
s._drain_stdout(_FakePopen(lines = ["sampling 1/8", "", "sampling 8/8", "done"], exit_code = 0))
|
|
assert "sampling 1/8" in seen and "sampling 8/8" in seen
|
|
assert "" not in seen # blank lines skipped
|
|
assert s._tail[-1] == "done"
|
|
|
|
|
|
def test_stop_is_idempotent_and_terminates(patched):
|
|
popen = _FakePopen()
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
client = _FakeClient(get = lambda url: _Resp(200, {}))
|
|
s = _server_with(popen, client)
|
|
s.start(_FILES, startup_timeout = 5.0)
|
|
s.stop()
|
|
assert popen.terminated is True
|
|
assert s.is_alive() is False
|
|
assert client.closed is True # stop() releases the pooled HTTP client
|
|
s.stop() # second call must not raise
|
|
|
|
|
|
def test_img_gen_submit_error_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(400, text = "bad params")))
|
|
with pytest.raises(RuntimeError, match = "submit -> 400"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_malformed_submit_json_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(202, bad_json = True)))
|
|
with pytest.raises(RuntimeError, match = "non-JSON submit"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_empty_result_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(
|
|
popen,
|
|
_FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobE"}),
|
|
get = lambda url: _Resp(200, {"status": "completed", "result": {"images": []}}),
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match = "no images"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_rejected_after_stop(patched):
|
|
popen = _FakePopen()
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
|
|
s.start(_FILES, startup_timeout = 5.0)
|
|
s.stop()
|
|
with pytest.raises(RuntimeError, match = "not running"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
# ── cancellation + defensive parsing (review follow-ups) ───────────────────────
|
|
|
|
|
|
def test_img_gen_cancelled_before_submit_reports_cancellation(patched):
|
|
# The server was stopped for a cancel/unload before submit; with the cancel event set this must
|
|
# surface as a cancellation (route 409), not a generic "not running" 500.
|
|
popen = _FakePopen()
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
|
|
s.start(_FILES, startup_timeout = 5.0)
|
|
s.stop()
|
|
cancel = threading.Event()
|
|
cancel.set()
|
|
with pytest.raises(SdCppCancelled):
|
|
s.img_gen({"prompt": "x"}, cancel_event = cancel)
|
|
|
|
|
|
def test_img_gen_abandons_when_cancel_not_honored(patched):
|
|
# A best-effort cancel the server ignores must not pin this call (and the generate lock) until
|
|
# natural completion: after the grace window it raises cancellation.
|
|
patched.setattr(srv, "_CANCEL_GRACE_S", 0.0)
|
|
popen = _FakePopen()
|
|
cancel = threading.Event()
|
|
cancel.set()
|
|
client = _FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobG"}),
|
|
get = lambda url: _Resp(200, {"status": "generating"}), # never terminal
|
|
)
|
|
s = _server_with(popen, client)
|
|
with pytest.raises(SdCppCancelled):
|
|
s.img_gen({"prompt": "x"}, cancel_event = cancel, poll_interval = 0.01)
|
|
# And the process is stopped, not left running the abandoned job: sd-server does not interrupt
|
|
# an in-flight job, so a server that ignored the cancel would otherwise burn a core (or the GPU)
|
|
# to completion and hold its job slot against the next request.
|
|
assert not s.is_alive()
|
|
|
|
|
|
def test_img_gen_non_dict_submit_json_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(202, ["not", "a", "dict"])))
|
|
with pytest.raises(RuntimeError, match = "unexpected submit response"):
|
|
s.img_gen({"prompt": "x"})
|
|
|
|
|
|
def test_img_gen_non_dict_status_json_raises(patched):
|
|
popen = _FakePopen()
|
|
s = _server_with(
|
|
popen,
|
|
_FakeClient(
|
|
post = lambda url, json: _Resp(202, {"id": "jobH"}),
|
|
get = lambda url: _Resp(200, ["unexpected"]),
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match = "unexpected response type"):
|
|
s.img_gen({"prompt": "x"}, poll_interval = 0.01)
|
|
|
|
|
|
def test_decode_images_tolerates_unexpected_shapes():
|
|
# A misbehaving/older server can return non-dict result/images/items, so _decode_images must raise
|
|
# a clean "no images" rather than an AttributeError on .get().
|
|
for job in ({"result": ["x"]}, {"result": {"images": "nope"}}, {"result": {"images": [1, 2]}}):
|
|
with pytest.raises(RuntimeError, match = "no images"):
|
|
SdCppServer._decode_images(job)
|
|
|
|
|
|
def test_start_aborted_by_concurrent_stop(patched):
|
|
# A stop() during the readiness wait must abort start() promptly (without waiting out the startup
|
|
# timeout) and surface as a cancellation.
|
|
popen = _FakePopen(lines = ["loading model"])
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
|
|
def _never_ready(url):
|
|
raise srv.httpx.ConnectError("refused")
|
|
|
|
s = _server_with(popen, _FakeClient(get = _never_ready))
|
|
|
|
def _stop_soon():
|
|
import time as _t
|
|
_t.sleep(0.2)
|
|
s.stop()
|
|
|
|
threading.Thread(target = _stop_soon, daemon = True).start()
|
|
with pytest.raises(SdCppCancelled):
|
|
s.start(_FILES, startup_timeout = 30.0)
|
|
|
|
|
|
def test_diagnostic_tail_keeps_the_reason_not_just_the_backtrace():
|
|
"""What a Metal host produces: the abort prints its cause, then ggml_print_backtrace fills the
|
|
buffer with stack frames. Taking the last N lines reported addresses and no cause, so the
|
|
failure was undiagnosable from the message alone."""
|
|
lines = [
|
|
"loading model from flux-2-klein-4b-Q2_K.gguf",
|
|
"ggml_metal_op_encode: error: unsupported op 'SOME_OP'",
|
|
"/tmp/ggml/src/ggml-metal.m:1234: fatal error",
|
|
*[f"{i} sd-server 0x000000010311{i:04x} ggml_print_backtrace + {i}" for i in range(24)],
|
|
]
|
|
|
|
tail = srv._diagnostic_tail(lines)
|
|
|
|
assert "unsupported op 'SOME_OP'" in tail
|
|
assert "fatal error" in tail
|
|
# Still ends with recent context, so a failure with no marked line is not left empty.
|
|
assert "ggml_print_backtrace" in tail
|
|
|
|
|
|
def test_diagnostic_tail_falls_back_to_the_last_lines():
|
|
lines = [f"step {i}" for i in range(50)]
|
|
tail = srv._diagnostic_tail(lines)
|
|
assert "step 49" in tail
|
|
assert "step 0" not in tail
|
|
|
|
|
|
def test_diagnostic_tail_is_bounded():
|
|
lines = ["error: " + "x" * 500 for _ in range(20)]
|
|
assert len(srv._diagnostic_tail(lines)) <= 1500
|
|
|
|
|
|
def test_readiness_refuses_a_port_held_by_another_process(patched):
|
|
# _find_free_port picks an ephemeral port, closes the socket, and sd-server binds it only after
|
|
# loading the model -- minutes for a big checkpoint. Another local process can take it in that
|
|
# window, and /v1/models is a stock OpenAI route that llama.cpp's server also answers 200 on, so
|
|
# readiness would pass and every generation would go to an unrelated listener.
|
|
import types
|
|
|
|
popen = _FakePopen(lines = ["loading model"])
|
|
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
|
|
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {"model": {}})))
|
|
|
|
fake_psutil = types.SimpleNamespace(
|
|
CONN_LISTEN = "LISTEN",
|
|
net_connections = lambda kind = "inet": [
|
|
types.SimpleNamespace(
|
|
laddr = types.SimpleNamespace(port = s.port),
|
|
status = "LISTEN",
|
|
pid = popen.pid + 1000, # somebody else
|
|
)
|
|
],
|
|
Process = lambda pid: types.SimpleNamespace(parent = lambda: None),
|
|
)
|
|
patched.setitem(__import__("sys").modules, "psutil", fake_psutil)
|
|
assert s._port_is_ours() is False
|
|
|
|
|
|
def test_readiness_accepts_our_own_child_and_its_descendants(patched):
|
|
import types
|
|
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
|
|
|
|
def _conns(owner_pid):
|
|
return [
|
|
types.SimpleNamespace(
|
|
laddr = types.SimpleNamespace(port = s.port), status = "LISTEN", pid = owner_pid
|
|
)
|
|
]
|
|
|
|
# The spawned pid itself.
|
|
patched.setitem(
|
|
__import__("sys").modules,
|
|
"psutil",
|
|
types.SimpleNamespace(
|
|
CONN_LISTEN = "LISTEN",
|
|
net_connections = lambda kind = "inet": _conns(popen.pid),
|
|
Process = lambda pid: types.SimpleNamespace(parent = lambda: None),
|
|
),
|
|
)
|
|
assert s._port_is_ours() is True
|
|
|
|
# A grandchild (wrapper script / shell) still counts as ours.
|
|
child_pid = popen.pid + 7
|
|
|
|
def _process(pid):
|
|
if pid == child_pid:
|
|
return types.SimpleNamespace(
|
|
parent = lambda: types.SimpleNamespace(pid = popen.pid, parent = lambda: None)
|
|
)
|
|
return types.SimpleNamespace(parent = lambda: None)
|
|
|
|
patched.setitem(
|
|
__import__("sys").modules,
|
|
"psutil",
|
|
types.SimpleNamespace(
|
|
CONN_LISTEN = "LISTEN",
|
|
net_connections = lambda kind = "inet": _conns(child_pid),
|
|
Process = _process,
|
|
),
|
|
)
|
|
assert s._port_is_ours() is True
|
|
|
|
|
|
def test_readiness_check_is_best_effort(patched):
|
|
# No psutil, an unreadable owner pid, or a raising lookup must never fail a healthy start.
|
|
import types
|
|
|
|
popen = _FakePopen()
|
|
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
|
|
|
|
patched.setitem(__import__("sys").modules, "psutil", None)
|
|
assert s._port_is_ours() is True
|
|
|
|
def _boom(kind = "inet"):
|
|
raise PermissionError("not allowed")
|
|
|
|
patched.setitem(
|
|
__import__("sys").modules,
|
|
"psutil",
|
|
types.SimpleNamespace(CONN_LISTEN = "LISTEN", net_connections = _boom),
|
|
)
|
|
assert s._port_is_ours() is True
|
|
|
|
# Owner pid not visible (common for another user's process): unknown, so keep going.
|
|
patched.setitem(
|
|
__import__("sys").modules,
|
|
"psutil",
|
|
types.SimpleNamespace(
|
|
CONN_LISTEN = "LISTEN",
|
|
net_connections = lambda kind = "inet": [
|
|
types.SimpleNamespace(
|
|
laddr = types.SimpleNamespace(port = s.port), status = "LISTEN", pid = None
|
|
)
|
|
],
|
|
),
|
|
)
|
|
assert s._port_is_ours() is True
|