From bc6c9519a5586c0216b047e86be78e419392ba24 Mon Sep 17 00:00:00 2001 From: Frederick Roy Date: Wed, 22 Jul 2026 13:46:16 +0900 Subject: [PATCH] isolate {write/compare} process in order to avoid leaks between runs --- .../tools/RegressionSceneData.py | 10 + .../tools/RegressionSceneList.py | 47 ++-- .../tools/RegressionWorker.py | 203 ++++++++++++++++++ 3 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 SofaRegressionProgram/tools/RegressionWorker.py diff --git a/SofaRegressionProgram/tools/RegressionSceneData.py b/SofaRegressionProgram/tools/RegressionSceneData.py index ec6be5c..e9f8a99 100644 --- a/SofaRegressionProgram/tools/RegressionSceneData.py +++ b/SofaRegressionProgram/tools/RegressionSceneData.py @@ -108,6 +108,16 @@ def log_errors(self): else: helper.writeSuccess(f"{self.file_scene_path} | Number of key frames compared: {self.nbr_tested_frame} | run time: {self.total_run_time/1e9} seconds. ") + def apply_worker_result(self, result): + """Copy the fields reported by an isolated worker process back onto this + object so that log_errors() and error counting behave as if the scene had + been compared in-process.""" + self.regression_failed = bool(result.get("regression_failed", False)) + self.nbr_tested_frame = int(result.get("nbr_tested_frame", 0)) + self.total_run_time = result.get("total_run_time", 0) + self.error_by_dof = result.get("error_by_dof", []) + self.total_error = result.get("total_error", []) + def print_meca_objs(self): helper.writeLog("# Nbr Meca: " + str(len(self.meca_objs))) counter = 0 diff --git a/SofaRegressionProgram/tools/RegressionSceneList.py b/SofaRegressionProgram/tools/RegressionSceneList.py index 4f17a95..93f4eb3 100644 --- a/SofaRegressionProgram/tools/RegressionSceneList.py +++ b/SofaRegressionProgram/tools/RegressionSceneList.py @@ -1,6 +1,7 @@ import os import tools.RegressionSceneData as RegressionSceneData import tools.RegressionHelper as helper +import tools.RegressionWorker as RegressionWorker from tools import ProgressBarHandler as pbh import re @@ -197,14 +198,18 @@ def process_file(self): def write_references(self, id_scene, print_log = False): + scene = self.scenes_data_sets[id_scene] if self.verbose: - helper.writeLog(f'Writing reference files for {self.scenes_data_sets[id_scene].file_scene_path}.') + helper.writeLog(f'Writing reference files for {scene.file_scene_path}.') - self.scenes_data_sets[id_scene].load_scene() - if print_log is True: - self.scenes_data_sets[id_scene].print_meca_objs() - - self.scenes_data_sets[id_scene].write_references() + # Each scene is written in its own process to guarantee a clean SOFA + # state (SOFA does not fully reset global state between load/unload). + result = RegressionWorker.run_scene_in_subprocess( + scene, mode="write", + disable_progress_bar=self.disable_progress_bar, verbose=self.verbose) + + if not result.get("ok", False): + helper.writeError(f"While writing references for {scene.file_scene_path}: {result.get('error')}") def write_all_references(self): nbr_scenes = len(self.scenes_data_sets) @@ -222,22 +227,26 @@ def write_all_references(self): def compare_references(self, id_scene): + scene = self.scenes_data_sets[id_scene] if self.verbose: - self.scenes_data_sets[id_scene].print_info() + scene.print_info() - try: - self.scenes_data_sets[id_scene].load_scene() - except Exception as e: + # Each scene is compared in its own process to guarantee a clean SOFA + # state, identical to the one used when the references were written. + result = RegressionWorker.run_scene_in_subprocess( + scene, mode="compare", legacy=self.legacy_mode, + disable_progress_bar=self.disable_progress_bar, verbose=self.verbose) + + if not result.get("ok", False): + # Hard failure (scene could not be loaded / worker crashed). + self.nbr_errors = self.nbr_errors + 1 + helper.writeError(f"While trying to compare {scene.file_scene_path}: {result.get('error')}") + return + + # Bring the worker's outcome back so log_errors() reports it as usual. + scene.apply_worker_result(result) + if not result.get("result", False): self.nbr_errors = self.nbr_errors + 1 - helper.writeError(f"While trying to load: {str(e)}") - else: - if self.legacy_mode: - result = self.scenes_data_sets[id_scene].compare_legacy_references() - else: - result = self.scenes_data_sets[id_scene].compare_references() - - if not result: - self.nbr_errors = self.nbr_errors + 1 def compare_all_references(self): diff --git a/SofaRegressionProgram/tools/RegressionWorker.py b/SofaRegressionProgram/tools/RegressionWorker.py new file mode 100644 index 0000000..9e264ca --- /dev/null +++ b/SofaRegressionProgram/tools/RegressionWorker.py @@ -0,0 +1,203 @@ +""" +Per-scene subprocess isolation for the SOFA regression program. + +SOFA does not fully reset its global/static state between two load/unload +cycles inside a single process. As a consequence, the simulation result of a +scene depends on which scenes were simulated before it in the same process. +This makes references order-dependent: a scene simulated during the batch +`--write-references` pass can produce a different result than the same scene +simulated during the batch compare pass, causing regressions to fail right +after regenerating the references without anything having changed. + +To guarantee reproducibility, every scene is simulated in its own freshly +spawned Python process. Each child starts from a clean SOFA state, so the +write pass and the compare pass always see identical conditions. + +This module has two roles: + * Parent side: `run_scene_in_subprocess()` spawns a child for one scene and + marshals the result back through a temporary JSON file. + * Child side: executed as `python RegressionWorker.py ...`, it sets up the + SOFA environment, runs a single scene (write or compare) and writes its + result to the file given by `--result-file`. + +Only the standard library is imported at module top-level so that importing +this module in the parent does NOT import SOFA (the parent must never load or +simulate a scene, otherwise the isolation would be defeated). +""" + +import os +import sys +import json +import argparse +import subprocess +import tempfile + + +def _safe_remove(path): + try: + os.remove(path) + except OSError: + pass + + +# -------------------------------------------------- +# Parent side: spawn one child process for one scene +# -------------------------------------------------- +def run_scene_in_subprocess(scene_data, mode, legacy=False, + disable_progress_bar=False, verbose=False, + format="JSON", python_exe=None): + """Run a single scene (write or compare) in an isolated child process. + + Args: + scene_data: the RegressionSceneData describing the scene to run. + mode (str): "write" to generate references, "compare" to check them. + legacy (bool): use the legacy reference format (compare only). + disable_progress_bar (bool): forwarded to the child. + verbose (bool): forwarded to the child. + format (str): reference file format ("JSON" or "CSV"). + python_exe (str): interpreter to use for the child (defaults to the + current one). + + Returns: + dict: the result reported by the child. Always contains an "ok" key. + For compare runs it also contains "result", "regression_failed", + "nbr_tested_frame", "total_run_time", "error_by_dof" and + "total_error". + """ + python_exe = python_exe or sys.executable + worker_path = os.path.abspath(__file__) + + fd, result_path = tempfile.mkstemp(suffix=".json", prefix="regression_result_") + os.close(fd) + + cmd = [ + python_exe, worker_path, + "--mode", mode, + "--scene", str(scene_data.file_scene_path), + "--ref", str(scene_data.file_ref_path), + "--steps", str(scene_data.steps), + "--epsilon", repr(scene_data.epsilon), + "--meca-in-mapping", "1" if scene_data.meca_in_mapping else "0", + "--dump-number-step", str(scene_data.dump_number_step), + "--format", format, + "--result-file", result_path, + ] + if legacy: + cmd.append("--legacy") + if verbose: + cmd.append("--verbose") + if disable_progress_bar: + cmd.append("--disable-progress-bar") + + # stdout/stderr are inherited so SOFA logs and progress bars behave exactly + # as before (and the parent's --quiet redirection propagates to the child). + try: + completed = subprocess.run(cmd) + except Exception as e: + _safe_remove(result_path) + return {"ok": False, "error": f"Failed to launch worker subprocess: {e}"} + + result = None + try: + with open(result_path, "r") as f: + result = json.load(f) + except Exception: + result = None + _safe_remove(result_path) + + if result is None: + return {"ok": False, + "error": f"Worker produced no result (exit code {completed.returncode})."} + return result + + +# -------------------------------------------------- +# Child side: run one scene in a fresh SOFA process +# -------------------------------------------------- +def _make_worker_parser(): + parser = argparse.ArgumentParser(description="Regression per-scene worker (internal)") + parser.add_argument("--mode", choices=["write", "compare"], required=True) + parser.add_argument("--scene", required=True) + parser.add_argument("--ref", required=True) + parser.add_argument("--steps", type=int, required=True) + parser.add_argument("--epsilon", type=float, required=True) + parser.add_argument("--meca-in-mapping", dest="meca_in_mapping", choices=["0", "1"], required=True) + parser.add_argument("--dump-number-step", dest="dump_number_step", type=int, required=True) + parser.add_argument("--format", default="JSON") + parser.add_argument("--result-file", dest="result_file", required=True) + parser.add_argument("--legacy", action="store_true") + parser.add_argument("--verbose", action="store_true") + parser.add_argument("--disable-progress-bar", dest="disable_progress_bar", action="store_true") + return parser + + +def _worker_main(): + args = _make_worker_parser().parse_args() + + result = {"ok": False, "error": None} + try: + # SOFA and the tools package must be imported inside this fresh process. + if "SOFA_ROOT" not in os.environ: + raise RuntimeError("SOFA_ROOT environment variable is not set.") + + sofapython3_path = os.path.join(os.environ["SOFA_ROOT"], "lib", "python3", "site-packages") + if sofapython3_path not in sys.path: + sys.path.append(sofapython3_path) + + # Make the "tools" package importable (program root = parent of this dir). + program_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if program_root not in sys.path: + sys.path.insert(0, program_root) + + import SofaRuntime # noqa: F401 (registers the py3 scene loader) + import tools.RegressionSceneData as RegressionSceneData + + scene = RegressionSceneData.RegressionSceneData( + file_scene_path=args.scene, + file_ref_path=args.ref, + steps=args.steps, + epsilon=args.epsilon, + meca_in_mapping=(args.meca_in_mapping == "1"), + dump_number_step=args.dump_number_step, + disable_progress_bar=args.disable_progress_bar, + verbose=args.verbose, + ) + + scene.load_scene(args.format) + + if args.mode == "write": + scene.write_references(args.format) + result = {"ok": True, "error": None} + else: # compare + if args.legacy: + passed = scene.compare_legacy_references() + else: + passed = scene.compare_references(args.format) + + result = { + "ok": True, + "result": bool(passed), + "regression_failed": bool(scene.regression_failed), + "nbr_tested_frame": int(scene.nbr_tested_frame), + "total_run_time": int(scene.total_run_time), + "error_by_dof": [float(v) for v in scene.error_by_dof], + "total_error": [float(v) for v in scene.total_error], + "error": None, + } + except Exception as e: + import traceback + result = {"ok": False, "error": str(e), "traceback": traceback.format_exc()} + finally: + try: + with open(args.result_file, "w") as f: + json.dump(result, f) + except Exception: + pass + + # The outcome is communicated through the result file, so always exit 0 + # unless the result could not be written at all. + sys.exit(0) + + +if __name__ == "__main__": + _worker_main()