From 5b72d8ebd82ff0071056e47df6daa1f672392131 Mon Sep 17 00:00:00 2001 From: Trent Clements Date: Sun, 6 Sep 2026 20:21:23 -0700 Subject: [PATCH 1/2] Import casadi and nlopt lazily so that importing cadquery does not crash at exit on Windows `import cadquery` pulled in casadi (assembly constraint solver) and nlopt (sketch solver) eagerly. On Windows the two wheels carry different C runtimes - nlopt is MSVC/UCRT, casadi is MinGW-w64 against the legacy msvcrt.dll with bundled libstdc++/libwinpthread - and with both loaded the interpreter corrupts its heap during teardown (#1911). Each alone is fine, and neither is needed unless constraints are actually solved. solver.py now imports casadi inside the functions that use it (with `from __future__ import annotations` for the ca.MX/ca.Opti annotations) and sketch_solver.py imports nlopt inside SketchConstraintSolver.solve. A fresh-process test asserts that importing cadquery loads neither. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KTte2haDWay6HAwN5afmDC --- cadquery/occ_impl/sketch_solver.py | 2 +- cadquery/occ_impl/solver.py | 18 +++++++++++++++++- tests/test_lazy_imports.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/test_lazy_imports.py diff --git a/cadquery/occ_impl/sketch_solver.py b/cadquery/occ_impl/sketch_solver.py index 1f4e10c50..0594792e1 100644 --- a/cadquery/occ_impl/sketch_solver.py +++ b/cadquery/occ_impl/sketch_solver.py @@ -7,7 +7,6 @@ from numpy import array, full, inf, sign from numpy.linalg import norm -import nlopt from OCP.gp import gp_Vec2d @@ -345,6 +344,7 @@ def grad(x, rv) -> None: def solve(self) -> Tuple[Sequence[Sequence[float]], Dict[str, Any]]: + import nlopt x0 = array(list(chain.from_iterable(self.entities))).ravel() f, grad, lb, ub = self._cost(x0) diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py index 66bc71898..3192a106d 100644 --- a/cadquery/occ_impl/solver.py +++ b/cadquery/occ_impl/solver.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import ( List, Tuple, @@ -14,7 +16,10 @@ from math import radians, pi -import casadi as ca +# casadi is imported inside the functions that use it rather than at module +# level: importing it together with nlopt at `import cadquery` time crashes +# the interpreter at exit on Windows (#1911), and neither is needed unless +# constraints are actually solved. from OCP.gp import ( gp_Vec, @@ -308,6 +313,7 @@ def toPODs(self) -> Tuple[Constraint, ...]: # Cost functions of simple constraints def Quaternion(R): + import casadi as ca m = ca.sumsqr(R) u = 2 * R / (1 + m) @@ -318,6 +324,7 @@ def Quaternion(R): def Rotate(v, R): + import casadi as ca s, u = Quaternion(R) return 2 * ca.dot(u, v) * u + (s ** 2 - ca.dot(u, u)) * v + 2 * s * ca.cross(u, v) @@ -344,6 +351,7 @@ def point_cost( scale: float = 1, ) -> float: + import casadi as ca val = 0 if val is None else val m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) @@ -375,6 +383,7 @@ def axis_cost( scale: float = 1, ) -> float: + import casadi as ca val = pi if val is None else val m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) @@ -413,6 +422,7 @@ def point_in_plane_cost( scale: float = 1, ) -> float: + import casadi as ca val = 0 if val is None else val m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) @@ -451,6 +461,7 @@ def point_on_line_cost( scale: float = 1, ) -> float: + import casadi as ca val = 0 if val is None else val m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) @@ -500,6 +511,7 @@ def fixed_point_cost( scale: float = 1, ): + import casadi as ca m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) dummy = (Transform(m1_dm, T1_0 + T1, R1_0 + R1) - ca.DM(val)) / scale @@ -518,6 +530,7 @@ def fixed_axis_cost( scale: float = 1, ): + import casadi as ca m1_dm = ca.DM((m1.X(), m1.Y(), m1.Z())) m_val = ca.DM(val) / ca.norm_2(ca.DM(val)) @@ -537,6 +550,7 @@ def fixed_rotation_cost( scale: float = 1, ): + import casadi as ca q = gp_Quaternion() q.SetEulerAngles(gp_Extrinsic_XYZ, *val) q_dm = ca.DM((q.W(), q.X(), q.Y(), q.Z())) @@ -591,6 +605,7 @@ def __init__( scale: float = 1, ): + import casadi as ca self.scale = scale self.opti = opti = ca.Opti() self.variables = [ @@ -660,6 +675,7 @@ def _build_transform(self, T: ca.MX, R: ca.MX) -> gp_Trsf: def solve(self, verbosity: int = 0) -> Tuple[List[Location], Dict[str, Any]]: + import casadi as ca suppress_banner = "yes" if verbosity == 0 else "no" opti = self.opti diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 000000000..694596290 --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,12 @@ +import subprocess +import sys + + +def test_import_cadquery_does_not_load_solver_backends(): + """casadi and nlopt are only needed when constraints are solved. Loading both at + import time crashes the interpreter at exit on Windows (#1911), so `import + cadquery` must leave them unloaded. Checked in a fresh process so this test does + not depend on what the rest of the suite imported first.""" + code = "import sys, cadquery; print('casadi' in sys.modules, 'nlopt' in sys.modules)" + out = subprocess.check_output([sys.executable, "-c", code], text=True) + assert out.split() == ["False", "False"] From f2744fa85759b262d41f350840699dbcd170d35f Mon Sep 17 00:00:00 2001 From: Trent Clements Date: Sun, 6 Sep 2026 20:26:44 -0700 Subject: [PATCH 2/2] Disarm SWIG's shared runtime capsule at exit so solving constraints does not crash on Windows Lazy imports keep `import cadquery` clean, but a process that actually solves an assembly (casadi) and a sketch (nlopt) still crashed at exit. The mechanism: both are SWIG modules and share SWIG's runtime type table through the swig_runtime_data5 capsule. Its destructor frees every module's type records with the C runtime of the module that created the capsule, and the two wheels are built against different runtimes (nlopt: MSVC/UCRT; casadi: MinGW-w64 on msvcrt), so the heap is corrupted during teardown (0xC0000005 / 0xC0000374). cadquery.occ_impl.swig_runtime registers an atexit handler that clears the capsule's destructor when both modules are loaded; the handful of records it would have freed are leaked at exit instead, which is harmless. tests/test_assembly.py + tests/test_sketch.py now exit 0 on Windows (they exited 0xC0000005 before), and a fresh-process test solves a constraint with both backends loaded and asserts exit 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KTte2haDWay6HAwN5afmDC --- cadquery/__init__.py | 1 + cadquery/occ_impl/swig_runtime.py | 44 +++++++++++++++++++++++++++++++ tests/test_lazy_imports.py | 19 +++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 cadquery/occ_impl/swig_runtime.py diff --git a/cadquery/__init__.py b/cadquery/__init__.py index 09368872f..a160c0738 100644 --- a/cadquery/__init__.py +++ b/cadquery/__init__.py @@ -21,6 +21,7 @@ ) from .occ_impl import exporters from .occ_impl import importers +from .occ_impl import swig_runtime # registers the exit-time guard for #1911 # these items are the common implementation diff --git a/cadquery/occ_impl/swig_runtime.py b/cadquery/occ_impl/swig_runtime.py new file mode 100644 index 000000000..b9e678b69 --- /dev/null +++ b/cadquery/occ_impl/swig_runtime.py @@ -0,0 +1,44 @@ +"""Keep the interpreter from crashing at exit when casadi and nlopt are both loaded. + +casadi and nlopt are SWIG-generated extension modules, and on Windows their PyPI +wheels are built against different C runtimes (nlopt: MSVC/UCRT; casadi: MinGW-w64 +on the legacy msvcrt). SWIG modules in one process share a runtime type table +through the ``swig_runtime_data.type_pointer_capsule`` capsule; at interpreter +exit the capsule's destructor walks the shared table and frees every module's +records with the C runtime of whichever module created the capsule. Freeing memory +that the other runtime allocated corrupts the heap (#1911: exit 0xC0000005 or +0xC0000374, after all output). Leaving those few records unfreed at exit is +harmless, so this disarms the destructor once both modules are present. +""" + +import atexit +import ctypes +import sys + + +def _real(name: str) -> bool: + module = sys.modules.get(name) + return module is not None and getattr(module, "__file__", None) is not None + + +def disarm_swig_runtime_capsule() -> bool: + """Clear the SWIG runtime capsule's destructor if casadi and nlopt are both loaded. + + Returns True if a capsule was disarmed. Safe to call more than once. + """ + + if not (_real("casadi") and _real("nlopt")): + return False + setter = ctypes.pythonapi.PyCapsule_SetDestructor + setter.argtypes = [ctypes.py_object, ctypes.c_void_p] + setter.restype = ctypes.c_int + disarmed = False + for name, module in list(sys.modules.items()): + if name.startswith("swig_runtime_data"): + capsule = getattr(module, "type_pointer_capsule", None) + if capsule is not None and setter(capsule, None) == 0: + disarmed = True + return disarmed + + +atexit.register(disarm_swig_runtime_capsule) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 694596290..3d0c0d3b3 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -10,3 +10,22 @@ def test_import_cadquery_does_not_load_solver_backends(): code = "import sys, cadquery; print('casadi' in sys.modules, 'nlopt' in sys.modules)" out = subprocess.check_output([sys.executable, "-c", code], text=True) assert out.split() == ["False", "False"] + + +def test_process_exits_cleanly_with_both_solver_backends_loaded(): + """casadi and nlopt are SWIG modules that share SWIG's runtime capsule; on Windows + their wheels use different C runtimes and the capsule's exit-time destructor + corrupts the heap (#1911). cadquery.occ_impl.swig_runtime disarms it at exit, so + a process that solved an assembly and loaded nlopt must still exit 0.""" + code = ( + "import cadquery as cq, nlopt, sys\n" + "a = cq.Assembly()\n" + "a.add(cq.Workplane().box(10, 10, 10), name='b1')\n" + "a.add(cq.Workplane().box(5, 5, 5), name='b2')\n" + "a.constrain('b1@faces@>Z', 'b2@faces@