Skip to content

Import casadi and nlopt lazily so that importing cadquery does not crash at exit on Windows (#1911) - #2092

Open
69k9zb628r-sys wants to merge 2 commits into
CadQuery:masterfrom
69k9zb628r-sys:lazy-solver-imports
Open

Import casadi and nlopt lazily so that importing cadquery does not crash at exit on Windows (#1911)#2092
69k9zb628r-sys wants to merge 2 commits into
CadQuery:masterfrom
69k9zb628r-sys:lazy-solver-imports

Conversation

@69k9zb628r-sys

@69k9zb628r-sys 69k9zb628r-sys commented Sep 7, 2026

Copy link
Copy Markdown

Fixes the crash-at-exit on Windows reported in #1911 — both the import cadquery case and the case where constraints are actually solved. Two independent commits so they can be taken separately.

Root cause

Minimal reproduction needs no CadQuery — it is the pair of dependencies:

> python -c "import casadi, nlopt"        # exit 0xC0000005 (or 0xC0000374), after all output
> python -c "import casadi"               # exit 0
> python -c "import nlopt"                # exit 0

Reproduced with python.org CPython 3.12.14 and the PyPI wheels casadi==3.8.0, nlopt==2.11.0, from native PowerShell ($LASTEXITCODE = -1073741819), from cmd.exe (cmd /v:on, !ERRORLEVEL! = -1073741819 — a plain %ERRORLEVEL% on the same line expands before the command runs and shows 0, which is why cmd looked clean in the issue), and from Git Bash. Import order does not matter. -X faulthandler shows Windows fatal exception: access violation with <no Python frame>: native, during interpreter teardown.

Why the pair, precisely:

  1. The two wheels carry different C runtimes. Import tables: nlopt/_nlopt.pyd and nloptjni.dll link MSVCP140.dll, VCRUNTIME140.dll, api-ms-win-crt-* (MSVC / UCRT); casadi/_casadi.pyd and libcasadi.dll link libgcc_s_seh-1.dll, libstdc++-6.dll, libwinpthread-1.dll, msvcrt.dll (MinGW-w64, legacy CRT), bundled in the wheel.
  2. Loading any of casadi's DLLs next to nlopt via ctypes does not crash; only the two Python extension modules together do. Both are SWIG modules and both embed swig_runtime_data5: they share SWIG's runtime type table through the swig_runtime_data5.type_pointer_capsule capsule. At exit the capsule's destructor walks the shared table and frees every module's type records with the C runtime of the module that created the capsule — memory the other runtime allocated. Clearing that destructor (PyCapsule_SetDestructor(capsule, NULL)) before exit makes the pair exit 0; that is the whole mechanism.

conda builds everything with one toolchain, which is why it does not reproduce there.

Commit 1 — lazy imports (import cadquery no longer loads either)

  • occ_impl/solver.py: the module-level import casadi as ca becomes an import at the top of the 11 functions/methods that use it; from __future__ import annotations keeps the ca.MX/ca.Opti annotations in the class body and _build_transform's signature from importing at definition time.
  • occ_impl/sketch_solver.py: import nlopt moves into SketchConstraintSolver.solve, its only user.
  • tests/test_lazy_imports.py::test_import_cadquery_does_not_load_solver_backends: asserts in a fresh process that import cadquery loads neither.

Commit 2 — exit-time guard (solving constraints no longer crashes)

  • occ_impl/swig_runtime.py: an atexit handler that, when both casadi and nlopt are loaded, clears the SWIG runtime capsule's destructor. The handful of type records it would have freed are leaked at exit instead, which is harmless. Registered from cadquery/__init__.py.
  • tests/test_lazy_imports.py::test_process_exits_cleanly_with_both_solver_backends_loaded: solves an assembly constraint with nlopt also loaded in a fresh process and asserts exit 0.

Tests (Windows 11, Python 3.12.14, wheels as above)

  • pytest tests/test_assembly.py tests/test_sketch.py: 196 passed, and the pytest process itself now exits 0 — before commit 2 it exited 0xC0000005 after printing the summary (the same crash, since the suite loads both backends).
  • python -c "import cadquery" exits 0 (was 0xC0000005).
  • Both new tests pass.

Nothing here changes behaviour on Linux/macOS beyond deferring two imports.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KTte2haDWay6HAwN5afmDC

Trentlclements and others added 2 commits September 6, 2026 20:21
…ash 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 (CadQuery#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTte2haDWay6HAwN5afmDC
…oes 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTte2haDWay6HAwN5afmDC
@flawlessfall76

Copy link
Copy Markdown

Independent reproduction on a different setup, plus two findings that may be useful here.

Environment: Windows 11, python.org CPython 3.12.10, installed with pip (not uv) into a clean venv: cadquery==2.8.0, cadquery-ocp==7.9.3.1.1, casadi==3.8.0, nlopt==2.11.0, vtk==9.6.2. Confirms the diagnosis in the PR body — import casadi, nlopt exits non-zero 8/8, each alone exits 0, and -X faulthandler gives <no Python frame>.

All exit codes below come from a Python subprocess driver reading returncode over repeated runs. Worth flagging for anyone reproducing: Git Bash reports these as 139/127 and hides which NTSTATUS actually occurred, much like the %ERRORLEVEL% expansion problem you noted for cmd.

1. casadi 3.8.0 looks like the regression point — 3.7.2 is clean

Identical wheels and machine, only casadi differs:

probe, 6 runs each casadi 3.8.0 casadi 3.7.2
import casadi, nlopt 0/6 clean (0xC0000005 x5, 0xC0000374 x1) 6/6 clean
import cadquery 0/6 clean 6/6 clean
import cadquery + build a box 6/6 clean

This corroborates @gdahlberg55's table in #1564. It bounds the affected range, and gives people a stopgap (casadi<3.8) until this lands.

2. Possible false-green in the commit 2 regression test

Whether the process crashes depends on what is still alive at interpreter exit, and the dependence is not monotonic:

held alive at exit, 6 runs each result
imports only 0/6 clean
a live casadi.Function + its result 0/6 clean
a live nlopt.opt + its optimize() result 0/6 clean
both of the above alive at once 6/6 clean
both created, then dropped + gc.collect() 0/6 clean

So holding a wrapped object from both modules at exit suppresses the crash on its own, with no fix applied. Since test_process_exits_cleanly_with_both_solver_backends_loaded solves an assembly constraint with nlopt loaded, it may leave wrapped objects from both backends referenced at exit — in which case it would pass with commit 2 reverted. Probably worth confirming that it fails without the fix.

3. Ruled out by test

In case it saves anyone the detour taken in #1911: OCP and VTK are not involved (import OCP, vtk exits 0, and any two of {OCP, casadi, nlopt} exit 0); the OCCT allocator switches MMGT_OPT / MMGT_CLEAR / MMGT_REENTRANT change nothing; and there were no duplicate tbb*.dll, TKernel.dll or VTK DLLs on PATH.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants