unsloth/studio/backend/tests/test_sd_cpp_engine.py
Long Yixing 22cb12ec65
Studio: validate legacy sd binary discovery (#8560)
* fix(studio): validate legacy sd discovery

* studio: separate sd.cpp identity from MiniMax-H3 capability

The legacy PATH probe answers "is this stable-diffusion.cpp". The H3 gate was answering
"does this build carry H3" and reporting the result as if it had answered the first, so an
unrelated binary reached through SD_CLI_PATH was described as a stable-diffusion.cpp build
predating MiniMax-H3 -- true of every program that is not stable-diffusion.cpp, and the
sentence that sent #8507 looking for a newer build of something never installed.

- share one predicate, help_text_identifies_sd_cpp, between the two callers
- the H3 gate takes ONE --help and derives both answers from it, asking identity first.
  --ref-video is a plain option name that unrelated reference-video tools also expose, so
  returning on the marker alone readmitted the same class of program through the override
- a user-supplied binary that is not stable-diffusion.cpp gets a message saying so, and the
  log lines on the managed path name the actual fault
- memoize the identity verdict per file revision (path + mtime + ctime + size). Discovery
  runs on every load and ensure_sd_cpp_binary resolves twice, so an unrelated "sd" was
  executed once per discovery. Only a verdict the probe produced is cached: a timeout
  leaves the file unchanged, so caching that "no" would blacklist a genuine build until
  Studio restarts

* studio: vet the MiniMax-H3 binary before downloading its bundle

ensure_h3_sd_cpp_binary exists to refuse a build that cannot run H3 before the four-file
bundle is fetched, and its own error said so. _run_load_h3_native called it after the
download loop, so every refusal still cost tens of GB, and a None return -- auto-install
off, an unsupported platform, no network, or a managed copy something else is running out
of -- was not rejected until later still.

- move the preflight and the accelerator fallback above the asset resolution
- check cancel_event before it: the preflight may install the prebuilt and takes no
  cancel_event, so a load cancelled before its worker started paid for an install nobody
  was waiting for. The download loop used to be the first check
- refuse a preflight that produced no binary there, rather than after the downloads. The
  claimed re-vet keeps its own check; that one guards a replacement arriving mid-download
- test_sd_cpp_h3_matrix.py covers platform x GPU vendor x binary state, because the
  combinations are what broke: a CPU-only prebuilt on a CUDA host reaches its refusal
  through the fallback, and that path downloaded the bundle on the way to failing

* studio: apply the repo kwarg-spacing format hook

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: assert the managed-path fault string, and correct which sources reach it

Mutation testing found the one production change in this branch that could be reverted
silently: forcing fault back to the H3 wording left every test green. That log line is
the same wrong diagnosis #8507 was reported as, written to the log instead of the user.

The comment above it also claimed only an env override reaches that branch. An in-tree
developer build and a PATH sd-cli do too, since the identity gate covers only the sd stem.

* studio: do not memoize an identity probe that learned nothing

A binary that cannot load a shared library exits 127 from the dynamic loader with nothing
identifying on either stream. That is a CompletedProcess, not an exception, so the previous
rule cached it as a definitive "not stable-diffusion.cpp" against a file that never
changed, and installing the missing library would not get it re-probed until restart.

Memoize a decisive verdict only: identifying output settles it whatever the exit code, and
otherwise only a clean exit is evidence of anything. The case this memo exists for is
unaffected, since Debian/Ubuntu's sd answers --help with rc 0.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: close two windows the H3 preflight move opened

Both found in review of the move itself.

Re-check cancellation on the way out of the preflight. The ensure takes no cancel_event
and can spend minutes downloading and extracting the prebuilt, so a cancel arriving during
it is already late; a CPU or MPS target then skipped the claimed accelerator probe (the
only other cancel-aware step) and paid four sequential model_info round trips before the
download loop noticed.

Re-vet EVERY binary after the download, not only a managed one. The managed-only test was
written when the ensure sat right there, so a user-supplied build had been vetted
microseconds earlier and only a concurrent install could have moved underneath it. Vetting
before the download makes that window the whole download -- long enough to rebuild or
repoint an SD_CLI_PATH copy, after which the replacement became the recorded identity that
every later generation compares against.

* studio: re-vet identity too, and bound how long an identity verdict answers

Both from review of the previous round.

The post-download re-vet asked only the capability question. --ref-video is a plain option
name that unrelated reference-video tools expose too, so a swap to one of those cleared a
marker-only check and was then recorded as the vetted build. It now applies the same
identity-and-capability pair the preflight does, off one --help.

And the memo key is not a content revision on Windows: st_ctime there is the CREATION
time, which an in-place overwrite preserves, so a same-sized write that also restores mtime
leaves the whole tuple unchanged. Hashing the binary per lookup would trade the exec this
memo avoids for a full read on a path walked every load, so verdicts expire instead. That
bounds what the key cannot see on any platform, and a minute is still far longer than the
several resolutions inside one load.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: re-check the accelerator for every binary, on a decisive reading

native_device is decided on the accelerator reading and then committed for the life of the
runtime, and moving that reading before the download made the gap between deciding and
committing the whole fetch. The re-check still only ran for a managed binary, so a user's
own build rebuilt inside that window committed a GPU device around a CPU executable --
offload policy and an arbiter claim written against hardware nothing is running on.

The comparison needs a decisive reading, which is why this adds the verdict form rather
than reusing sd_cpp_lists_accelerator_device: that one folds "could not tell" into True,
and against a recorded False it would refuse the very CPU fallback that recorded it.

* studio: only re-probe the device list where there is a baseline to compare it to

Hoisting the verdict out of the condition made every H3 load spawn --list-devices, including
the CPU and MPS targets that deliberately never record a baseline and so cannot use the
answer. On a build that hangs on that flag it cost the full probe timeout before readiness,
for a value the comparison then discarded.

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-12 20:05:54 -07:00

959 lines
37 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
"""Unit tests for the sd-cli engine + routing (``sd_cpp_engine.py``).
Hermetic: the binary finder is driven against a tmp filesystem, and ``generate``
runs a fake ``subprocess.Popen`` that emits canned lines and writes the output
PNG -- no real ``sd-cli``, no GPU.
"""
from __future__ import annotations
import inspect
import os
import sys
import time
import types
from pathlib import Path
import pytest
from core.inference import sd_cpp_engine as eng
from core.inference.sd_cpp_engine import (
ENGINE_DIFFUSERS,
ENGINE_SD_CPP,
SdCppEngine,
find_sd_cpp_binary,
find_sd_server_binary,
runtime_env,
select_diffusion_engine,
)
from core.inference.sd_cpp_args import SdCppGenParams, SdCppModelFiles, SdCppUpscaleParams
# ── binary discovery ────────────────────────────────────────────────────────
@pytest.fixture(autouse = True)
def _isolate_binary_discovery(tmp_path_factory, monkeypatch):
"""Point every hop of the finder at an empty tree, so a real install on the machine running the
tests cannot satisfy it.
Clearing ``SD_CLI_PATH`` / ``UNSLOTH_SD_CPP_PATH`` and patching ``Path.home`` is not enough:
hop 3 goes through ``managed_install_root()``, which honors ``UNSLOTH_STUDIO_HOME`` /
``STUDIO_HOME`` and resolves to ``<studio home>/../stable-diffusion.cpp``. Anyone running the
suite with a Studio home set -- which is the documented way to run side-by-side Studios -- gets
a real binary back and every "nothing is installed" assertion here fails. Hop 4 (the in-tree
developer build) has the same problem for anyone who built sd.cpp in the checkout.
Autouse rather than a helper because the failure does not need a fixture to reach it:
``SdCppEngine(binary = None)`` calls the finder from its constructor.
"""
eng._IDENTITY_MEMO.clear() # a verdict from another test must never answer for this one
root = tmp_path_factory.mktemp("no_sd_cpp")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(root / "studio"))
monkeypatch.delenv("STUDIO_HOME", raising = False)
monkeypatch.setattr(eng, "in_tree_install_root", lambda: root / "in_tree")
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: root / "nohome"))
def _clear_env(monkeypatch):
monkeypatch.delenv("SD_CLI_PATH", raising = False)
monkeypatch.delenv("UNSLOTH_SD_CPP_PATH", raising = False)
def test_find_prefers_sd_cli_path_env(tmp_path, monkeypatch):
_clear_env(monkeypatch)
binary = tmp_path / "sd-cli"
binary.write_text("#!/bin/sh\n")
monkeypatch.setenv("SD_CLI_PATH", str(binary))
# even with PATH empty, the direct env wins
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_cpp_binary() == str(binary)
def test_find_custom_install_dir_build_layout(tmp_path, monkeypatch):
_clear_env(monkeypatch)
root = tmp_path / "sdcpp"
built = root / "build" / "bin" / "sd-cli"
built.parent.mkdir(parents = True)
built.write_text("x")
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_cpp_binary() == str(built)
def test_find_falls_back_to_path(tmp_path, monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(
eng.shutil, "which", lambda stem: "/usr/bin/sd-cli" if stem == "sd-cli" else None
)
assert find_sd_cpp_binary() == "/usr/bin/sd-cli"
def test_find_rejects_unrelated_ubuntu_sd_on_path(tmp_path, monkeypatch, caplog):
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda stem: "/usr/bin/sd" if stem == "sd" else None)
run_kwargs = {}
hidden = object()
def _run(*_args, **kwargs):
run_kwargs.update(kwargs)
return types.SimpleNamespace(
stdout = "Find & replace occurrences of a pattern\nUsage: sd [OPTIONS] <FIND> <REPLACE>",
stderr = "",
returncode = 0,
)
monkeypatch.setattr(eng.subprocess, "run", _run)
monkeypatch.setattr(eng, "windows_hidden_subprocess_kwargs", lambda: {"startupinfo": hidden})
assert find_sd_cpp_binary() is None
assert "does not identify stable-diffusion.cpp" in caplog.text
assert run_kwargs["timeout"] == 10
assert run_kwargs["startupinfo"] is hidden
def test_find_rejects_legacy_sd_when_identity_probe_times_out(tmp_path, monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda stem: "/usr/bin/sd" if stem == "sd" else None)
def _timeout(*_args, **_kwargs):
raise eng.subprocess.TimeoutExpired("sd", 10)
monkeypatch.setattr(eng.subprocess, "run", _timeout)
assert find_sd_cpp_binary() is None
def test_find_accepts_genuine_legacy_sd_on_path(tmp_path, monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(
eng.shutil, "which", lambda stem: "/opt/sd.cpp/bin/sd" if stem == "sd" else None
)
monkeypatch.setattr(
eng.subprocess,
"run",
lambda *_a, **_k: types.SimpleNamespace(
stdout = (
"usage: /opt/sd.cpp/bin/sd [arguments]\n"
"--model [MODEL]\n--negative-prompt PROMPT\n--cfg-scale SCALE\n--steps STEPS"
),
stderr = "",
returncode = 0,
),
)
assert find_sd_cpp_binary() == "/opt/sd.cpp/bin/sd"
def test_rejected_legacy_sd_allows_managed_install(tmp_path, monkeypatch):
import core.inference.sd_cpp_backend as backend
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda stem: "/usr/bin/sd" if stem == "sd" else None)
monkeypatch.setattr(
eng.subprocess,
"run",
lambda *_a, **_k: types.SimpleNamespace(
stdout = "Find & replace occurrences of a pattern\nUsage: sd [OPTIONS] <FIND> <REPLACE>",
stderr = "",
returncode = 0,
),
)
installed = tmp_path / "managed" / "sd-cli"
installs = []
def _install(**kwargs):
installs.append(kwargs)
return installed
stub = types.ModuleType("install_sd_cpp_prebuilt")
stub.install = _install
monkeypatch.setitem(sys.modules, "install_sd_cpp_prebuilt", stub)
assert backend.ensure_sd_cpp_binary(accelerator = "cpu") == str(installed)
assert installs == [{"accelerator": "cpu"}]
def test_identity_probe_is_memoized_per_file_revision(tmp_path, monkeypatch):
# Discovery runs on every load, and ensure_sd_cpp_binary resolves twice on its own, so an
# unrelated `sd` was re-executed several times per load -- once per full 10s timeout when the
# candidate hangs. The verdict is keyed on the file, not the path, so an in-place replacement
# is still re-probed rather than answered from a stale entry.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("#!/bin/sh\n")
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
runs: list[list[str]] = []
def _run(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(
stdout = "Find & replace occurrences of a pattern\n", stderr = "", returncode = 0
)
monkeypatch.setattr(eng.subprocess, "run", _run)
assert find_sd_cpp_binary() is None
assert find_sd_cpp_binary() is None
assert len(runs) == 1
# Replaced in place by a genuine build: a new revision, so the old verdict does not apply.
candidate.write_text("#!/bin/sh\n# a real stable-diffusion.cpp build now\n")
os.utime(candidate, (0, 0))
def _run_real(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(
stdout = "stable-diffusion.cpp version unknown\n", stderr = "", returncode = 0
)
monkeypatch.setattr(eng.subprocess, "run", _run_real)
assert find_sd_cpp_binary() == str(candidate)
assert len(runs) == 2
def test_identity_probe_rekeys_a_timestamp_preserving_replacement(tmp_path, monkeypatch):
# cp -p / shutil.copy2 / an archive carrying source timestamps restore the mtime of the file
# they overwrite, so path + mtime + size alone would serve the old verdict for a different
# program. The inode change time is not restorable that way.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("A" * 64)
stamp = os.stat(candidate)
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
runs: list[list[str]] = []
def _reject(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(stdout = "Find & replace\n", stderr = "", returncode = 0)
monkeypatch.setattr(eng.subprocess, "run", _reject)
assert find_sd_cpp_binary() is None
# Same path, same size, mtime restored -- a different program underneath.
candidate.write_text("B" * 64)
os.utime(candidate, ns = (stamp.st_atime_ns, stamp.st_mtime_ns))
assert os.stat(candidate).st_mtime_ns == stamp.st_mtime_ns
def _accept(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(
stdout = "stable-diffusion.cpp version unknown\n", stderr = "", returncode = 0
)
monkeypatch.setattr(eng.subprocess, "run", _accept)
assert find_sd_cpp_binary() == str(candidate)
assert len(runs) == 2
def test_identity_probe_does_not_memoize_a_nonzero_exit_it_learned_nothing_from(
tmp_path, monkeypatch
):
# A genuine sd.cpp that cannot load an adjacent shared library exits 127 from the dynamic
# loader with nothing identifying on either stream. That is a CompletedProcess, not an
# exception, so it would otherwise be cached as a definitive "not stable-diffusion.cpp"
# against a file that never changed -- and installing the missing library would not get it
# re-probed until Studio restarted.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("#!/bin/sh\n")
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
monkeypatch.setattr(
eng.subprocess,
"run",
lambda *_a, **_k: types.SimpleNamespace(
stdout = "",
stderr = "sd: error while loading shared libraries: libggml.so: cannot open",
returncode = 127,
),
)
assert find_sd_cpp_binary() is None
assert eng._IDENTITY_MEMO == {}
# The library is back. Same file, so the same key -- it must be probed again, not answered.
monkeypatch.setattr(
eng.subprocess,
"run",
lambda *_a, **_k: types.SimpleNamespace(
stdout = "stable-diffusion.cpp version unknown\n", stderr = "", returncode = 0
),
)
assert find_sd_cpp_binary() == str(candidate)
def test_identity_probe_memoizes_an_identifying_build_that_exits_nonzero(tmp_path, monkeypatch):
# The other half: older builds print usage and exit 1. Identifying output settles the question
# whatever the exit code, so that verdict is decisive and worth keeping.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("#!/bin/sh\n")
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
runs = []
monkeypatch.setattr(
eng.subprocess,
"run",
lambda cmd, **_k: (
runs.append(cmd),
types.SimpleNamespace(
stdout = "usage: sd\n --negative-prompt P\n --cfg-scale S\n --steps N\n",
stderr = "",
returncode = 1,
),
)[1],
)
assert find_sd_cpp_binary() == str(candidate)
assert find_sd_cpp_binary() == str(candidate)
assert len(runs) == 1
def test_identity_verdict_expires(tmp_path, monkeypatch):
# No stat tuple is a content hash. On Windows st_ctime is the CREATION time, which an in-place
# overwrite preserves, so a same-sized write that also restores mtime leaves the whole key
# unchanged. Hashing the binary on every lookup would cost a full read on a path walked for
# every load; a short life bounds that staleness instead, and bounds whatever else the key
# cannot see.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("#!/bin/sh\n")
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
runs = []
def _reject(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(stdout = "Find & replace\n", stderr = "", returncode = 0)
monkeypatch.setattr(eng.subprocess, "run", _reject)
assert find_sd_cpp_binary() is None
assert find_sd_cpp_binary() is None
assert len(runs) == 1 # inside the window, the verdict answers
clock = [time.monotonic() + eng._IDENTITY_MEMO_TTL_S + 1]
monkeypatch.setattr(eng.time, "monotonic", lambda: clock[0])
def _accept(cmd, **_kwargs):
runs.append(cmd)
return types.SimpleNamespace(
stdout = "stable-diffusion.cpp version unknown\n", stderr = "", returncode = 0
)
monkeypatch.setattr(eng.subprocess, "run", _accept)
# Past the window the same unchanged file is probed again, so a replacement the key could not
# see is picked up rather than being answered from a verdict about the binary it replaced.
assert find_sd_cpp_binary() == str(candidate)
assert len(runs) == 2
def test_identity_probe_does_not_memoize_a_probe_that_failed(tmp_path, monkeypatch):
# A timeout or a failed spawn does not touch the file, so its memo key does not change either.
# Remembering that "no" would blacklist a genuine build for the life of the process over one
# slow --help under disk or memory pressure -- Studio would have to be restarted to see it.
_clear_env(monkeypatch)
candidate = tmp_path / "sd"
candidate.write_text("#!/bin/sh\n")
monkeypatch.setattr(eng.shutil, "which", lambda stem: str(candidate) if stem == "sd" else None)
def _timeout(*_args, **_kwargs):
raise eng.subprocess.TimeoutExpired("sd", 10)
monkeypatch.setattr(eng.subprocess, "run", _timeout)
assert find_sd_cpp_binary() is None
assert eng._IDENTITY_MEMO == {}
# Same file, unchanged on disk, and the probe now answers: the earlier failure must not stand in.
monkeypatch.setattr(
eng.subprocess,
"run",
lambda *_a, **_k: types.SimpleNamespace(
stdout = "stable-diffusion.cpp version unknown\n", stderr = "", returncode = 0
),
)
assert find_sd_cpp_binary() == str(candidate)
def test_identity_probe_does_not_memoize_a_candidate_it_cannot_stat(monkeypatch):
# No key means no cache entry: a path that does not resolve yet must be re-probed once it does,
# rather than being remembered as "not stable-diffusion.cpp" for the life of the process.
_clear_env(monkeypatch)
monkeypatch.setattr(
eng.shutil, "which", lambda stem: "/nonexistent/sd" if stem == "sd" else None
)
runs = []
monkeypatch.setattr(
eng.subprocess,
"run",
lambda cmd, **_k: (
runs.append(cmd),
types.SimpleNamespace(stdout = "unrelated\n", stderr = "", returncode = 0),
)[1],
)
assert find_sd_cpp_binary() is None
assert find_sd_cpp_binary() is None
assert len(runs) == 2
assert eng._IDENTITY_MEMO == {}
def test_find_returns_none_when_absent(tmp_path, monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_cpp_binary() is None
# ── sd-server discovery ──────────────────────────────────────────────────────
def _clear_server_env(monkeypatch):
monkeypatch.delenv("SD_SERVER_PATH", raising = False)
monkeypatch.delenv("SD_CLI_PATH", raising = False)
monkeypatch.delenv("UNSLOTH_SD_CPP_PATH", raising = False)
def test_find_server_prefers_sd_server_path_env(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
binary = tmp_path / "sd-server"
binary.write_text("x")
monkeypatch.setenv("SD_SERVER_PATH", str(binary))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() == str(binary)
def test_find_server_build_layout(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
root = tmp_path / "sdcpp"
built = root / "build" / "bin" / "sd-server"
built.parent.mkdir(parents = True)
built.write_text("x")
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() == str(built)
def test_find_server_path_fallback(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(
eng.shutil, "which", lambda stem: "/usr/bin/sd-server" if stem == "sd-server" else None
)
assert find_sd_server_binary() == "/usr/bin/sd-server"
def test_find_server_not_confused_with_sd_cli(tmp_path, monkeypatch):
# A tree with only sd-cli must NOT be reported as an sd-server (and vice versa), so the backend falls back to one-shot.
_clear_server_env(monkeypatch)
root = tmp_path / "sdcpp"
(root / "build" / "bin").mkdir(parents = True)
(root / "build" / "bin" / "sd-cli").write_text("x")
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() is None
assert find_sd_cpp_binary() == str(root / "build" / "bin" / "sd-cli")
# ── availability / version ──────────────────────────────────────────────────
def test_engine_unavailable_when_no_binary(monkeypatch):
# Force the "no binary anywhere" condition so the test is hermetic on a host that happens to have sd-cli installed.
monkeypatch.setattr(eng, "find_sd_cpp_binary", lambda: None)
e = SdCppEngine(binary = None)
assert e.is_available() is False
assert e.version() is None
def test_engine_version_parsed_and_cached(tmp_path, monkeypatch):
binary = tmp_path / "sd-cli"
binary.write_text("x")
e = SdCppEngine(binary = str(binary))
calls = {"n": 0}
def _fake_run(*_a, **_k):
calls["n"] += 1
return types.SimpleNamespace(
stdout = "stable-diffusion.cpp version master-721\n", stderr = "", returncode = 0
)
monkeypatch.setattr(eng.subprocess, "run", _fake_run)
assert e.version() == "stable-diffusion.cpp version master-721"
assert e.version() == "stable-diffusion.cpp version master-721"
assert calls["n"] == 1 # cached after the first probe
# ── runtime env (bundled shared libs) ───────────────────────────────────────
def test_runtime_env_prepends_binary_dir_to_lib_path():
var = eng._lib_path_var()
env = runtime_env("/opt/sdcpp/bin/sd-cli", {var: "/existing"})
first = env[var].split(os.pathsep)[0]
assert first == "/opt/sdcpp/bin"
assert "/existing" in env[var]
def test_runtime_env_scrubs_native_path_lease_secret(monkeypatch):
# The sd-cli child is an external process and must never receive the native-path lease secret; every launch funnels through runtime_env.
monkeypatch.setenv("UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET", "top-secret")
from_os = runtime_env("/opt/sdcpp/bin/sd-cli")
assert "UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET" not in from_os
from_base = runtime_env(
"/opt/sdcpp/bin/sd-cli",
{"UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET": "top-secret"},
)
assert "UNSLOTH_STUDIO_NATIVE_PATH_LEASE_SECRET" not in from_base
def test_runtime_env_handles_missing_lib_path():
var = eng._lib_path_var()
env = runtime_env("/opt/sdcpp/bin/sd-cli", {})
assert env[var] == "/opt/sdcpp/bin"
def test_terminate_reaps_killed_child():
# Cancellation/timeout paths call _terminate then raise, so it must reap the killed child itself or a burst of image
# cancellations leaves zombies. After _terminate the returncode is set, so nothing lingers.
import subprocess
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
start_new_session = (os.name == "posix"),
)
try:
eng._terminate(proc)
assert proc.returncode is not None
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
# ── generate (fake subprocess) ──────────────────────────────────────────────
class _FakePopen:
"""Stand-in for subprocess.Popen: streams ``lines`` then writes ``out_file``
(unless ``write`` is False) and exits with ``returncode``."""
captured_cmd: list[str] = []
captured_env: dict = {}
def __init__(
self,
cmd,
*,
lines,
returncode,
out_file,
write,
env = None,
):
type(self).captured_cmd = list(cmd)
type(self).captured_env = dict(env or {})
self.pid = 424242 # a real Popen has one, and the lifetime record needs it
self._lines = list(lines)
self.returncode = returncode
self._out_file = out_file
self._write = write
@property
def stdout(self):
return iter(self._lines)
def wait(self, timeout = None):
if self._write:
Path(self._out_file).write_bytes(b"\x89PNG\r\n")
return self.returncode
def poll(self):
return self.returncode
def kill(self):
pass
def _patch_popen(
monkeypatch,
*,
lines,
returncode,
out_file,
write = True,
):
def _factory(cmd, **kw):
return _FakePopen(
cmd,
lines = lines,
returncode = returncode,
out_file = out_file,
write = write,
env = kw.get("env"),
)
monkeypatch.setattr(eng.subprocess, "Popen", _factory)
def _engine(tmp_path):
binary = tmp_path / "sd-cli"
binary.write_text("x")
return SdCppEngine(binary = str(binary))
def test_generate_success_returns_path_and_collects_logs(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(
monkeypatch, lines = ["loading model", "step 1/8", "done"], returncode = 0, out_file = out
)
seen: list[str] = []
files = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/ae.sft", llm = "/m/q.gguf")
params = SdCppGenParams(prompt = "a cat", steps = 8, seed = 1)
result = e.generate(files, params, output_path = str(out), on_log = seen.append)
assert result == out and out.is_file()
assert seen == ["loading model", "step 1/8", "done"]
# the real argv was built and handed to Popen
assert "--diffusion-model" in _FakePopen.captured_cmd
assert str(out) == _FakePopen.captured_cmd[_FakePopen.captured_cmd.index("--output") + 1]
# the subprocess env carries the binary's dir on the library path
var = eng._lib_path_var()
assert str(Path(e.binary).resolve().parent) in _FakePopen.captured_env.get(var, "")
def test_generate_raises_on_nonzero_exit(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(monkeypatch, lines = ["boom: bad gguf"], returncode = 1, out_file = out, write = False)
with pytest.raises(RuntimeError, match = "exited 1"):
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(out),
)
def test_generate_raises_when_no_output_despite_success(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(monkeypatch, lines = ["ok"], returncode = 0, out_file = out, write = False)
with pytest.raises(RuntimeError, match = "no image"):
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(out),
)
def test_generate_does_not_return_stale_preexisting_output(tmp_path, monkeypatch):
# A leftover file at the target path must not satisfy the post-run output check when the run produced nothing: the target is cleared first.
e = _engine(tmp_path)
out = tmp_path / "img.png"
out.write_bytes(b"stale")
_patch_popen(monkeypatch, lines = ["ok"], returncode = 0, out_file = out, write = False)
with pytest.raises(RuntimeError, match = "no image"):
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(out),
)
assert not out.exists()
def test_generate_raises_when_binary_missing():
e = SdCppEngine(binary = None)
with pytest.raises(RuntimeError, match = "not found"):
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = "/tmp/x.png",
)
class _HangingPopen:
"""A child that runs but never prints and never exits -- the case a plain
`for line in stdout` would block on forever, ignoring the timeout."""
def __init__(self, cmd, **_kw):
self._alive = True
self.pid = 424243
class _Blocking:
def __init__(self, owner):
self.owner = owner
def __iter__(self):
return self
def __next__(self):
while self.owner._alive:
time.sleep(0.01)
raise StopIteration
@property
def stdout(self):
return self._Blocking(self)
def poll(self):
return None if self._alive else -9
def wait(self, timeout = None):
self._alive = False
return -9
def kill(self):
self._alive = False
def test_generate_times_out_on_silent_hang(tmp_path, monkeypatch):
e = _engine(tmp_path)
monkeypatch.setattr(eng.subprocess, "Popen", lambda cmd, **kw: _HangingPopen(cmd, **kw))
t0 = time.time()
with pytest.raises(RuntimeError, match = "timed out"):
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(tmp_path / "x.png"),
timeout = 0.3,
)
# The timeout is enforced promptly (not blocked until stdout EOF).
assert time.time() - t0 < 5.0
def test_img2img_generate_passes_init_image(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
src = tmp_path / "src.png"
src.write_bytes(b"\x89PNG\r\n")
_patch_popen(monkeypatch, lines = ["img2img"], returncode = 0, out_file = out)
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x", init_img = str(src), strength = 0.5),
output_path = str(out),
)
assert "--init-img" in _FakePopen.captured_cmd
assert str(src) == _FakePopen.captured_cmd[_FakePopen.captured_cmd.index("--init-img") + 1]
def test_generate_native_speed_dedupes_against_offload(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(monkeypatch, lines = ["ok"], returncode = 0, out_file = out)
# offload already adds --diffusion-fa; native_speed="default" would add it again.
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(out),
offload = ["--offload-to-cpu", "--diffusion-fa"],
native_speed = "default",
)
# --diffusion-fa appears exactly once (de-duped), not twice.
assert _FakePopen.captured_cmd.count("--diffusion-fa") == 1
def test_generate_native_speed_adds_flag_when_not_offloaded(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(monkeypatch, lines = ["ok"], returncode = 0, out_file = out)
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "x"),
output_path = str(out),
offload = [], # fast/resident tier: no offload, but speed flag still applies
native_speed = "default",
)
assert _FakePopen.captured_cmd.count("--diffusion-fa") == 1
def test_upscale_runs_and_returns_path(tmp_path, monkeypatch):
e = _engine(tmp_path)
out = tmp_path / "big.png"
_patch_popen(monkeypatch, lines = ["upscaling", "done"], returncode = 0, out_file = out)
result = e.upscale(
SdCppUpscaleParams(input_image = "/in/small.png", upscale_model = "/m/esrgan.pth", repeats = 2),
output_path = str(out),
)
assert result == out and out.is_file()
assert _FakePopen.captured_cmd[_FakePopen.captured_cmd.index("--mode") + 1] == "upscale"
assert "--upscale-model" in _FakePopen.captured_cmd
def test_upscale_raises_when_binary_missing(monkeypatch, tmp_path):
monkeypatch.setattr(eng, "find_sd_cpp_binary", lambda: None)
e = SdCppEngine(binary = None)
with pytest.raises(RuntimeError, match = "not found"):
e.upscale(
SdCppUpscaleParams(input_image = "/i.png", upscale_model = "/m/e.pth"),
output_path = str(tmp_path / "x.png"),
)
# ── engine routing ──────────────────────────────────────────────────────────
def test_routing_gpu_backends_use_diffusers():
for backend in ("cuda", "rocm", "xpu"):
assert select_diffusion_engine(backend, native_available = True) == ENGINE_DIFFUSERS
def test_routing_cpu_and_mps_use_native_when_available():
assert select_diffusion_engine("cpu", native_available = True) == ENGINE_SD_CPP
assert select_diffusion_engine("mps", native_available = True) == ENGINE_SD_CPP
def test_routing_cpu_falls_back_to_diffusers_without_binary():
assert select_diffusion_engine("cpu", native_available = False) == ENGINE_DIFFUSERS
def test_routing_prefer_native_overrides_gpu():
assert (
select_diffusion_engine("cuda", native_available = True, prefer_native = True) == ENGINE_SD_CPP
)
# but only if a binary is actually available
assert (
select_diffusion_engine("cuda", native_available = False, prefer_native = True)
== ENGINE_DIFFUSERS
)
def test_native_generation_timeout_matches_the_ui_settle_window():
# The native engine exists for slow CPU hosts: on GPU-less CI runners a 512x512 4-step Q2_K generation took 900 s (Linux)
# and 1465 s (Windows), so the old 30-minute default killed still-progressing jobs. The ceiling now matches SETTLE_MAX_MS.
from core.inference.sd_cpp_engine import NATIVE_GENERATION_TIMEOUT_S, SdCppEngine
from core.inference import sd_cpp_backend
assert NATIVE_GENERATION_TIMEOUT_S == 6 * 60 * 60
for fn in (SdCppEngine.generate, SdCppEngine.upscale):
assert (
inspect.signature(fn).parameters["timeout"].default == NATIVE_GENERATION_TIMEOUT_S
), fn.__name__
# The resident-server path shares the same ceiling, applied per request (see test_server_generate_splits_batches_above_server_limit).
assert sd_cpp_backend.NATIVE_GENERATION_TIMEOUT_S == NATIVE_GENERATION_TIMEOUT_S
# ── in-place progress redraws ───────────────────────────────────────────────
# sd-cli redraws its sampling bar with a LEADING carriage return and closes each redraw with an
# erase-to-end-of-line, emitting a newline only on the final step:
# printf("\r%s %i/%i - %s\033[K%s", bar, step, steps, speed, step == steps ? "\n" : "")
# so a reader that keys only on newlines reports nothing until sampling is already over.
_REDRAW = "\r |=========> | {}/{} - 21.50s/it\x1b[K"
def test_split_progress_records_treats_erase_as_a_terminator():
"""The redraw is complete the moment sd-cli flushes it, even though its own newline never
comes and the NEXT redraw's carriage return has not arrived yet."""
records, rest = eng.split_progress_records(_REDRAW.format(7, 30))
assert records == ["", " |=========> | 7/30 - 21.50s/it\x1b[K"]
assert rest == ""
def test_split_progress_records_keeps_unterminated_remainder():
records, rest = eng.split_progress_records("done\nhalf a li")
assert records == ["done"]
assert rest == "half a li"
def test_split_progress_records_counts_crlf_as_one_terminator():
records, rest = eng.split_progress_records("a\r\nb\r\n")
assert records == ["a", "b"]
assert rest == ""
def test_strip_ansi_removes_the_erase_sequence():
assert eng.strip_ansi(" |==> | 7/30 - 21.50s/it\x1b[K") == " |==> | 7/30 - 21.50s/it"
class _ChunkStream:
"""A text stream over a pipe: ``.buffer.read1`` returns whatever the child has flushed,
exactly like a real subprocess pipe, and iteration would block until a newline. Counts reads
so a test can prove WHEN a record was delivered, not merely that it arrived eventually."""
class _Raw:
def __init__(self, chunks, owner):
self._chunks = list(chunks)
self._owner = owner
def read1(self, _n):
if not self._chunks:
return b""
self._owner.reads += 1
return self._chunks.pop(0)
def __init__(self, chunks):
self.reads = 0
self.buffer = self._Raw(chunks, self)
def __iter__(self):
raise AssertionError("iteration would block on a redraw that carries no newline")
def test_iter_records_delivers_every_redraw():
chunks = [_REDRAW.format(i, 3).encode() for i in (1, 2)]
chunks.append((_REDRAW.format(3, 3) + "\n").encode())
got = [r for r in eng.iter_sd_cpp_records(_ChunkStream(chunks)) if r.strip()]
assert got == [
" |=========> | 1/3 - 21.50s/it",
" |=========> | 2/3 - 21.50s/it",
" |=========> | 3/3 - 21.50s/it",
]
def test_iter_records_delivers_a_redraw_as_soon_as_it_is_flushed():
"""The actual regression: progress was not merely late-ish, it was one redraw behind, so a
30-step job showed 0/30 until step 2 and never showed the last step before completion.
Delivering after ONE read is the whole claim. A redraw carries no newline, and its carriage
return sits at the front of the NEXT redraw, so a reader terminating only on CR/LF cannot
produce step 1 until step 2 has been flushed -- which is a second read.
"""
stream = _ChunkStream([_REDRAW.format(i, 3).encode() for i in (1, 2, 3)])
records = eng.iter_sd_cpp_records(stream)
first = next(r for r in records if r.strip())
assert first == " |=========> | 1/3 - 21.50s/it"
assert stream.reads == 1
def test_iter_records_decodes_utf8_split_across_reads():
"""A multi-byte character straddling two read1() boundaries must not become mojibake."""
blob = "café\n".encode()
stream = _ChunkStream([blob[:4], blob[4:]])
assert list(eng.iter_sd_cpp_records(stream)) == ["café"]
def test_iter_records_falls_back_to_line_iteration_without_a_raw_buffer():
"""Test doubles (and non-pipe streams) hand us a plain iterable with no ``.buffer``."""
lines = ["loading\n", _REDRAW.format(4, 4) + "\n"]
got = [r for r in eng.iter_sd_cpp_records(iter(lines)) if r.strip()]
assert got == ["loading", " |=========> | 4/4 - 21.50s/it"]
def test_run_forwards_clean_redraws_to_on_log(tmp_path, monkeypatch):
"""End of the engine's own chain: a redraw reaches on_log, with no escape left in it."""
e = _engine(tmp_path)
out = tmp_path / "img.png"
_patch_popen(
monkeypatch,
lines = [_REDRAW.format(1, 2), _REDRAW.format(2, 2) + "\n"],
returncode = 0,
out_file = str(out),
)
seen: list[str] = []
e.generate(
SdCppModelFiles(diffusion_model = "/m/z.gguf"),
SdCppGenParams(prompt = "p"),
output_path = str(out),
on_log = seen.append,
)
bars = [s for s in seen if "|" in s]
assert bars == [
" |=========> | 1/2 - 21.50s/it",
" |=========> | 2/2 - 21.50s/it",
]
assert not any("\x1b" in s for s in seen)