"""Small, uncached compile/link/run check using the production native toolchain.""" from __future__ import annotations import os from pathlib import Path import shutil import signal import subprocess import tempfile from threading import Event from time import monotonic from typing import Callable from .build import CACHE, NativeCommandError, link_library_arguments, platform_build_inputs, toolchain from .processes import command_environment, is_driver_launch_failure STAGE_MESSAGES = { "toolchain": "正在查找编译器与 SUNDIALS", "preparing": "正在准备最小自检程序", "preprocessing": "正在预处理最小自检程序", "compiling": "正在编译最小自检程序", "linking": "正在链接最小自检程序", "running": "正在运行最小自检程序", "schema": "正在检查模型 XML 解析环境", } OUTPUT_MARKER = "native-runtime-check-ok" # One smooth scalar equation exercises every library used by production BDF. # Its known answer is independent of the user's model and cannot hit model cache. SOURCE = r''' #include #include #include #include #include #include #include static int rhs(sunrealtype t, N_Vector y, N_Vector dy, void *data) { (void)t; (void)data; NV_Ith_S(dy,0)=-NV_Ith_S(y,0); return 0; } int main(void) { SUNContext context=NULL; N_Vector y=NULL; SUNMatrix matrix=NULL; SUNLinearSolver linear=NULL; void *solver=NULL; int result=1; sunrealtype time=0; if (SUNContext_Create(SUN_COMM_NULL,&context)) goto done; y=N_VNew_Serial(1,context); if (!y) goto done; NV_Ith_S(y,0)=1; matrix=SUNDenseMatrix(1,1,context); if (!matrix) goto done; linear=SUNLinSol_Dense(y,matrix,context); solver=CVodeCreate(CV_BDF,context); if (!linear || !solver) goto done; if (CVodeInit(solver,rhs,0,y)<0 || CVodeSStolerances(solver,1e-8,1e-10)<0 || CVodeSetLinearSolver(solver,linear,matrix)<0 || CVode(solver,0.1,y,&time,CV_NORMAL)<0) goto done; if (!isfinite(NV_Ith_S(y,0)) || fabs(NV_Ith_S(y,0)-exp(-0.1))>1e-6) goto done; puts("native-runtime-check-ok"); result=0; done: if (result) fputs("Minimal BDF self-check failed.\n",stderr); if (solver) CVodeFree(&solver); if (linear) SUNLinSolFree(linear); if (matrix) SUNMatDestroy(matrix); if (y) N_VDestroy(y); if (context) SUNContext_Free(&context); return result; } ''' class CheckCancelled(Exception): pass class CheckFailure(RuntimeError): def __init__(self, message: str, *, command=(), cwd=None, exit_code=None, stdout="", stderr="", error_type="RuntimeError"): super().__init__(message) self.details = {"command": list(command), "cwd": str(cwd) if cwd else None, "exitCode": exit_code, "stdout": stdout[-8000:], "stderr": stderr[-8000:], "errorType": error_type} def _stop_process_tree(process: subprocess.Popen) -> None: # GCC starts cc1/as/ld. Killing only its driver can leave inherited pipes open. if os.name == "nt": try: subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, creationflags=subprocess.CREATE_NO_WINDOW) except (OSError, subprocess.SubprocessError): pass if process.poll() is None: process.kill() else: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass def _command(command: list[str], directory: Path, stop: Event, timeout: float = 30) -> str: if stop.is_set(): raise CheckCancelled() try: with subprocess.Popen(command, cwd=directory, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=command_environment(command[0]), close_fds=True, start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) as process: deadline = monotonic() + timeout while True: try: stdout, stderr = process.communicate(timeout=0.2) break except subprocess.TimeoutExpired: if stop.is_set() or monotonic() >= deadline: _stop_process_tree(process) stdout, stderr = process.communicate() if stop.is_set(): raise CheckCancelled() raise CheckFailure(f"命令执行超过 {timeout:g} 秒", command=command, cwd=directory, exit_code=process.returncode, stdout=stdout.decode("utf-8", errors="replace"), stderr=stderr.decode("utf-8", errors="replace"), error_type="TimeoutExpired") except OSError as exc: raise CheckFailure(str(exc), command=command, cwd=directory, error_type=type(exc).__name__) from exc output = stdout.decode("utf-8", errors="replace") if process.returncode: stderr_text = stderr.decode('utf-8', errors='replace') message = ('GCC 已启动,但无法启动编译子进程;该错误不能直接判定为编译器不存在' if is_driver_launch_failure(stderr_text) else '命令执行失败') # Whole-check retries remain owned by warmup (initial check + 5 rounds). raise CheckFailure(message, command=command, cwd=directory, exit_code=process.returncode, stdout=output, stderr=stderr_text, error_type="CalledProcessError") return output def check_native_runtime(progress: Callable[..., None], stop: Event) -> None: def stage(name: str, **details): if stop.is_set(): raise CheckCancelled() progress(stage=name, message=STAGE_MESSAGES[name], **details) candidate = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc") stage("toolchain", compiler=candidate) try: compiler, sundials, version = toolchain() except NativeCommandError as exc: details = exc.details failure = CheckFailure(str(exc), command=details['command'], cwd=details['cwd'], exit_code=details['exitCode'], stdout=details['stdout'], stderr=details['stderr'], error_type=details['errorType']) failure.details['attempts'] = details['attempts'] raise failure from exc except subprocess.SubprocessError as exc: def decoded(value): return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else (value or "") raise CheckFailure(str(exc), command=getattr(exc, "cmd", ()), cwd=Path.cwd(), exit_code=getattr(exc, "returncode", None), stdout=decoded(getattr(exc, "stdout", "")), stderr=decoded(getattr(exc, "stderr", "")), error_type=type(exc).__name__) from exc except OSError as exc: raise CheckFailure(str(exc), command=[candidate, "--version"] if candidate else [], cwd=Path.cwd(), error_type=type(exc).__name__) from exc compiler = str(Path(shutil.which(compiler) or compiler).resolve()) stage("preparing", compiler=compiler, compilerVersion=version, sundialsRoot=str(sundials)) flags, libraries, dlls, executable_name = platform_build_inputs(sundials) # Keep the probe on the build filesystem: Linux /tmp may be mounted noexec. # Disposable probes never populate or evict the model/object caches. directory_root = CACHE.parent / "native-runtime-checks" directory_root.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="check-", dir=directory_root) as temporary: directory = Path(temporary).resolve() source, preprocessed, obj = directory / "check.c", directory / "check.i", directory / "check.o" executable = directory / executable_name source.write_text(SOURCE, encoding="utf-8", newline="\n") stage("preprocessing") _command([compiler, *flags, "-E", "-P", f"-fmacro-prefix-map={directory}=/generated", "-I", str(sundials / "include"), str(source), "-o", str(preprocessed)], directory, stop) stage("compiling") _command([compiler, *flags, "-x", "cpp-output", "-c", str(preprocessed), "-o", str(obj)], directory, stop) stage("linking") _command([compiler, *flags, str(obj), *link_library_arguments(libraries), "-lm", "-o", str(executable)], directory, stop) stage("running") for dll in dlls: shutil.copyfile(dll, directory / dll.name) output = _command([str(executable)], directory, stop, timeout=10) if output.strip() != OUTPUT_MARKER: raise CheckFailure("最小程序未返回预期的自检成功标记", command=[str(executable)], cwd=directory, exit_code=0, stdout=output, error_type="UnexpectedOutput") stage("schema") from app.system_xml import validate_system_xml_document validate_system_xml_document(b"")