Skip to content

Windows wheel support: remaining work on making-wheel-3 (vanilla, no DAGMC) #127

Description

@shimwell

Context

PR #70 (making-wheel-3) builds Linux and macOS wheels via scikit-build-core plus
cibuildwheel. Windows is not covered. This issue tracks what is left to add a
Windows wheel.

Scope: vanilla OpenMC only, no DAGMC, no libMesh, no MPI, no UWUW. Dropping
DAGMC removes almost the entire dependency problem: for a vanilla build the only
external dependency is HDF5 (C + HL). pugixml and fmt are vendored submodules,
PNG is optional, and Catch2 is tests-only. Compare against
tools/ci/cibw_before_all_linux.sh, which builds HDF5, netcdf, MOAB, DAGMC,
Embree and double-down.

The port is smaller than expected

A survey of the tree shows upstream already carries a fair amount of MSVC
compatibility, so most of the classic porting work is done:

  • src/error.cpp and src/progress_bar.cpp are the only POSIX includes
    (unistd.h), and both are already #if defined(__unix__) guarded with
    non-POSIX fallbacks. is_terminal() simply returns false.
  • src/source.cpp puts dlopen/dlsym behind HAS_DYNAMIC_LINKING, so
    compiled custom sources self-disable rather than breaking the build.
  • src/mcpl_interface.cpp and src/ncrystal_load.cpp already have complete
    _WIN32 branches using LoadLibrary and _popen.
  • src/mesh.cpp has _MSC_VER branches.
  • UNREACHABLE() in include/openmc/error.h already has a non-GCC fallback.
  • CMakeLists.txt has if(MSVC) handling, including the
    H5_BUILT_AS_DYNAMIC_LIB definition and the LNK1149 output-name workaround.
  • 852f927 (_USE_MATH_DEFINES / M_PI) was itself a Windows portability fix.
  • No VLAs and no GCC-only builtins or attributes. Alternative tokens
    (and/or/not) outside src/external/ were claimed clear here too, which
    was wrong. See Use && instead of the alternative token 'and' in mesh.h openmc-dev/openmc#4048 below.
  • No HOME or /tmp assumptions in src/ or openmc/. The only hardcoded
    / paths in the Python package are HDF5 dataset paths, not filesystem paths.

One helpful structural property: the Python binding is ctypes, not pybind11 or
Cython
. Nothing crosses a C++ or CPython ABI boundary, only a C ABI. So the DLL
can be built with a different toolchain than CPython was built with, which makes
/openmp:llvm, clang-cl and MinGW-w64 all viable options. That matters for the
OpenMP item below.

Upstream fixes that have landed since this survey

Two commits merged upstream on 2026-08-05 take work off the list above.

openmc-dev#4048, alternative tokens in mesh.h (163fdf7)

The survey bullet claiming no alternative tokens outside src/external/ was
wrong. include/openmc/mesh.h carried two and operators, one in each of the
two sanitize_angular_index helpers. MSVC accepts and/or/not only under
/permissive-, which CMake does not pass by default, so both sites were a hard
error C2065 in a header included widely across the tree. They are now &&.

Re-grepped include/openmc and src after the merge, excluding
src/external/: no alternative operator tokens remain outside comments and
string literals. The bullet is now accurate rather than aspirational.

Nothing stops this regressing, though. format-check.yml only runs
clang-format, which does not rewrite alternative tokens, so a fresh and can
land at any time and will surface only on the next Windows CI run. A grep guard
in that workflow would keep the property from decaying.

openmc-dev#4047, invalidated iterators in the Region constructor (69de6ba)

src/cell.cpp had two iterator-invalidation bugs in Region::Region. The
DeMorgan complement-removal loop kept using it after expression_.erase(it),
and the simple-cell token cleanup did erase(it); it--, which decrements past
begin() when the first element matches. expression_ is a vector<int32_t>,
so both are undefined behaviour. They are now fixed, using erase's return value
and remove_if respectively.

This one bears on checklist item 9 rather than on getting a build green. Cell
region parsing runs for essentially every model, so it sits on the hot path of
the whole Python test suite, and it is exactly the shape of bug that behaves
differently per standard library. MSVC's STL ships checked iterators, and a
non-release Windows build (_ITERATOR_DEBUG_LEVEL defaults to 2 under /MDd)
turns both of these into an assertion abort rather than the silent misbehaviour
a libstdc++ release build happens to get away with. Release wheels build with
_ITERATOR_DEBUG_LEVEL 0, so this would not necessarily have shown up in the
wheel itself, but it was a good candidate for a confusing Windows-only failure
during test triage.

Probe results: the vanilla Windows build now works

The probe has run properly and the survey below is wrong in three places. Read
this section first.

A vanilla, no-DAGMC, serial Windows build is complete. The openmp-off leg
produces both artifacts and exports everything openmc.lib needs:

