diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cd85d56abb..2bab1ae97a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1261,6 +1261,9 @@ jobs: matlab: name: MATLAB ${{ matrix.release }} on ${{ matrix.os }} + # Disabled: MATLAB is tested with the self-contained library that ships with the + # MATLAB toolbox instead (see 'matlab-bundled'). This job is to be removed. + if: false strategy: matrix: os: [ubuntu-24.04, windows-2025, macos-14] @@ -1402,3 +1405,159 @@ jobs: uses: matlab-actions/run-tests@214a16e600ef2c705a4b2dd6c0794dc6957682a9 # v3.3.0 with: select-by-folder: test/matlab + + matlab-library: + # Builds a self-contained Cantera library for MATLAB: all dependencies are vendored + # and linked statically, and the result is audited for non-system dependencies. + # The cantera_matlab toolbox builds its library with the same scripts in + # interfaces/matlab/buildUtilities. + name: MATLAB bundled library on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + matrix: + os: [ubuntu-24.04, macos-14, macos-15, windows-2025] + fail-fast: false + env: + BOOST_ROOT: ${{ github.workspace }}/3rdparty/boost + BOOST_URL: https://github.com/boostorg/boost/releases/download/boost-1.87.0/boost-1.87.0-b2-nodocs.7z + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + name: Checkout the repository + with: + submodules: recursive + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install SCons and Cantera build dependencies + run: python -m pip install --upgrade 'scons>=4.0' packaging jinja2 ruamel.yaml + - name: Install Boost and Doxygen (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libboost-dev doxygen + - name: Install Boost and Doxygen (macOS) + if: runner.os == 'macOS' + run: | + brew install --display-times boost doxygen + echo "BOOST_INC_DIR=$(brew --prefix)/include" >> "$GITHUB_ENV" + - name: Restore Boost cache (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v6 + id: cache-boost + with: + path: ${{ env.BOOST_ROOT }} + key: boost-187-win + - name: Install Boost headers (Windows) + if: runner.os == 'Windows' && steps.cache-boost.outputs.cache-hit != 'true' + run: | + BOOST_ROOT=$(echo "$BOOST_ROOT" | sed 's/\\/\//g') + mkdir -p "$BOOST_ROOT" + curl --progress-bar --location --output "$BOOST_ROOT/download.7z" "$BOOST_URL" + 7z -o"$BOOST_ROOT" x "$BOOST_ROOT/download.7z" -y -bd boost-1.87.0/boost + mv "$BOOST_ROOT/boost-1.87.0/boost" "$BOOST_ROOT/boost" + rm "$BOOST_ROOT/download.7z" + - name: Set Boost include directory and install Doxygen (Windows) + if: runner.os == 'Windows' + run: | + echo "BOOST_INC_DIR=$(echo "$BOOST_ROOT" | sed 's/\\/\//g')" >> "$GITHUB_ENV" + choco install doxygen.install -y --no-progress + echo "C:/Program Files/doxygen/bin" >> "$GITHUB_PATH" + - name: Build the Cantera library + run: >- + python interfaces/matlab/buildUtilities/build_cantera_library.py --verbose + --cantera-root "$GITHUB_WORKSPACE" --prefix "$GITHUB_WORKSPACE/build/canteraLib" + - name: Archive the Cantera library + # tar keeps the versioned library symlinks, which upload-artifact does not + run: tar -czf canteraLib.tar.gz -C build canteraLib + - name: Upload the Cantera library + uses: actions/upload-artifact@v7 + with: + name: matlab-canteraLib-${{ matrix.os }} + path: canteraLib.tar.gz + + matlab-bundled: + # Runs the tests of the 'matlab' job with the self-contained library from + # 'matlab-library', so no third-party runtime libraries are installed. + name: MATLAB ${{ matrix.release }} on ${{ matrix.os }} (bundled library) + strategy: + matrix: + os: [ubuntu-24.04, windows-2025, macos-14] + # R2024b is the oldest release supported by the MATLAB toolbox + release: [R2024b, latest] + include: + - os: 'macos-15' + release: 'latest' + fail-fast: false + env: + CANTERA_ROOT: ${{ github.workspace }} + CANTERA_DATA: ${{ github.workspace }}/data + runs-on: ${{ matrix.os }} + needs: [matlab-library] + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + name: Checkout the repository + with: + persist-credentials: false + - name: Download the Cantera library + uses: actions/download-artifact@v8 + with: + name: matlab-canteraLib-${{ matrix.os }} + - name: Extract the Cantera library + run: | + mkdir -p build + tar -xzf canteraLib.tar.gz -C build + - name: Add Cantera library directory to PATH (Windows) + if: runner.os == 'Windows' + run: | + echo "$GITHUB_WORKSPACE/build/canteraLib/lib" >> "$GITHUB_PATH" + echo "$GITHUB_WORKSPACE/interfaces/matlab/+ct/+impl/ctMatlab" >> "$GITHUB_PATH" + - name: Install Visual C++ 2022 build tools (Windows, R2024b) + # R2024b supports Visual C++ 2022 at newest, but windows-2025 ships a newer + # Visual Studio, so mex finds no supported compiler; see #2152. + run: | + choco install visualstudio2022buildtools -y --no-progress + choco install visualstudio2022-workload-vctools -y --no-progress + if: runner.os == 'Windows' && matrix.release == 'R2024b' + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@f9e43010f1ae678f7cfa0542fe2a4f60f7d1ad8d # v3.1.0 + with: + release: ${{ matrix.release }} + - name: Build MATLAB C++ Interface + uses: matlab-actions/run-command@bfa857648f4895aa98a446fe41c85e0788421ed5 # v3.3.0 + env: + # On Linux, MATLAB R2024b crashes while generating the interface unless the + # system libstdc++ is preloaded. Only the build needs it: the interface and the + # Cantera library link libstdc++ statically, so the tests run without it. + LD_PRELOAD: ${{ runner.os == 'Linux' && '/lib/x86_64-linux-gnu/libstdc++.so.6' || '' }} + with: + command: | + disp("MATLAB version: " + version); + ctDir = getenv('CANTERA_ROOT'); + ctToolboxDir = fullfile(ctDir, 'interfaces', 'matlab'); + ctIncludeDir = fullfile(ctDir, 'build', 'canteraLib', 'include'); + ctLibDir = fullfile(ctDir, 'build', 'canteraLib', 'lib'); + addpath(genpath(ctToolboxDir)); + ct.buildInterface(ctToolboxDir, ctIncludeDir, ctLibDir); + - name: Run tests + # The library has no third-party dependencies, so it loads in-process on every + # platform without LD_PRELOAD. The tests use the library loaded here instead of + # loading it. + uses: matlab-actions/run-command@bfa857648f4895aa98a446fe41c85e0788421ed5 # v3.3.0 + with: + command: | + ctDir = getenv('CANTERA_ROOT'); + addpath(genpath(fullfile(ctDir, 'interfaces', 'matlab'))); + ct.load("inprocess"); + results = runtests(fullfile(ctDir, 'test', 'matlab')); + disp(table(results)); + assertSuccess(results); diff --git a/doc/sphinx/matlab/utilities.rst b/doc/sphinx/matlab/utilities.rst index 7aa97bc30f..8c6b602c70 100644 --- a/doc/sphinx/matlab/utilities.rst +++ b/doc/sphinx/matlab/utilities.rst @@ -15,6 +15,8 @@ Utility Functions Library Setup ------------- +.. autofunction:: install +.. autofunction:: uninstall .. autofunction:: load .. autofunction:: unload .. autofunction:: isLoaded @@ -23,6 +25,7 @@ Library Setup Global Settings --------------- +.. autofunction:: addDataDirectories .. autofunction:: dataDirectories .. autofunction:: makeDeprecationWarningsFatal diff --git a/interfaces/matlab/README.md b/interfaces/matlab/README.md index e0a025ae1e..d361e40cec 100644 --- a/interfaces/matlab/README.md +++ b/interfaces/matlab/README.md @@ -72,7 +72,11 @@ Cantera objects and functions. 1. **Load the toolbox** + Run `ct.install` once to register the data directory and verify the build; + the setting persists across sessions. Then load Cantera in each session: + ```matlab + ct.install() % once ct.load() ``` diff --git a/interfaces/matlab/Utility/+ct/addDataDirectories.m b/interfaces/matlab/Utility/+ct/addDataDirectories.m new file mode 100644 index 0000000000..0335763cca --- /dev/null +++ b/interfaces/matlab/Utility/+ct/addDataDirectories.m @@ -0,0 +1,22 @@ +function addDataDirectories(dirs) + % Add one or more directories to the data file search path. :: + % + % >> ct.addDataDirectories('/path/to/data') + % >> ct.addDataDirectories(["/path/one", "/path/two"]) + % + % Directories are added to the front of the Cantera data file search path, + % so the most recently added directory is searched first. Use + % :mat:func:`dataDirectories` to inspect the current search path. + % + % :param dirs: + % A string, char array, or string array of directories to add to the + % Cantera data file search path. + arguments + dirs (1,:) string + end + + ct.isLoaded(true); + for d = dirs + ct.impl.call('mCt_addDataDirectory', char(d)); + end +end diff --git a/interfaces/matlab/Utility/+ct/buildInterface.m b/interfaces/matlab/Utility/+ct/buildInterface.m index 72ff3afbfa..a5e24678bf 100644 --- a/interfaces/matlab/Utility/+ct/buildInterface.m +++ b/interfaces/matlab/Utility/+ct/buildInterface.m @@ -86,7 +86,7 @@ function generateLibraryDefinitions(includeDir, ctLibDir, outputDir) headerPaths = fullfile({headerFiles.folder}, headerPaths); % Get path for the shared library file - libraries = ct.ctLib(ctLibDir); + libraries = ctLib(ctLibDir); disp("Using shared library: " + libraries); if isMATLABReleaseOlderThan("R2024a") @@ -97,9 +97,26 @@ function generateLibraryDefinitions(includeDir, ctLibDir, outputDir) overwriteExistingDefinitionFiles = true; + % On Linux, link the interface statically against libstdc++ and libgcc. The + % interface is built with the system compiler, whose libstdc++ can be newer than + % the one MATLAB ships (R2024b ships the one from GCC 12), so the interface would + % otherwise only load with the system libstdc++ preloaded. + linkerArgs = {}; + if isunix && ~ismac + linkerArgs = {"AdditionalLinkerFlags", ["-static-libstdc++", "-static-libgcc"]}; + end + % Set up C++ compiler mex -setup cpp + % With R2026b, the interface build command on macOS no longer includes a C++ + % standard flag, so older compilers (for example, Xcode 15) fall back to C++98, + % which cannot compile the MATLAB Data API headers. + compilerArgs = {}; + if ~ispc + compilerArgs = {"AdditionalCompilerFlags", "-std=c++17"}; + end + % Generate definition file for C++ library clibgen.generateLibraryDefinition(headerPaths, ... "IncludePath", includeDir, ... @@ -107,9 +124,11 @@ function generateLibraryDefinitions(includeDir, ctLibDir, outputDir) "OutputFolder", outputDir, ... nameArg, "ctMatlab", ... "OverwriteExistingDefinitionFiles", overwriteExistingDefinitionFiles, ... + compilerArgs{:}, ... "CLinkage", true, ... "TreatObjectPointerAsScalar", true, ... "TreatConstCharPointerAsCString", true, ... + linkerArgs{:}, ... "ReturnCArrays", false, ... "Verbose", true); end diff --git a/interfaces/matlab/Utility/+ct/cleanUp.m b/interfaces/matlab/Utility/+ct/cleanUp.m index 917b96e6f5..b65df1e020 100644 --- a/interfaces/matlab/Utility/+ct/cleanUp.m +++ b/interfaces/matlab/Utility/+ct/cleanUp.m @@ -3,10 +3,10 @@ function cleanUp() ct.isLoaded(true); -classList = {'ct.Interface', 'ct.Kinetics', 'ct.Mixture', 'ct.ThermoPhase', ... - 'ct.Transport', 'ct.Solution', 'ct.Func1', 'ct.oneD.Domain', ... - 'ct.oneD.Sim1D', 'ct.zeroD.Connector', 'ct.zeroD.ReactorBase', ... - 'ct.zeroD.ReactorNet'}; + classList = {'ct.Interface', 'ct.Kinetics', 'ct.Mixture', 'ct.ThermoPhase', ... + 'ct.Transport', 'ct.Solution', 'ct.Func1', 'ct.oneD.Domain', ... + 'ct.oneD.Sim1D', 'ct.zeroD.Connector', 'ct.zeroD.ReactorBase', ... + 'ct.zeroD.ReactorNet'}; varList = evalin('base', 'whos'); diff --git a/interfaces/matlab/Utility/+ct/install.m b/interfaces/matlab/Utility/+ct/install.m new file mode 100644 index 0000000000..5bcf298d6a --- /dev/null +++ b/interfaces/matlab/Utility/+ct/install.m @@ -0,0 +1,99 @@ +function install(opts) + % Set up the Cantera MATLAB toolbox after installing it. :: + % + % >> ct.install + % >> ct.install(Force=true) + % + % Run once after installing the toolbox; afterwards use :func:`ct.load`. + % This registers the data directory shipped with the toolbox and verifies + % that Cantera loads. Settings persist as MATLAB preferences in the + % "Cantera" group, and later calls do nothing unless the installation is + % incomplete or ``Force`` is true. Use :func:`ct.uninstall` to clear them. + % + % :param DataDirectory: + % Data directory to register instead of the one shipped with the + % toolbox. + % :param Force: + % Reinstall even if Cantera is already installed. + + arguments + opts.DataDirectory (1,1) string = "" + opts.Force (1,1) logical = false + end + + GROUP = "Cantera"; + [interfaceDir, toolboxDir] = toolboxPaths(); + + if ~opts.Force && isInstalled(GROUP, interfaceDir) + fprintf("Cantera is already installed. " + ... + "Use ct.install(Force=true) to reinstall.\n"); + return + end + + dataDir = resolveDataDirectory(toolboxDir, opts.DataDirectory); + setpref(GROUP, "DataDirectory", char(dataDir)); + fprintf("Registered Cantera data directory: %s\n", dataDir); + + % Set the flag only after verification, so a failed install is retried. + verifyInstallation(dataDir); + setpref(GROUP, "Installed", true); + fprintf("Cantera %s installed successfully.\n", ct.version); +end + +function tf = isInstalled(GROUP, interfaceDir) + % The flag alone is not trusted: preferences outlive the toolbox when it is + % removed in the Add-On Manager without running ct.uninstall. + tf = ispref(GROUP, "Installed") && isequal(getpref(GROUP, "Installed"), true) ... + && hasInterface(interfaceDir) && ispref(GROUP, "DataDirectory") ... + && isfolder(string(getpref(GROUP, "DataDirectory"))); +end + +function dataDir = resolveDataDirectory(toolboxDir, override) + if override ~= "" + if ~isfolder(override) + error("ct:install:BadDataDir", ... + "Data directory does not exist: %s", override); + end + dataDir = override; + return + end + + candidates = [ + fullfile(fileparts(toolboxDir), "data") % packaged toolbox + fullfile(fileparts(fileparts(toolboxDir)), "data") % source checkout + ]; + for c = candidates' + if ~isempty(dir(fullfile(c, "*.yaml"))) + dataDir = c; + return + end + end + + error("ct:install:NoDataDir", ... + ("Could not find the Cantera data files shipped with the toolbox. " + ... + "Pass a folder with ct.install(DataDirectory=).")); +end + +function verifyInstallation(dataDir) + if ct.isLoaded + ct.addDataDirectories(dataDir); + else + ct.load(); + end + + registered = string(ct.dataDirectories()); + if ~any(arrayfun(@(d) samePath(d, dataDir), registered)) + error("ct:install:DataDirNotRegistered", ... + "Cantera loaded, but did not register the data directory %s", ... + dataDir); + end +end + +function tf = samePath(a, b) + normalize = @(p) regexprep(strrep(string(p), "\", "/"), "/+$", ""); + if ispc + tf = strcmpi(normalize(a), normalize(b)); + else + tf = normalize(a) == normalize(b); + end +end diff --git a/interfaces/matlab/Utility/+ct/isLoaded.m b/interfaces/matlab/Utility/+ct/isLoaded.m index 9056878515..b258d40ce0 100644 --- a/interfaces/matlab/Utility/+ct/isLoaded.m +++ b/interfaces/matlab/Utility/+ct/isLoaded.m @@ -20,8 +20,14 @@ global ctMatlab i = ctMatlab.Loaded; else - cfg = clibConfiguration("ctMatlab"); - i = cfg.Loaded; + try + cfg = clibConfiguration("ctMatlab"); + i = cfg.Loaded; + catch ME + if ME.identifier ~= "MATLAB:CPP:InterfaceLibraryNotFound" + rethrow(ME); + end + end end if ~i && throw diff --git a/interfaces/matlab/Utility/+ct/load.m b/interfaces/matlab/Utility/+ct/load.m index ac7b498e88..bbd827ad59 100644 --- a/interfaces/matlab/Utility/+ct/load.m +++ b/interfaces/matlab/Utility/+ct/load.m @@ -5,6 +5,8 @@ function load(mode) % ct.load % defaults to 'outofprocess' % ct.load('inprocess') % load in-process % ct.load('outofprocess')% load out-of-process + % + % After installing the toolbox, run :func:`ct.install` once before loading. arguments mode (1,1) string {mustBeMember(mode, ... @@ -27,23 +29,115 @@ function load(mode) end end - pathVar = dictionary(); - if ismac - arch = computer("arch"); - matlabLibPath = matlabroot + "/bin/" + arch + pathsep + ... - matlabroot + "/sys/os/" + arch; - pathVar = dictionary("DYLD_LIBRARY_PATH", matlabLibPath); + libDir = toolboxPaths(); + if ~hasInterface(libDir) + error("ct:load:NotInstalled", ... + ("The Cantera MATLAB interface for this platform was not found " + ... + "in %s.\nBuild it with ct.buildInterface, or reinstall the " + ... + "toolbox."), libDir); + end + if ~any(strcmp(strsplit(path, pathsep), libDir)) + addpath(libDir); + end + + % The interface folder also holds the Cantera shared library, which the OS + % loader must find. Out-of-process mode on R2025a+ can pass environment + % variables to the child library process; otherwise set them here. + useOOPEnv = ~isMATLABReleaseOlderThan("R2025a") && mode == "outofprocess"; + pathVar = buildLoaderPathVar(libDir); + if ~useOOPEnv + applyLoaderEnvInProcess(pathVar); end global ctMatlab if ~ct.isLoaded - if isMATLABReleaseOlderThan("R2025a") || strcmp(mode, "inprocess") - ctMatlab = clibConfiguration("ctMatlab", ExecutionMode=mode); - else + if useOOPEnv && numEntries(pathVar) > 0 ctMatlab = clibConfiguration("ctMatlab", ExecutionMode=mode, ... OutOfProcessEnvironmentVariables=pathVar); + else + ctMatlab = clibConfiguration("ctMatlab", ExecutionMode=mode); end end - fprintf('Cantera %s is ready for use (%s mode).\n', ct.version, mode); + ctVersion = ct.version; + addInstalledDataDirectory(); + + fprintf('Cantera %s is ready for use (%s mode).\n', ctVersion, mode); +end + +function pathVar = buildLoaderPathVar(libDir) + % Build the OS-specific dynamic-loader environment dictionary, prepending + % the library directory (and, on macOS, MATLAB's own libraries) to the + % current value of the relevant environment variable. + % + % Nothing is set on Linux: the interface finds the Cantera library in its + % own folder through its RUNPATH ($ORIGIN). Passing MATLAB's + % LD_LIBRARY_PATH on to the library process would make Cantera's + % dependencies resolve to the copies bundled with MATLAB instead. + pathVar = dictionary(string.empty, string.empty); + + if ispc + varName = "PATH"; + extra = strings(0, 1); + elseif ismac + varName = "DYLD_LIBRARY_PATH"; + arch = computer("arch"); + extra = [matlabroot + "/bin/" + arch; matlabroot + "/sys/os/" + arch]; + else + return + end + + parts = [libDir; extra]; + existing = string(getenv(varName)); + if existing ~= "" + parts = [parts; split(existing, pathsep)]; + end + parts = unique(parts(parts ~= ""), "stable"); + + pathVar(varName) = strjoin(parts, pathsep); +end + +function applyLoaderEnvInProcess(pathVar) + % Apply the loader environment via setenv. Used when + % OutOfProcessEnvironmentVariables is unavailable (in-process mode, or + % MATLAB older than R2025a). + k = keys(pathVar); + if isempty(k) + return + end + for i = 1:numel(k) + setenv(char(k(i)), char(pathVar(k(i)))); + end + + if ~ispc + warning("ct:load:InProcessEnvUnreliable", ... + ("Setting %s after MATLAB has started may not affect the " + ... + "dynamic loader. If Cantera fails to load, use out-of-process " + ... + "mode on R2025a or newer, or launch MATLAB with the library " + ... + "directory already on the loader path."), char(k(1))); + end +end + +function addInstalledDataDirectory() + % Data directories live in the loaded library, so the one registered by + % ct.install has to be re-added on every load. + GROUP = "Cantera"; + if ~ispref(GROUP, "DataDirectory") + return + end + + dataDir = string(getpref(GROUP, "DataDirectory")); + dataDir = reshape(dataDir, 1, []); + missing = dataDir(~arrayfun(@isfolder, dataDir)); + if ~isempty(missing) + warning("ct:load:MissingDataDir", ... + ("Cantera data directory no longer exists: %s\n" + ... + "Run ct.install(Force=true) to reconfigure."), ... + strjoin(missing, ", ")); + end + + dataDir = dataDir(arrayfun(@isfolder, dataDir)); + if ~isempty(dataDir) + ct.addDataDirectories(dataDir); + end end diff --git a/interfaces/matlab/Utility/+ct/ctLib.m b/interfaces/matlab/Utility/+ct/private/ctLib.m similarity index 100% rename from interfaces/matlab/Utility/+ct/ctLib.m rename to interfaces/matlab/Utility/+ct/private/ctLib.m diff --git a/interfaces/matlab/Utility/+ct/private/hasInterface.m b/interfaces/matlab/Utility/+ct/private/hasInterface.m new file mode 100644 index 0000000000..815c759cdb --- /dev/null +++ b/interfaces/matlab/Utility/+ct/private/hasInterface.m @@ -0,0 +1,18 @@ +function tf = hasInterface(folder) + % True if folder contains the compiled interface for this platform. + % + % The packaged toolbox ships every platform's interface in one folder, so + % only the current platform's file counts. + arguments + folder (1,1) string + end + + if ispc + ext = "dll"; + elseif ismac + ext = "dylib"; + else + ext = "so"; + end + tf = isfile(fullfile(folder, "ctMatlabInterface." + ext)); +end diff --git a/interfaces/matlab/Utility/+ct/private/toolboxPaths.m b/interfaces/matlab/Utility/+ct/private/toolboxPaths.m new file mode 100644 index 0000000000..c674e9710f --- /dev/null +++ b/interfaces/matlab/Utility/+ct/private/toolboxPaths.m @@ -0,0 +1,15 @@ +function [interfaceDir, toolboxDir] = toolboxPaths() + % Locate the compiled interface folder and the toolbox root. + % + % Paths are derived from this file's location, so they hold both for the + % packaged toolbox and for a source checkout (interfaces/matlab). + % + % :return interfaceDir: + % Folder holding the compiled interface and the Cantera shared library. + % :return toolboxDir: + % Toolbox root containing the +ct package and the Utility folder. + + ctDir = fileparts(fileparts(mfilename("fullpath"))); + toolboxDir = string(fileparts(fileparts(ctDir))); + interfaceDir = fullfile(toolboxDir, "+ct", "+impl", "ctMatlab"); +end diff --git a/interfaces/matlab/Utility/+ct/uninstall.m b/interfaces/matlab/Utility/+ct/uninstall.m new file mode 100644 index 0000000000..26fe9769bf --- /dev/null +++ b/interfaces/matlab/Utility/+ct/uninstall.m @@ -0,0 +1,22 @@ +function uninstall() + % Remove all persisted Cantera settings. :: + % + % >> ct.uninstall + % + % Clears the "Cantera" MATLAB preference group written by :func:`ct.install`. + % The toolbox itself is removed in the Add-On Manager; run this first, since + % this function is part of the toolbox. + + % A loaded library keeps its files locked on Windows, which would stop the + % Add-On Manager from deleting them. + if ct.isLoaded + ct.unload(); + end + + if ispref("Cantera") + rmpref("Cantera"); + fprintf("Removed Cantera MATLAB preferences.\n"); + else + fprintf("No Cantera MATLAB preferences to remove.\n"); + end +end diff --git a/interfaces/matlab/Utility/+ct/unload.m b/interfaces/matlab/Utility/+ct/unload.m index ea12fa199f..56f3563563 100644 --- a/interfaces/matlab/Utility/+ct/unload.m +++ b/interfaces/matlab/Utility/+ct/unload.m @@ -7,12 +7,12 @@ function unload() try ct.cleanUp; catch ME - warning("ct.unload:CleanupFailed", ... + warning("ct:unload:CleanupFailed", ... "cleanUp failed (%s).", ME.message); end if ct.executionMode() == "inprocess" - warning("ct.unload:UnloadFailed", ... + warning("ct:unload:UnloadFailed", ... ("Unloading of `ctMatlab` library is not supported for " + ... "'inprocess' execution mode. Restart MATLAB to unload.")); return @@ -26,7 +26,7 @@ function unload() ctMatlab.unload; disp("Cantera has been unloaded"); catch ME - warning("ct.unload:UnloadFailed", ... + warning("ct:unload:UnloadFailed", ... "ctMatlab.unload failed (%s). Attempting fallback.", ME.message); end clear global ctMatlab @@ -36,7 +36,7 @@ function unload() unload(cfg); disp("Cantera has been unloaded"); catch ME - warning("ct.unload:UnloadFailed", ... + warning("ct:unload:UnloadFailed", ... "unload(clibConfiguration) failed (%s).", ME.message); end end diff --git a/interfaces/matlab/buildUtilities/audit_library.py b/interfaces/matlab/buildUtilities/audit_library.py new file mode 100644 index 0000000000..462ef4e062 --- /dev/null +++ b/interfaces/matlab/buildUtilities/audit_library.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Verify that a built Cantera library depends only on system libraries. + +Inspects the dynamic dependencies of a built cantera_shared and fails if +anything outside a per-platform allowlist appears. + +Run standalone against any prefix: + python audit_library.py --prefix build/canteraLib +""" + +from __future__ import annotations + +import argparse +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Libraries that are part of the OS (or of MATLAB's guaranteed runtime). +# libstdc++ and libgcc_s are deliberately absent on Linux: the build links them +# statically, so their appearance means -static-libstdc++ did not take effect. +ALLOWLISTS = { + "Linux": [ + r"^linux-vdso\.so\.1$", + r"^libc\.so\.6$", + r"^libm\.so\.6$", + r"^libdl\.so\.2$", + r"^libpthread\.so\.0$", + r"^librt\.so\.1$", + r"^ld-linux-.*\.so\.\d+$", + ], + "Darwin": [ + r"^/usr/lib/libSystem\.B\.dylib$", + r"^/usr/lib/libc\+\+\.1\.dylib$", + r"^/usr/lib/libobjc\.A\.dylib$", + r"^/System/Library/Frameworks/Accelerate\.framework/", + # The library's own id and its co-located siblings. + r"^@rpath/libcantera_shared", + r"^@loader_path/", + ], + "Windows": [ + r"^KERNEL32\.dll$", + r"^ADVAPI32\.dll$", + r"^USER32\.dll$", + r"^SHELL32\.dll$", + r"^ole32\.dll$", + r"^OLEAUT32\.dll$", + r"^bcrypt\.dll$", + r"^dbgeng\.dll$", + r"^api-ms-win-.*\.dll$", + # The MSVC redistributable, which MATLAB ships. + r"^MSVCP140.*\.dll$", + r"^VCRUNTIME140.*\.dll$", + ], +} + + +def find_library(prefix: Path) -> Path: + """Locate the real (non-symlink) shared library under the prefix.""" + patterns = { + "Linux": "libcantera_shared.so*", + "Darwin": "libcantera_shared*.dylib", + "Windows": "cantera_shared.dll", + }[platform.system()] + + candidates = [p for p in (prefix / "lib").glob(patterns) if not p.is_symlink()] + if not candidates: + raise FileNotFoundError( + f"No cantera_shared library found in {prefix / 'lib'}" + ) + # Prefer the fully versioned file, which is the real object on Linux/macOS. + return max(candidates, key=lambda p: len(p.name)) + + +def dependencies(library: Path) -> list[str]: + """Return the library's direct dynamic dependencies, as recorded names.""" + system = platform.system() + + if system == "Linux": + # readelf reports DT_NEEDED. ldd would resolve the transitive graph + # through this machine's paths and hide a missing dependency behind a + # locally installed copy. + out = _run(["readelf", "-d", str(library)]) + return re.findall(r"Shared library: \[([^\]]+)\]", out) + + if system == "Darwin": + # First line is the file name; the rest are deps, starting with the + # library's own install id. + out = _run(["otool", "-L", str(library)]) + return [line.strip().split(" (")[0] + for line in out.splitlines()[1:] if line.startswith("\t")] + + if system == "Windows": + # Parsed in-process; dumpbin only exists in a Visual Studio developer + # shell, and CI runs these steps under plain bash. + return pe_imports(library) + + raise RuntimeError(f"Unsupported platform: {system}") + + +def pe_imports(library: Path) -> list[str]: + """Read the DLL names from a PE file's import directory.""" + data = library.read_bytes() + + def u16(off: int) -> int: + return int.from_bytes(data[off:off + 2], "little") + + def u32(off: int) -> int: + return int.from_bytes(data[off:off + 4], "little") + + if data[:2] != b"MZ": + raise ValueError(f"{library} is not a PE image") + + pe = u32(0x3C) + if data[pe:pe + 4] != b"PE\0\0": + raise ValueError(f"{library} has no PE signature") + + coff = pe + 4 + n_sections = u16(coff + 2) + opt_size = u16(coff + 16) + opt = coff + 20 + + # Data directories sit at offset 96 in a PE32 optional header, 112 in + # PE32+, where several fields widen to 8 bytes. + magic = u16(opt) + dir_off = opt + (96 if magic == 0x10B else 112) + import_rva = u32(dir_off + 8 * 1) # data directory 1 = imports + if import_rva == 0: + return [] + + sections = [] + sec_table = opt + opt_size + for i in range(n_sections): + s = sec_table + 40 * i + sections.append((u32(s + 12), u32(s + 8), u32(s + 20))) # va, vsize, raw + + def to_offset(rva: int) -> int: + for va, vsize, raw in sections: + if va <= rva < va + max(vsize, 1): + return raw + (rva - va) + raise ValueError(f"RVA {rva:#x} is outside every section") + + def cstring(rva: int) -> str: + start = to_offset(rva) + end = data.index(b"\0", start) + return data[start:end].decode("ascii", "replace") + + names, entry = [], to_offset(import_rva) + while True: + # IMAGE_IMPORT_DESCRIPTOR is 20 bytes with the name RVA at offset 12; + # an all-zero descriptor terminates the table. + descriptor = data[entry:entry + 20] + if len(descriptor) < 20 or descriptor == b"\0" * 20: + break + names.append(cstring(u32(entry + 12))) + entry += 20 + + return names + + +def _run(cmd: list[str]) -> str: + """Run a required inspection tool and return its stdout.""" + if shutil.which(cmd[0]) is None: + raise RuntimeError( + f"'{cmd[0]}' not found; it is required to audit the built library." + ) + return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--prefix", help="Install prefix containing lib/") + source.add_argument("--library", + help="Audit this library file directly, e.g. the copy " + "clibgen placed in the staged ctMatlab folder.") + args = parser.parse_args() + + if args.library: + library = Path(args.library).resolve() + if not library.is_file(): + print(f"error: no such file: {library}", file=sys.stderr) + return 1 + else: + library = find_library(Path(args.prefix).resolve()) + allowed = [re.compile(p) for p in ALLOWLISTS[platform.system()]] + + deps = dependencies(library) + unexpected = [d for d in deps if not any(p.search(d) for p in allowed)] + + print(f"[audit_library] {library.name} depends on {len(deps)} libraries:") + for dep in deps: + mark = " " if dep not in unexpected else "->" + print(f" {mark} {dep}") + + if unexpected: + sys.stdout.flush() # keep the listing above the error in CI logs + print( + f"\nerror: {library.name} has {len(unexpected)} dependency/ies " + f"outside the {platform.system()} allowlist:\n" + + "\n".join(f" {d}" for d in unexpected) + + "\n\nEither vendor them into the build or, if genuinely part of " + "the OS, add them to ALLOWLISTS in this file.", + file=sys.stderr, + ) + return 1 + + print(f"\n[audit_library] OK: no dependencies outside the " + f"{platform.system()} system allowlist.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/interfaces/matlab/buildUtilities/build_cantera_library.py b/interfaces/matlab/buildUtilities/build_cantera_library.py new file mode 100644 index 0000000000..8471e551a1 --- /dev/null +++ b/interfaces/matlab/buildUtilities/build_cantera_library.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Build a self-contained Cantera shared library for the MATLAB toolbox. + +This script produces a library by building Cantera from source with all +third-party dependencies vendored and statically linked, and verifies the result +against a per-platform allowlist of system libraries (see audit_library.py). + +It is deliberately kept out of MATLAB: MATLAB injects its own library paths into +child processes, which breaks compilers and linkers. + +Usage: + python build_cantera_library.py --cantera-root --prefix +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +STAMP_NAME = ".build-stamp" + +# Bump when the recipe changes in a way the SCons flag list does not capture. +# 2: normalize_layout also stages the Windows import library into lib/. +RECIPE_VERSION = 2 + +# Submodules the vendored build compiles directly. HighFive is excluded because +# hdf_support=n means it is never used. +REQUIRED_SUBMODULES = ("ext/fmt", "ext/sundials", "ext/eigen", "ext/yaml-cpp") + + +def check_submodules(cantera_root: Path) -> list[str]: + """Check the vendored dependencies are at the commits Cantera records. + + A stale submodule looks healthy but fails later with missing headers. + """ + try: + out = subprocess.run( + ["git", "-C", str(cantera_root), "submodule", "status"], + capture_output=True, text=True, check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + # Not a git checkout; fall back to checking the directories exist. + empty = [p for p in REQUIRED_SUBMODULES + if not any((cantera_root / p).glob("*"))] + if empty: + return [f"Vendored dependencies are missing: {', '.join(empty)}"] + return [] + + # " ()"; '-' uninitialized, '+' wrong commit. + stale = [] + for line in out.splitlines(): + if not line.strip(): + continue + parts = line[1:].split() + if len(parts) < 2: + continue + path = parts[1].replace("\\", "/") + if path not in REQUIRED_SUBMODULES: + continue + if line[0] == "-": + stale.append(f"{path}: not initialized") + elif line[0] == "+": + described = parts[2].strip("()") if len(parts) > 2 else "unknown" + stale.append(f"{path}: at {described}, not the recorded commit") + + if stale: + return ["Cantera submodules are not at the recorded commits:\n" + + "\n".join(f" {s}" for s in stale) + + f"\n git -C {cantera_root} submodule update --init " + f"--recursive"] + + return [] + + +def check_prerequisites(boost_inc_dir: str | None = None) -> list[str]: + """Report missing build tools before starting a long build.""" + problems = [] + + # cantera_clib is generated at build time by sourcegen, which reads a + # Doxygen tag file, so doxygen is a build requirement and not a docs-only + # one. Cantera's Doxyfile sets CITE_BIB_FILES, so doxygen also needs perl. + if shutil.which("doxygen") is None: + problems.append( + "doxygen is not on PATH; it generates the tag file sourcegen " + "reads to produce cantera_clib.\n" + " conda install -c conda-forge doxygen" + ) + + if shutil.which("perl") is None: + problems.append( + "perl is not on PATH; doxygen needs it to run bibtex.\n" + " Windows: run from Git Bash, or install Strawberry Perl." + ) + + if boost_inc_dir: + boost = Path(boost_inc_dir) + if not boost.is_dir(): + problems.append(f"BOOST_INC_DIR does not exist: {boost}") + elif not (boost / "boost" / "version.hpp").is_file(): + problems.append( + f"BOOST_INC_DIR has no boost/version.hpp: {boost}\n" + f" It must contain boost/, not be boost/ itself." + ) + + # packaging is imported by SConstruct itself; the other two by sourcegen. + missing = [] + for module in ("packaging", "jinja2", "ruamel.yaml"): + try: + __import__(module) + except ImportError: + missing.append(module) + + if missing: + problems.append( + f"The Cantera build requires {', '.join(missing)}, not importable " + f"from {sys.executable}.\n" + f" python -m pip install {' '.join(missing)}" + ) + + return problems + + +def warn_about_inherited_options(cantera_root: Path, flags: list[str]) -> None: + """Warn about cantera.conf options this script does not set. + + SCons rewrites cantera.conf every run, so only options absent from `flags` + are actually inherited. + """ + conf = cantera_root / "cantera.conf" + if not conf.is_file(): + return + + ours = {flag.split("=", 1)[0] for flag in flags} + try: + theirs = set(re.findall(r"^(\w+)\s*=", conf.read_text(), re.M)) + except OSError: + return + + inherited = sorted(theirs - ours) + if inherited: + print(f"warning: {conf} sets options this script does not control, " + f"which will be\n inherited by the build: " + f"{', '.join(inherited)}.\n" + f" Rename the file for a build that matches CI.", + file=sys.stderr) + + +def boost_flags(boost_inc_dir: str | None) -> list[str]: + """Point SCons at Boost headers when not on the default include path. + + Boost (>= 1.83) is the one dependency Cantera does not vendor. It is + header-only here, so it adds no runtime dependency. + """ + if not boost_inc_dir: + return [] + return [f"boost_inc_dir={Path(boost_inc_dir).as_posix()}"] + + +def scons_flags(prefix: Path) -> list[str]: + """SCons options shared by every platform. + + Each ``system_*=n`` statically links the copy vendored under ``ext/``, + leaving no runtime dependency behind. + """ + return [ + f"prefix={prefix.as_posix()}", + "layout=compact", + "libdirname=lib", + "system_eigen=n", + "system_fmt=n", + "system_yamlcpp=n", + "system_sundials=n", + "system_highfive=n", + "system_blas_lapack=n", + "hdf_support=n", + "python_package=n", + "f90_interface=n", + "googletest=none", + "package_build=y", + "optimize=y", + "debug=n", + "renamed_shared_libraries=y", + "versioned_shared_library=y", + "use_rpath_linkage=n", + ] + + +def platform_flags() -> list[str]: + """Per-platform flags that make the library relocatable. + + ``no_debug_linker_flags`` reaches LINKFLAGS because every build here is + ``debug=n``. + """ + system = platform.system() + + if system == "Linux": + link = [ + # MATLAB ships its own, usually older, libstdc++. + "-static-libstdc++", + "-static-libgcc", + # Hide symbols from the vendored static archives so they cannot + # collide with copies already loaded into MATLAB. Do NOT add + # -fvisibility=hidden: it would hide Cantera's own exports too. + "-Wl,--exclude-libs,ALL", + "-Wl,-z,origin", + # SCons substitutes '$$' to a literal '$'. + "-Wl,-rpath,$$ORIGIN", + ] + return [f"no_debug_linker_flags={' '.join(link)}"] + + if system == "Darwin": + # libc++ is part of the OS, so there is no C++ runtime to bundle. + return ["no_debug_linker_flags=-Wl,-rpath,@loader_path"] + + if system == "Windows": + # ext/sundials_export.h defines SUNDIALS_DEPRECATED with GCC's + # __attribute__ syntax unconditionally, which MSVC cannot parse. The + # header guards it with #ifndef, so pre-defining it empty sidesteps + # the problem. Remove once fixed upstream. + # + # cc_flags REPLACES SConstruct's default, so the 'cl' default is + # repeated here and must be kept in sync. Dropping /MD would switch + # cantera_shared to the static CRT and give it a private heap. + msvc_defaults = ("/MD /nologo /D_SCL_SECURE_NO_WARNINGS " + "/D_CRT_SECURE_NO_WARNINGS") + return [ + # SConstruct guesses mingw whenever g++ is on PATH and cl.exe is + # not -- true on GitHub's Windows images -- and the MSVC flags + # below would then go to g++. + "toolchain=msvc", + f"cc_flags={msvc_defaults} /DSUNDIALS_DEPRECATED=", + ] + + raise RuntimeError(f"Unsupported platform: {system}") + + +def environment() -> dict[str, str]: + """Environment for the SCons subprocess.""" + env = os.environ.copy() + + if platform.system() == "Darwin": + # Load on older macOS than the build machine. + env.setdefault("MACOSX_DEPLOYMENT_TARGET", "11.0") + + return env + + +def stamp_value(cantera_root: Path, flags: list[str]) -> dict: + """Identify this build by Cantera commit and recipe. + + Hashing the source tree would be accurate but far too slow. + """ + try: + sha = subprocess.run( + ["git", "-C", str(cantera_root), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + sha = "unknown" + + digest = hashlib.sha256("\n".join(sorted(flags)).encode()).hexdigest()[:16] + + return { + "cantera_commit": sha, + "flags_sha256": digest, + "recipe_version": RECIPE_VERSION, + "platform": f"{platform.system()}-{platform.machine()}", + } + + +def is_current(prefix: Path, want: dict) -> bool: + """Report whether the existing install matches this build's stamp.""" + stamp = prefix / STAMP_NAME + if not stamp.is_file(): + return False + try: + return json.loads(stamp.read_text()) == want + except (json.JSONDecodeError, OSError): + return False + + +def scons_command() -> list[str]: + """Prefer the SCons module in the active interpreter over a bare script.""" + try: + subprocess.run([sys.executable, "-m", "SCons", "--version"], + capture_output=True, check=True) + return [sys.executable, "-m", "SCons"] + except (subprocess.CalledProcessError, FileNotFoundError): + pass + + scons = shutil.which("scons") + if scons is None: + raise RuntimeError( + "SCons not found. Install it with 'pip install scons' or make the " + "'scons' executable available on PATH." + ) + return [scons] + + +def run_scons(cantera_root: Path, targets: list[str], flags: list[str], + jobs: int, verbose: bool) -> None: + """Run SCons in the Cantera source tree.""" + cmd = scons_command() + targets + [f"-j{jobs}"] + flags + print(f"[build_cantera_library] {' '.join(cmd)}", flush=True) + + result = subprocess.run( + cmd, cwd=cantera_root, env=environment(), + stdout=None if verbose else subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, + ) + if result.returncode != 0: + if result.stdout: + print(result.stdout, file=sys.stderr) + raise RuntimeError(f"SCons failed with exit code {result.returncode}") + + +def normalize_layout(prefix: Path) -> None: + """Adjust the install so ct.buildInterface can consume it directly. + + The compact layout already provides ``/cantera_clib``. + """ + if platform.system() == "Windows": + # SCons installs both the DLL and its import library to bin/, while + # lib/ receives the static library. ctLib.m looks for the DLL in one + # directory and MSVC needs the import library to link, so copy both + # into lib/. The .exp is a link byproduct and is not needed. + bindir, libdir = prefix / "bin", prefix / "lib" + libdir.mkdir(parents=True, exist_ok=True) + for pattern in ("*.dll", "*_shared.lib"): + for artifact in bindir.glob(pattern): + shutil.copy2(artifact, libdir / artifact.name) + print(f"[build_cantera_library] staged {artifact.name} " + f"into lib/") + + if platform.system() == "Darwin": + # Locate the library relative to wherever it is unpacked. + for dylib in (prefix / "lib").glob("libcantera_shared*.dylib"): + if dylib.is_symlink(): + continue + subprocess.run( + ["install_name_tool", "-id", f"@rpath/{dylib.name}", str(dylib)], + check=True, + ) + print(f"[build_cantera_library] set install_name for {dylib.name}") + + +def audit(prefix: Path) -> None: + """Verify the built library has no non-system dependencies.""" + script = Path(__file__).with_name("audit_library.py") + subprocess.run([sys.executable, str(script), "--prefix", str(prefix)], + check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cantera-root", default=os.environ.get("CANTERA_ROOT"), + help="Cantera source checkout (default: $CANTERA_ROOT)") + parser.add_argument("--prefix", required=True, + help="Install prefix for the built library") + parser.add_argument("--boost-inc-dir", default=os.environ.get("BOOST_INC_DIR"), + help="Boost header directory (default: $BOOST_INC_DIR). " + "Only needed if Boost >= 1.83 is not on the " + "compiler's default include path.") + parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4) + parser.add_argument("--force", action="store_true", + help="Rebuild even if the stamp is current") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + if not args.cantera_root: + parser.error("--cantera-root not given and CANTERA_ROOT is not set") + + cantera_root = Path(args.cantera_root).resolve() + if not (cantera_root / "SConstruct").is_file(): + parser.error(f"No SConstruct in {cantera_root}; not a Cantera checkout") + + problems = (check_submodules(cantera_root) + + check_prerequisites(args.boost_inc_dir)) + if problems: + print("error: missing build prerequisites:\n\n" + + "\n\n".join(f" - {p}" for p in problems), + file=sys.stderr) + return 1 + + prefix = Path(args.prefix).resolve() + flags = (scons_flags(prefix) + platform_flags() + + boost_flags(args.boost_inc_dir)) + warn_about_inherited_options(cantera_root, flags) + want = stamp_value(cantera_root, flags) + + if not args.force and is_current(prefix, want): + print(f"[build_cantera_library] up to date at {prefix}; skipping " + f"(--force to rebuild)") + return 0 + + if prefix.exists(): + shutil.rmtree(prefix) + + run_scons(cantera_root, ["build"], flags, args.jobs, args.verbose) + run_scons(cantera_root, ["install"], flags, args.jobs, args.verbose) + + normalize_layout(prefix) + audit(prefix) + + (prefix / STAMP_NAME).write_text(json.dumps(want, indent=2)) + print(f"[build_cantera_library] self-contained Cantera installed to {prefix}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())