blender-render-studio / tests /test_runtime.py
cpuai's picture
Bundle verified Blender runtime to fix startup download 403
3a5873f verified
Raw History Blame Contribute Delete
10.3 kB
"""可复现的运行时边界测试,不联网,不需要 Blender 或 GPU。
安装测试用本地压缩包替代下载;子进程测试真实启动临时 Python 可执行
程序验证环境和进程树管理,不代表真实 Cycles / ZeroGPU 已通过测试。
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import sys
import tarfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import blender_runtime as runtime
pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="目标运行时是 Hugging Face Linux Space")
ARCHIVE_ROOT = "blender-5.2.2-linux-x64"
ARCHIVE_NAME = ARCHIVE_ROOT + ".tar.xz"
def make_archive(path: Path, entries: list[tuple[str, bytes | None, str | None]]) -> Path:
"""None 内容表示目录;第三列非空表示符号链接。"""
with tarfile.open(path, "w:xz") as archive:
for name, data, link in entries:
member = tarfile.TarInfo(name)
if link is not None:
member.type = tarfile.SYMTYPE
member.linkname = link
archive.addfile(member)
elif data is None:
member.type = tarfile.DIRTYPE
member.mode = 0o755
archive.addfile(member)
else:
member.mode = 0o755
member.size = len(data)
archive.addfile(member, io.BytesIO(data))
return path
@pytest.fixture
def installation(monkeypatch, tmp_path):
archive = make_archive(tmp_path / ARCHIVE_NAME, [
(ARCHIVE_ROOT, None, None),
(ARCHIVE_ROOT + "/blender", b"#!/bin/sh\nexit 0\n", None),
])
checksum = tmp_path / "checksums.sha256"
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
checksum.write_text(f"{digest} {ARCHIVE_NAME}\n", encoding="utf-8")
cache = tmp_path / "cache"
monkeypatch.delenv("BLENDER_BIN", raising=False)
monkeypatch.setenv("BLENDER_VERSION", "5.2.2")
monkeypatch.setenv("BLENDER_CACHE", str(cache))
# 测试使用独立目录,不能误用部署仓库随附的真实大安装包。
monkeypatch.setattr(runtime, "__file__", str(tmp_path / "blender_runtime.py"))
monkeypatch.setattr(runtime.shutil, "which", lambda _: None)
calls: list[str] = []
def local_download(url: str, destination: Path, maximum: int) -> None:
calls.append(url)
content = checksum.read_bytes() if url.endswith(".sha256") else archive.read_bytes()
assert len(content) < maximum
destination.write_bytes(content)
monkeypatch.setattr(runtime, "_download", local_download)
return archive, checksum, cache, calls, digest
def test_download_checksum_is_exact_and_mismatch_stops_installation(installation):
archive, checksum, cache, calls, digest = installation
assert runtime._expected_digest(checksum, ARCHIVE_NAME) == digest
with pytest.raises(RuntimeError, match="唯一匹配"):
runtime._expected_digest(checksum, "wrong-name.tar.xz")
# 模拟下载包损坏:官方清单保持不变,下载内容被更改。
archive.write_bytes(archive.read_bytes() + b"corrupted-download")
with pytest.raises(RuntimeError, match="SHA-256 校验失败"):
runtime.ensure_blender()
assert len(calls) == 2
assert not (cache / "blender-5.2.2" / "blender").exists()
def test_tar_rejects_traversal_and_absolute_paths(tmp_path):
for index, name in enumerate([
ARCHIVE_ROOT + "/../../outside", "/tmp/outside", ARCHIVE_ROOT + "/../outside",
]):
archive = make_archive(tmp_path / f"bad-{index}.tar.xz", [(name, b"unsafe", None)])
with pytest.raises(RuntimeError, match="不安全路径"):
runtime._safe_extract(archive, tmp_path / "output", ARCHIVE_ROOT)
assert not (tmp_path / "outside").exists()
def test_tar_accepts_internal_symlinks_and_rejects_escaping_links(tmp_path):
archive = make_archive(tmp_path / "good.tar.xz", [
(ARCHIVE_ROOT, None, None),
(ARCHIVE_ROOT + "/blender", b"executable", None),
(ARCHIVE_ROOT + "/linked", None, "blender"),
])
extracted = runtime._safe_extract(archive, tmp_path / "good", ARCHIVE_ROOT)
assert (extracted / "linked").is_symlink()
assert (extracted / "linked").read_bytes() == b"executable"
bad = make_archive(tmp_path / "escape.tar.xz", [
(ARCHIVE_ROOT + "/linked", None, "../../outside"),
])
with pytest.raises(RuntimeError, match="目录之外"):
runtime._safe_extract(bad, tmp_path / "bad", ARCHIVE_ROOT)
def test_concurrent_installation_downloads_each_file_once(installation):
_, _, cache, calls, _ = installation
# Future.result() 会把线程中的异常带回测试主线程,避免漏报。
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(runtime.ensure_blender) for _ in range(5)]
binaries = [future.result(timeout=10) for future in futures]
assert len(set(binaries)) == 1
assert binaries[0] == cache / "blender-5.2.2" / "blender"
assert os.access(binaries[0], os.X_OK)
assert len(calls) == 2
assert sum(url.endswith(".sha256") for url in calls) == 1
assert sum(url.endswith(".tar.xz") for url in calls) == 1
assert runtime.ensure_blender() == binaries[0]
assert len(calls) == 2
def test_bundled_archive_installs_without_network(installation, monkeypatch, tmp_path):
archive, checksum, cache, calls, _ = installation
vendor = tmp_path / "vendor"
vendor.mkdir()
(vendor / ARCHIVE_NAME).write_bytes(archive.read_bytes())
(vendor / "blender-5.2.2.sha256").write_bytes(checksum.read_bytes())
monkeypatch.setattr(runtime, "__file__", str(tmp_path / "blender_runtime.py"))
binary = runtime.ensure_blender()
assert binary == cache / "blender-5.2.2" / "blender"
assert os.access(binary, os.X_OK)
assert calls == []
def test_corrupt_bundled_archive_is_rejected_without_network(installation, monkeypatch, tmp_path):
archive, checksum, cache, calls, _ = installation
vendor = tmp_path / "vendor"
vendor.mkdir()
(vendor / ARCHIVE_NAME).write_bytes(archive.read_bytes() + b"damaged")
(vendor / "blender-5.2.2.sha256").write_bytes(checksum.read_bytes())
monkeypatch.setattr(runtime, "__file__", str(tmp_path / "blender_runtime.py"))
with pytest.raises(RuntimeError, match="SHA-256 校验失败"):
runtime.ensure_blender()
assert calls == []
assert not (cache / "blender-5.2.2").exists()
@pytest.fixture
def subprocess_runtime(monkeypatch, tmp_path):
fake = tmp_path / "fake-blender"
fake.write_text("#!" + sys.executable + "\n" + r'''
import json, os, subprocess, sys, time
from pathlib import Path
task = json.loads(Path(sys.argv[-1]).read_text(encoding="utf-8"))
if task.get("delay"):
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
Path(task["child_pid"]).write_text(str(child.pid), encoding="utf-8")
time.sleep(60)
else:
Path(task["result_path"]).write_text(json.dumps({
"ok": True,
"secret_present": "HF_TOKEN" in os.environ or "AWS_SECRET_ACCESS_KEY" in os.environ,
"cuda": os.environ.get("CUDA_VISIBLE_DEVICES"),
"threads": os.environ.get("OMP_NUM_THREADS"),
"autoexec_disabled": "--disable-autoexec" in sys.argv,
}), encoding="utf-8")
''', encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setattr(runtime, "_WORKER", fake)
monkeypatch.setattr(runtime, "ensure_blender", lambda: fake)
monkeypatch.setenv("BLENDER_THREADS", "4")
return tmp_path
def test_real_subprocess_scrubs_secrets_and_preserves_cuda(subprocess_runtime, monkeypatch):
root = subprocess_runtime
monkeypatch.setenv("HF_TOKEN", "test-only-never-log")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-only-never-log")
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "test-gpu-uuid")
result = runtime.run_task({"result_path": str(root / "result.json")}, 5, root / "render.log")
assert result == {
"ok": True, "secret_present": False, "cuda": "test-gpu-uuid",
"threads": "4", "autoexec_disabled": True,
}
assert not list(root.glob(".render-task-*.json"))
def assert_descendant_stopped(pid_file: Path) -> None:
assert pid_file.exists(), "测试替身未成功创建子进程,无法验证进程树终止。"
pid = int(pid_file.read_text(encoding="utf-8"))
stat = Path(f"/proc/{pid}/stat")
# 被结束的孤儿子进程可能暂时等待容器 init 回收;Z 状态已不再执行。
deadline = time.monotonic() + 2
while stat.exists() and time.monotonic() < deadline:
try:
if stat.read_text().rsplit(")", 1)[1].split()[0] == "Z":
return
except FileNotFoundError:
return
time.sleep(0.02)
assert not stat.exists(), f"后代进程 {pid} 仍在运行。"
def test_timeout_terminates_real_descendant_process(subprocess_runtime):
root = subprocess_runtime
pid_file = root / "timeout-child.pid"
task = {"result_path": str(root / "timeout.json"), "delay": True, "child_pid": str(pid_file)}
with pytest.raises(TimeoutError, match="超过"):
runtime.run_task(task, 1, root / "timeout.log")
assert_descendant_stopped(pid_file)
assert not list(root.glob(".render-task-*.json"))
def test_cancellation_terminates_real_descendant_process(subprocess_runtime):
root = subprocess_runtime
pid_file = root / "cancel-child.pid"
cancel = root / "cancel.flag"
task = {"result_path": str(root / "cancel.json"), "delay": True, "child_pid": str(pid_file)}
def cancel_when_child_started():
deadline = time.monotonic() + 5
while not pid_file.exists() and time.monotonic() < deadline:
time.sleep(0.02)
cancel.touch()
thread = threading.Thread(target=cancel_when_child_started, daemon=True)
thread.start()
try:
with pytest.raises(RuntimeError, match="取消"):
runtime.run_task(task, 8, root / "cancel.log", cancel)
finally:
thread.join(timeout=6)
assert_descendant_stopped(pid_file)
assert not list(root.glob(".render-task-*.json"))