mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* 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>
959 lines
37 KiB
Python
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)
|