"""Reproducible native builds and a checked, model-specific executable cache.""" from __future__ import annotations from dataclasses import dataclass from hashlib import sha256 import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import time from .compiler import NativeProgram ROOT = Path(__file__).resolve().parents[3] NATIVE = ROOT / "native" CACHE = ROOT / "app/data/native-builds" LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense") @dataclass(frozen=True) class NativeBuild: executable: Path manifest: dict cache_hit: bool seconds: float def _hash(path: Path) -> str: return sha256(path.read_bytes()).hexdigest() def toolchain() -> tuple[str, Path, str]: compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc") if not compiler: raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.") base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library"))) if not (base / "include/cvode/cvode.h").is_file(): raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.") version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0] return compiler, base, version def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild: start = time.perf_counter() compiler, sundials, compiler_version = toolchain() runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h")) flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"] if os.name == "nt": flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"] libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES] dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES] vc_runtime = sundials / "bin/vcruntime140.dll" if vc_runtime.is_file(): dlls.append(vc_runtime) else: raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.") sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime} sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt") dependencies = {str(p.name): _hash(p) for p in libraries + dlls} # Header hashes include precision/index ABI settings as well as library APIs. for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"): for path in sorted((sundials / "include" / directory).glob("*.h")): dependencies[f"{directory}/{path.name}"] = _hash(path) identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources, dependencies=dependencies, compiler=compiler_version, flags=flags, platform=sys.platform, abi=1) signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() cache = (cache_dir or CACHE).resolve() target = cache / signature manifest_path = target / "manifest.json" if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if all((target / name).is_file() and _hash(target / name) == digest for name, digest in manifest["artifacts"].items()): return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start) raise RuntimeError(f"Native cache integrity check failed: {target}") cache.mkdir(parents=True, exist_ok=True) stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache)) (stage / "model.c").write_text(program.source, encoding="utf-8") (stage / "model.h").write_text(program.header, encoding="utf-8") command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"), "-I", str(sundials / "include"), str(stage / "model.c"), *[str(p) for p in runtime if p.suffix == ".c"], *map(str, libraries), "-lm", "-o", str(stage / "model.exe")] result = subprocess.run(command, capture_output=True, text=True, timeout=120) (stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8") if result.returncode: raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}") for library in dlls: shutil.copy2(library, stage / library.name) shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt") manifest = { **program.manifest(), "buildKey": signature, "compiler": compiler_version, "compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies, "artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"}, } (stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") try: stage.rename(target) except OSError: # A concurrent compiler may have published the identical cache first. if not manifest_path.is_file(): raise published = json.loads(manifest_path.read_text(encoding="utf-8")) # PE linker timestamps can differ between concurrent equivalent builds. # Validate the winning build against its own hashes and our identity. if published.get("buildKey") != signature or not all( (target / name).is_file() and _hash(target / name) == digest for name, digest in published["artifacts"].items() ): raise RuntimeError("Concurrent native build did not produce the expected artifacts.") manifest = published if stage.resolve().parent != cache: raise RuntimeError("Unexpected native build staging directory.") shutil.rmtree(stage) return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start)