From 2f38dd6d2473f4826625bd1e69ad071cb5e69a8f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 29 Jun 2026 18:37:27 +0800 Subject: [PATCH] feat(build): Windows runtime-DLL deployment beside the executable; v0.0.73 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directly-launched Windows .exe cannot RUNPATH-locate a dependency's runtime DLL (PE has no rpath). mcpp now stages every *.dll found in a linked dependency's [runtime] library_dirs into bin/, beside the produced executable, via a ninja copy edge the executable takes as an implicit dependency. This completes an abstraction already designed but unimplemented on PE: RPATH on ELF/Mach-O, copy-beside-exe on PE — both 'make the runtime library locatable'. The deploy is filtered by the *.dll extension, NOT by if constexpr(is_windows) and with no schema change: a real Linux/macOS dependency ships .so/.dylib (never .dll), so the glob matches nothing and non-Windows builds are byte-for-byte unchanged. A recipe declares [runtime] library_dirs globally; only a Windows prebuilt-DLL package populates the deploy list. This unblocks a Windows compat.openblas (import-lib + runtime DLL) and any future prebuilt-DLL package. Test: tests/e2e/84_runtime_dll_deploy.sh exercises the exact copy path on a Linux host via a dummy dependency shipping a stub libdummy.dll (asserts the DLL is staged beside the exe byte-for-byte; a non-DLL sibling is not). The Windows link/run half is verified in mcpp-index CI (design Phase D). Regression: unit suite (27 ok), e2e 74/80/81/83, self-host build. See .agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md. --- ...ows-runtime-dll-deployment-and-openblas.md | 33 ++++++- mcpp.toml | 2 +- src/build/ninja_backend.cppm | 22 +++++ src/build/plan.cppm | 33 +++++++ src/toolchain/fingerprint.cppm | 2 +- tests/e2e/84_runtime_dll_deploy.sh | 85 +++++++++++++++++++ 6 files changed, 171 insertions(+), 6 deletions(-) create mode 100755 tests/e2e/84_runtime_dll_deploy.sh diff --git a/.agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md b/.agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md index c8b1b76..713f1f6 100644 --- a/.agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md +++ b/.agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md @@ -1,10 +1,35 @@ # Windows Runtime-DLL Deployment & `compat.openblas` Windows Support (Design) Date: 2026-06-29 -Status: **Design — not yet implemented.** Proposed as the staged follow-up to the -feature/capability work (mcpp 0.0.72) and the `compat.openblas` package -(mcpp-index #54). Scope: `src/manifest.cppm`, `src/build/{plan,flags,ninja_backend}.cppm` -(mcpp); `pkgs/c/compat.openblas.lua`, `.github/workflows/validate.yml` (mcpp-index). +Status: **Phase A implemented in mcpp 0.0.73.** Phases B–D (release + recipe + +Windows CI) track in the same effort. Staged follow-up to the feature/capability +work (mcpp 0.0.72) and the `compat.openblas` package (mcpp-index #54). Scope: +`src/build/{plan,ninja_backend}.cppm` (mcpp); `pkgs/c/compat.openblas.lua`, +`.github/workflows/validate.yml` (mcpp-index). + +### Implementation note (deviation from the original design) + +The implemented mechanism is **gated by the `*.dll` file extension, not by +`if constexpr(is_windows)` and not by a schema change.** During build planning, +each `*.dll` found in a linked dependency's `[runtime] library_dirs` is staged +into `bin/` beside the produced executable via a ninja `cp_bmi` copy edge that +the executable target takes as an implicit dependency. Consequences: + +- **No `manifest.cppm` schema change** (the original Phase-A-step-1). A recipe + declares `[runtime] library_dirs` *globally*; on Linux/macOS the dependency + ships `.so`/`.dylib` (never `.dll`), so the glob matches nothing and the build + is byte-for-byte unchanged. This is exactly the §7 "declare it globally — + harmless on Linux" option, made safe by the extension filter. Per-OS scoping + under `mcpp.` is therefore unnecessary (and already supported for free by + the existing per-OS textual merge if a recipe ever wants it). +- **The deploy path is exercised on a Linux host** (test + `tests/e2e/84_runtime_dll_deploy.sh`) by a dummy dependency shipping a stub + `libdummy.dll` — the same code that runs on Windows, validated without a + Windows runner. The Windows link/run half is Phase D (mcpp-index CI). +- **`mcpp pack`** needs no change here: Windows PE packaging is separately + stubbed (`src/pack/pack.cppm`, see `2026-05-19-pack-windows-design.md`); when + implemented it will pick up the staged `bin/*.dll`. On Linux `mcpp pack` uses + `ldd`, which never sees a `.dll`, so the deploy is invisible to it. ## 1. Problem diff --git a/mcpp.toml b/mcpp.toml index 7e71554..135a986 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.72" +version = "0.0.73" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index b0184b5..f036705 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -633,6 +633,14 @@ std::string emit_ninja_string(const BuildPlan& plan) { for (auto& input : lu.implicitInputs) { implicit += " " + escape_ninja_path(input); } + // Windows runtime-DLL deployment: an executable takes an implicit + // dependency on each staged dep DLL (bin/), so ninja copies them + // beside the .exe before the build is considered done. Empty on RPATH + // platforms (no *.dll deps), so other targets are unaffected. + if (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) { + for (auto const& d : plan.runtimeDeployFiles) + implicit += " " + escape_ninja_path(d.dest); + } std::string out_line = std::format("build {} : {}{}{}\n", escape_ninja_path(lu.output), rule, ins, @@ -660,6 +668,17 @@ std::string emit_ninja_string(const BuildPlan& plan) { } append("\n"); + // Windows runtime-DLL deployment: one copy edge per staged dep DLL. Emitted + // once (deduped by dest in BuildPlan), reusing the generic cp_bmi copy rule. + // Inert on RPATH platforms where runtimeDeployFiles is empty. + for (auto const& d : plan.runtimeDeployFiles) { + append(std::format("build {} : cp_bmi {}\n", + escape_ninja_path(d.dest), + escape_ninja_path(d.source))); + } + if (!plan.runtimeDeployFiles.empty()) + append("\n"); + if (!plan.linkUnits.empty()) { std::string defaults; for (auto& lu : plan.linkUnits) { @@ -668,6 +687,9 @@ std::string emit_ninja_string(const BuildPlan& plan) { defaults += " " + escape_ninja_path(alias); } } + for (auto const& d : plan.runtimeDeployFiles) { + defaults += " " + escape_ninja_path(d.dest); + } append("default" + defaults + "\n"); } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 44b72ae..13b361b 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -61,6 +61,19 @@ struct BuildPlan { // binary's RUNPATH (e.g. compat.glx-runtime). Kept separate so static/musl // links don't pull the glibc payload dir. std::vector depRuntimeLibraryDirs; + // Windows runtime-DLL deployment. On PE (`supports_rpath` is false) a + // directly-launched .exe cannot RUNPATH-locate a dependency's DLL, so each + // *.dll found in a dependency's [runtime] library_dir is copied beside the + // produced executable (into bin/). The filter is the *.dll extension, not a + // platform `if constexpr`: a real Linux/macOS dependency ships .so/.dylib + // (never .dll), so this list is empty there and non-Windows builds are + // byte-for-byte unchanged; only a Windows prebuilt-DLL package (or a test + // that ships a .dll) populates it. dest is relative to outputDir. + struct DeployFile { + std::filesystem::path source; // absolute source DLL + std::filesystem::path dest; // relative to outputDir, e.g. bin/libopenblas.dll + }; + std::vector runtimeDeployFiles; // Aggregated host-runtime requirements from dependency packages' // [runtime] metadata. Capability/provider-driven — no platform special-casing // in mcpp: providers (e.g. compat.glx-runtime) declare these per platform. @@ -309,6 +322,26 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, auto abs = dir.is_absolute() ? dir : package.root / dir; append_unique_path(plan.runtimeLibraryDirs, abs); append_unique_path(plan.depRuntimeLibraryDirs, abs); + // Windows runtime-DLL deployment: stage each *.dll from this dir + // beside the produced executable (bin/). The *.dll filter — not a + // platform guard — keeps this inert for real .so/.dylib deps, so + // non-Windows builds are unchanged. See BuildPlan::DeployFile. + std::error_code dirEc; + if (std::filesystem::is_directory(abs, dirEc)) { + for (auto const& entry : + std::filesystem::directory_iterator(abs, dirEc)) { + if (!entry.is_regular_file()) continue; + auto ext = entry.path().extension().string(); + std::ranges::transform(ext, ext.begin(), + [](unsigned char c){ return std::tolower(c); }); + if (ext != ".dll") continue; + std::filesystem::path dest = + std::filesystem::path("bin") / entry.path().filename(); + if (std::ranges::none_of(plan.runtimeDeployFiles, + [&](auto const& d){ return d.dest == dest; })) + plan.runtimeDeployFiles.push_back({entry.path(), dest}); + } + } } for (auto const& lib : package.manifest.runtimeConfig.dlopenLibs) { if (std::ranges::find(plan.runtimeDlopenLibs, lib) == plan.runtimeDlopenLibs.end()) diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 4a30176..d38401d 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.72"; +inline constexpr std::string_view MCPP_VERSION = "0.0.73"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/84_runtime_dll_deploy.sh b/tests/e2e/84_runtime_dll_deploy.sh new file mode 100755 index 0000000..3639adc --- /dev/null +++ b/tests/e2e/84_runtime_dll_deploy.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 84_runtime_dll_deploy.sh — Windows runtime-DLL deployment, validated MECHANICALLY +# on any host. A dependency's `[runtime] library_dirs` may hold a runtime DLL that +# a directly-launched executable must find beside it (Windows has no RPATH). mcpp +# stages every *.dll from a linked dependency's runtime library_dir into bin/, +# next to the produced executable. +# +# The deploy is filtered by the *.dll extension, NOT by `if constexpr(is_windows)`: +# a real Linux/macOS dependency ships .so/.dylib (never .dll), so the mechanism is +# inert there and non-Windows builds are byte-for-byte unchanged. This test ships a +# dummy `libdummy.dll` so the exact copy-beside-exe path is exercised on the Linux +# CI host — the Windows link/run half is covered by mcpp-index CI (Phase D). +# See .agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md. +# +# No `requires:` capability → runs on all three CI platforms. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# A "prebuilt" dependency whose runtime artifact is a DLL sitting in bin/. The +# file content is irrelevant — deployment is a file copy, not a link — so a stub +# byte stream named libdummy.dll stands in for a real OpenBLAS-style DLL. +mkdir -p blasish/bin blasish/src +cat > blasish/mcpp.toml <<'EOF' +[package] +name = "blasish" +version = "0.1.0" + +[targets.blasish] +kind = "lib" + +# The runtime DLL lives in bin/. On Windows mcpp copies it beside the consumer's +# .exe; on RPATH platforms the *.dll filter makes this a no-op. +[runtime] +library_dirs = ["bin"] +EOF +cat > blasish/src/blasish.cppm <<'EOF' +export module blasish; +export int blasish_anchor() { return 0; } +EOF +# Stub runtime DLL (and a sibling non-DLL that must NOT be deployed). +printf 'MZ stub dll payload' > blasish/bin/libdummy.dll +printf 'not a dll' > blasish/bin/readme.txt + +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +blasish = { path = "../blasish" } +EOF +cat > app/src/main.cpp <<'EOF' +import blasish; +int main() { return blasish_anchor(); } +EOF + +cd app +"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: build errored"; exit 1; } + +# The executable's output directory (bin/, beside the .exe). +BIN=$(find target -type f \( -name app -o -name app.exe \) | head -1) +[ -n "$BIN" ] || { echo "FAIL: built binary not found under target/"; cat b.log; exit 1; } +BINDIR=$(dirname "$BIN") + +# The dependency's runtime DLL must have been staged beside the executable. +if [ ! -f "$BINDIR/libdummy.dll" ]; then + echo "FAIL: libdummy.dll was not deployed beside the executable ($BINDIR)" + echo "--- bin/ contents ---"; ls -la "$BINDIR" + echo "--- build.ninja deploy edges ---"; grep -n "libdummy" target/*/*/build.ninja 2>/dev/null || true + exit 1 +fi + +# Byte-for-byte copy of the source DLL. +cmp -s ../blasish/bin/libdummy.dll "$BINDIR/libdummy.dll" || { + echo "FAIL: deployed libdummy.dll differs from source"; exit 1; } + +# The non-DLL sibling must NOT be deployed (only *.dll is staged). +if [ -f "$BINDIR/readme.txt" ]; then + echo "FAIL: non-DLL readme.txt was deployed (only *.dll should be)"; exit 1; fi + +echo "OK"