"""Capture one native GUI quit plus independent background readback; never claim release acceptance.""" import argparse, datetime, hashlib, json, os, pathlib, shutil, signal, subprocess ROOT = pathlib.Path(__file__).resolve().parents[2] parser = argparse.ArgumentParser() parser.add_argument("--blender", default=str(ROOT / "build_blender_native/bin/blender")) parser.add_argument("--input", required=True); parser.add_argument("--output", required=True) parser.add_argument("--timeout", type=int, default=300) args = parser.parse_args(); native_binary = pathlib.Path(args.blender).resolve(); asset_input = pathlib.Path(args.input).resolve(); output = pathlib.Path(args.output).resolve() def sha(path): digest = hashlib.sha256() with pathlib.Path(path).open("rb") as stream: for chunk in iter(lambda: stream.read(1048576), b""): digest.update(chunk) return digest.hexdigest() producer = ROOT / "web/tests/fixtures/native-last-session-prepare.py"; reader = ROOT / "web/tests/fixtures/native-last-session-readback.py" companion = asset_input.with_name("ExternalImage.png") assert 0 < args.timeout <= 1800 and asset_input.suffix.lower() == ".blend" and all(path.is_file() for path in (native_binary, asset_input, companion, producer, reader)) and os.access(native_binary, os.X_OK), "Native executable, source fixtures, input blend and original image companion are required" environment = dict(os.environ); gui_prefix = [] if not (environment.get("DISPLAY") or environment.get("WAYLAND_DISPLAY")): xvfb = shutil.which("xvfb-run"); assert xvfb, "GUI quit capture requires a live display or existing xvfb-run" gui_prefix = [xvfb, "-a"]; environment.setdefault("LIBGL_ALWAYS_SOFTWARE", "1") output.mkdir(parents=True, exist_ok=False); assets = output / "assets"; assets.mkdir() working_input = assets / asset_input.name; shutil.copy2(asset_input, working_input); shutil.copy2(companion, assets / companion.name) work = output / "native-work"; receipt_path = output / "desktop-baseline.json" state = {"schemaVersion": 1, "kind": "desktop-native-quit-baseline", "phase": "PREPARED", "acceptance": "NOT_CLAIMED", "workDir": str(work), "nativeBinary": {"path": str(native_binary), "sha256": sha(native_binary)}, "sources": {name: {"path": str(path), "sha256": sha(path)} for name, path in (("producer", producer), ("reader", reader), ("capture", pathlib.Path(__file__).resolve()))}, "input": {"source": str(asset_input), "path": str(working_input), "sha256": sha(working_input), "companion": {"source": str(companion), "path": str(assets / companion.name), "sha256": sha(assets / companion.name)}}, "steps": {}} def persist(): temporary = receipt_path.with_suffix(".tmp"); temporary.write_text(json.dumps(state, indent=2) + "\n"); temporary.replace(receipt_path) def terminate_owned(process): for sig, grace in ((signal.SIGTERM, 5), (signal.SIGKILL, 5)): try: os.killpg(process.pid, sig) except ProcessLookupError: pass try: process.wait(timeout=grace); return except subprocess.TimeoutExpired: continue state["cleanupPendingPid"] = process.pid; persist(); raise RuntimeError(f"Owned process {process.pid} remains live after bounded cleanup") def run_step(label, command): log = output / f"{label}.log"; step = {"command": command, "log": str(log), "startedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), "timeoutSeconds": args.timeout}; state["steps"][label] = step; state["phase"] = label.upper() + "_RUNNING"; persist() with log.open("w") as stream: process = subprocess.Popen(command, cwd=ROOT, env=environment, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) step["pid"] = process.pid; persist() try: step["exitCode"] = process.wait(timeout=args.timeout) except subprocess.TimeoutExpired: step["timedOut"] = True; terminate_owned(process); step["exitCode"] = process.returncode except BaseException: terminate_owned(process); raise step["finishedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat(); step["logSha256"] = sha(log); persist() if step.get("timedOut") or step["exitCode"] != 0: raise RuntimeError(f"{label} failed or timed out; see {log}") def capture(): persist() run_step("gui-producer", gui_prefix + [str(native_binary), "--factory-startup", "--disable-autoexec", "--python-exit-code", "1", "--python", str(producer), "--", "--input", str(working_input), "--work-dir", str(work)]) quit_file = work / "native-temp/quit.blend"; assert quit_file.is_file() and quit_file.stat().st_size > 0, "GUI exit did not produce quit.blend"; state["quitFile"] = {"path": str(quit_file), "bytes": quit_file.stat().st_size, "sha256": sha(quit_file)}; persist() run_step("background-reader", [str(native_binary), "--background", "--factory-startup", "--disable-autoexec", "--python-exit-code", "1", "--python", str(reader), "--", "--work-dir", str(work)]) readback_path = work / "native-last-session-readback.json"; readback = json.loads(readback_path.read_text()) assert readback["kind"] == "desktop-native-quit-readback" and readback["phase"] == "NATIVE_QUIT_READBACK_CAPTURED" and readback["nativeBinary"] == str(native_binary) and readback["quitFile"] == state["quitFile"] and sha(quit_file) == state["quitFile"]["sha256"] state["readback"] = {"path": str(readback_path), "sha256": sha(readback_path)}; state["version"] = readback["version"]; state["platform"] = readback["platform"] assert readback["inputSha256"] == state["input"]["sha256"] and readback["producerSha256"] == state["sources"]["producer"]["sha256"] and readback["readerSha256"] == state["sources"]["reader"]["sha256"] and sha(work / "native-last-session-expected.json") == readback["expectedReceiptSha256"] assert sha(native_binary) == state["nativeBinary"]["sha256"] and all(sha(item["path"]) == item["sha256"] for item in state["sources"].values()), "Native/source inputs changed during capture" assert sha(asset_input) == sha(working_input) == state["input"]["sha256"] and sha(companion) == sha(assets / companion.name) == state["input"]["companion"]["sha256"], "Original or working assets changed" state["phase"] = "DESKTOP_BASELINE_CAPTURED"; state["finishedAt"] = datetime.datetime.now(datetime.timezone.utc).isoformat() persist(); print(str(receipt_path), flush=True) def capture_interrupted(signum, frame): raise KeyboardInterrupt(f"Capture interrupted by signal {signum}") signal.signal(signal.SIGTERM, capture_interrupted) try: capture() except BaseException as error: state["phase"] = "FAILED_WITH_OWNED_PROCESS_PENDING" if state.get("cleanupPendingPid") else "FAILED"; state["error"] = str(error); persist(); raise