Comment-only pass over the Python this PR touches: drop what the code already says, collapse multi-line explanations that still read on one line, and keep the reasoning that is not recoverable from the code. No code, docstring semantics or behaviour changes; verified with an AST comparison against the previous revision, and the backend suite is unchanged (same 37 environment failures as before: the API integration tests that need a live keyed server, the flash-attn install hooks, and the GPU memory fields).
417 lines
14 KiB
Python
417 lines
14 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)
|
|
|
|
|
|
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)
|