libopenmc.dll   5,698,048
openmc.exe         14,336
0 of 24 required data symbols missing
All required data symbols are exported.

Correction 1: it is 24 in_dll globals, not five

Item 1 below and the old checklist both say five. The real number is 24.
Fourteen of them are behind the _DLLGlobal descriptor in
openmc/lib/core.py rather than in a literal in_dll call, which is why a
grep for in_dll missed them: verbosity, event_based, n_particles,
gen_per_batch, cmfd_run, entropy_on, n_inactive, max_lost_particles,
need_depletion_rx, output_summary, restart_run, run_CE,
weight_windows_on and rel_max_lost_particles. The probe now checks all 24
and fails on any unexpected absence.

Worth noting the old probe reported its failures and then exited 0, which is why
it looked green on 2026-08-01 while four symbols were missing.

Correction 2: a .def file is not needed, the discriminator is const

The "watch item" in item 1 guessed that data exports might need a hand-written
.def file. They do not. bindexplib emits mutable data but not read-only
data, and nm on a Linux build shows the split exactly: the four flags that
vanished from the Windows export table are the only four in section R, while
every symbol that survived is in B or D.

R DAGMC_ENABLED   R UWUW_ENABLED   R LIBMESH_ENABLED   R STRICT_FP_ENABLED
B n_coord_levels  B current_batch  B openmc_err_msg    B path_statepoint_c  ...

So the fix is an OPENMC_API macro (include/openmc/export.h) that resolves to
dllexport while building the library, dllimport for anything linking it, and
nothing on POSIX or in a static MSVC build. That also fixed the four unresolved
externals that stopped openmc.exe linking, which were the same problem seen
from the other side: settings::run_mode, settings::solver_type,
mpi::master and openmc_err_msg.

Correction 3: OpenMP was never the blocker

