File size: 10,341 Bytes
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a5873f
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a5873f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""可复现的运行时边界测试,不联网,不需要 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"))