Spaces:
Running on Zero
Running on Zero
Download tests/test_runtime.py from cpuai/blender-render-studio: direct link, hf CLI and curl.
- Browser
- Download file 10.3 kB
-
https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/tests/test_runtime.py
- Command line
-
hf download hf://spaces/cpuai/blender-render-studio/tests/test_runtime.py
-
curl -L -o test_runtime.py https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/tests/test_runtime.py
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 | |
| 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() | |
| 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")) | |