Item 4 below treats OpenMP as the main source of friction and expects plain
/openmp to reject the 9 collapse sites. Both legs of the 2026-08-01 probe
failed identically, including openmp-off, because the real blocker was two
and tokens in mesh.h (openmc-dev#4048). Every other error in that log
was cascade fallout from a header that failed to parse.

With that fixed, /openmp:llvm accepts collapse and atomic capture and
rejects only four things:

  • seq_cst on atomic, shared_array.h:75 and :80
  • a non-conforming collapse bound, plot.cpp:1691. The pragma uses pixels()[0]
    and pixels()[1] as bounds while width and height locals already exist two
    lines above, so this one is nearly free
  • a reduction on a non-static member (C3028), flat_source_domain.cpp:1078 and
    random_ray_simulation.cpp:432, which GCC and Clang accept as an extension

So OpenMP is a four-site source fix rather than a toolchain question. Only the
two seq_cst sites need real thought, since dropping the clause weakens
ordering on a lock-free append. That leg is marked experimental in the probe
matrix so its known failure does not show up as a red check.

A pre-existing bug found while inventorying the symbols

settings::run_mode and settings::rel_max_lost_particles are declared in
namespace openmc::settings without extern "C", so they are name-mangled,
while openmc/lib looks them up unmangled. Both therefore raise on every
platform, not just Windows:

>>> ctypes.c_int.in_dll(dll, 'run_mode')
ValueError: openmc/lib/libopenmc.so: undefined symbol: run_mode

rel_max_lost_particles has been broken since it was introduced: d763806
renamed it on both the C++ and Python sides to make the names match, but with no
extern "C" the symbol never matched under either spelling. Neither attribute
is covered by a test, which is how it survived. This is not a Windows problem and
is fixed separately.

Remaining work

1. Build a DLL instead of a static library (blocker)

CMakeLists.txt currently does:

if(MSVC)
  # Use static library (otherwise explicit symbol portings are needed)
  add_library(libopenmc STATIC ${libopenmc_SOURCES})

openmc/lib/__init__.py calls CDLL(), which can never load a static library,
so no importable openmc.lib is possible as things stand. There are zero
__declspec(dllexport) annotations in the tree, but annotating the 204 openmc_*
functions in include/openmc/capi.h by hand is not necessary. The comment's
premise is exactly what WINDOWS_EXPORT_ALL_SYMBOLS exists to avoid:

if(MSVC)
  add_library(libopenmc SHARED ${libopenmc_SOURCES})
  set_target_properties(libopenmc PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
  target_compile_definitions(libopenmc PRIVATE -DH5_BUILT_AS_DYNAMIC_LIB)
else()

This touches only the MSVC path, so Linux and macOS output is unchanged.

The existing if(NOT MSVC) guard on OUTPUT_NAME can stay as is: the target
keeps the name libopenmc, producing libopenmc.dll, which the Python glob
already matches.

Watch item: the functions are the easy part. openmc/lib/ reads five
globals via ctypes in_dll: DAGMC_ENABLED, UWUW_ENABLED, LIBMESH_ENABLED,
STRICT_FP_ENABLED and n_coord_levels. All five are declared extern "C"
(in dagmc.h, mesh.h, output.h and geometry.h), so they are unmangled,
and CMake's bindexplib should emit them with the DATA keyword in the generated
.def. Data exports are the documented weak spot of WINDOWS_EXPORT_ALL_SYMBOLS
though, so this is the most likely place to need a hand-written .def fallback.
Worth testing early with a one-line import openmc.lib.

2. Add bin to the core library search path

openmc/__init__.py searches only lib and lib64:

lib = [lib_file for lib in ["lib", "lib64"] for lib_file in get_paths(lib, "libopenmc*", recursive=True)]

Windows CMake installs DLLs to bin/ (the RUNTIME destination), so this needs
"bin" adding to the list. It is a no-op on POSIX, where nothing matches
libopenmc* in bin/.

Only the library file lookup needs this. The lib_path line immediately below it
feeds linking, and the Windows import library (.lib) is an ARCHIVE artifact that
still lands in lib, so lib_path is deliberately left alone: adding bin there
would change openmc.lib_path on Linux for no benefit.

3. A scratch Windows CI job

The real bottleneck is the feedback loop, since Windows cannot be reproduced
locally. A continue-on-error: true job on windows-latest that installs HDF5
via vcpkg and runs cmake --build touches no existing files and answers the
OpenMP question below immediately. Highest value per unit of effort of anything
in this list, and worth doing first alongside item 1.

4. OpenMP version, the main source of friction

The code uses OpenMP features newer than MSVC's default /openmp, which is
frozen at OpenMP 2.0:

  • collapse (OpenMP 3.0), 9 sites
  • atomic capture (3.1)
  • atomic write seq_cst and atomic capture seq_cst (5.0)

MSVC's default /openmp rejects all three. Options, in rough order of
preference:

  1. /openmp:llvm, which is the smallest change if it covers the constructs used
  2. clang-cl
  3. MinGW-w64, which gives full OpenMP support but a different runtime
  4. -DOPENMC_USE_OPENMP=OFF for a serial-only first wheel, as a fallback to get
    something shipping while the above is sorted out

Because the binding is ctypes, options 2 and 3 do not create an ABI problem with
CPython. Item 3 above should tell us which of these is needed.

5. Packaging glue

  • Add a [tool.cibuildwheel.windows] section to pyproject.toml.
  • Swap auditwheel for delvewheel in repair-wheel-command. delvewheel also
    injects the os.add_dll_directory calls that Python 3.8+ needs on Windows.
  • Ensure openmc.exe lands somewhere openmc/executor.py can find, since it
    shells out to openmc via subprocess.

get_extra_libraries() needs no change: its non-darwin branch already
resolves to <site-packages>/openmc.libs, which is delvewheel's default output
directory as well as auditwheel's.

This is best left until items 1 and 3 confirm the DLL builds and exports the five
in_dll globals.

6. Fix the generated console entry point

cmake/GenerateScript.cmake writes the openmc console script that ends up in
the wheel's scripts directory. It is POSIX-only in three separate ways, so as
things stand pip install openmc on Windows would place an unrunnable file on
PATH pointing at a target that does not exist:

  • It starts with a #!/usr/bin/env python3 shebang. Windows does not honour
    shebangs, and the installed file is named openmc with no extension, so
    nothing can execute it.
  • It builds the target path by joining ${CMAKE_INSTALL_BINDIR} and the bare
    script name, which misses the .exe suffix the installed executable actually
    has on Windows.
  • It dispatches with os.execv. That exists on Windows but does not replace the
    process the way it does on POSIX: the parent returns immediately, which breaks
    exit codes and console handling for anything that shells out to openmc.

Two candidate fixes, neither large:

  1. On Windows, install openmc.exe directly into SKBUILD_SCRIPTS_DIR and skip
    the wrapper entirely. pip already puts that directory on PATH.
  2. Replace the generated script with a real [project.scripts] console entry
    point, which gets a proper .exe launcher generated for it on Windows.

This is closely related to the openmc/executor.py checklist item below, since
that module shells out to openmc by name through subprocess.

Risks and unknowns

  • None of the above has been compiled. This is a static survey of the tree, so
    treat the effort estimates as provisional.
  • The test suite has never been run on Windows. "It builds and imports" is a much
    lower bar than "results match", and latent path-handling or regression
    tolerance issues will likely only surface once the build works.
  • Rough estimate: a few days to a first green wheel if OpenMP cooperates, one to
    two weeks to something shippable. The long pole is iterating CI on a platform
    that cannot be reproduced locally, not the C++ itself.

Checklist

Dropped from this list: "confirm openmc.exe is discoverable by
openmc/executor.py" needs no code change. executor.py calls
subprocess.Popen(['openmc', ...]) with no shell=True, and Windows
CreateProcess searches PATH and appends .exe to an extensionless name. It
only depends on the console entry point item putting an openmc.exe on PATH, so
it is not separate work.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions