Skip to content

Commit 2f38dd6

Browse files
committed
feat(build): Windows runtime-DLL deployment beside the executable; v0.0.73
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.
1 parent 42ad3a4 commit 2f38dd6

6 files changed

Lines changed: 171 additions & 6 deletions

File tree

.agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
11
# Windows Runtime-DLL Deployment & `compat.openblas` Windows Support (Design)
22

33
Date: 2026-06-29
4-
Status: **Design — not yet implemented.** Proposed as the staged follow-up to the
5-
feature/capability work (mcpp 0.0.72) and the `compat.openblas` package
6-
(mcpp-index #54). Scope: `src/manifest.cppm`, `src/build/{plan,flags,ninja_backend}.cppm`
7-
(mcpp); `pkgs/c/compat.openblas.lua`, `.github/workflows/validate.yml` (mcpp-index).
4+
Status: **Phase A implemented in mcpp 0.0.73.** Phases B–D (release + recipe +
5+
Windows CI) track in the same effort. Staged follow-up to the feature/capability
6+
work (mcpp 0.0.72) and the `compat.openblas` package (mcpp-index #54). Scope:
7+
`src/build/{plan,ninja_backend}.cppm` (mcpp); `pkgs/c/compat.openblas.lua`,
8+
`.github/workflows/validate.yml` (mcpp-index).
9+
10+
### Implementation note (deviation from the original design)
11+
12+
The implemented mechanism is **gated by the `*.dll` file extension, not by
13+
`if constexpr(is_windows)` and not by a schema change.** During build planning,
14+
each `*.dll` found in a linked dependency's `[runtime] library_dirs` is staged
15+
into `bin/` beside the produced executable via a ninja `cp_bmi` copy edge that
16+
the executable target takes as an implicit dependency. Consequences:
17+
18+
- **No `manifest.cppm` schema change** (the original Phase-A-step-1). A recipe
19+
declares `[runtime] library_dirs` *globally*; on Linux/macOS the dependency
20+
ships `.so`/`.dylib` (never `.dll`), so the glob matches nothing and the build
21+
is byte-for-byte unchanged. This is exactly the §7 "declare it globally —
22+
harmless on Linux" option, made safe by the extension filter. Per-OS scoping
23+
under `mcpp.<os>` is therefore unnecessary (and already supported for free by
24+
the existing per-OS textual merge if a recipe ever wants it).
25+
- **The deploy path is exercised on a Linux host** (test
26+
`tests/e2e/84_runtime_dll_deploy.sh`) by a dummy dependency shipping a stub
27+
`libdummy.dll` — the same code that runs on Windows, validated without a
28+
Windows runner. The Windows link/run half is Phase D (mcpp-index CI).
29+
- **`mcpp pack`** needs no change here: Windows PE packaging is separately
30+
stubbed (`src/pack/pack.cppm`, see `2026-05-19-pack-windows-design.md`); when
31+
implemented it will pick up the staged `bin/*.dll`. On Linux `mcpp pack` uses
32+
`ldd`, which never sees a `.dll`, so the deploy is invisible to it.
833

934
## 1. Problem
1035

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "0.0.72"
3+
version = "0.0.73"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/ninja_backend.cppm

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,14 @@ std::string emit_ninja_string(const BuildPlan& plan) {
633633
for (auto& input : lu.implicitInputs) {
634634
implicit += " " + escape_ninja_path(input);
635635
}
636+
// Windows runtime-DLL deployment: an executable takes an implicit
637+
// dependency on each staged dep DLL (bin/<dll>), so ninja copies them
638+
// beside the .exe before the build is considered done. Empty on RPATH
639+
// platforms (no *.dll deps), so other targets are unaffected.
640+
if (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) {
641+
for (auto const& d : plan.runtimeDeployFiles)
642+
implicit += " " + escape_ninja_path(d.dest);
643+
}
636644

637645
std::string out_line = std::format("build {} : {}{}{}\n",
638646
escape_ninja_path(lu.output), rule, ins,
@@ -660,6 +668,17 @@ std::string emit_ninja_string(const BuildPlan& plan) {
660668
}
661669
append("\n");
662670

671+
// Windows runtime-DLL deployment: one copy edge per staged dep DLL. Emitted
672+
// once (deduped by dest in BuildPlan), reusing the generic cp_bmi copy rule.
673+
// Inert on RPATH platforms where runtimeDeployFiles is empty.
674+
for (auto const& d : plan.runtimeDeployFiles) {
675+
append(std::format("build {} : cp_bmi {}\n",
676+
escape_ninja_path(d.dest),
677+
escape_ninja_path(d.source)));
678+
}
679+
if (!plan.runtimeDeployFiles.empty())
680+
append("\n");
681+
663682
if (!plan.linkUnits.empty()) {
664683
std::string defaults;
665684
for (auto& lu : plan.linkUnits) {
@@ -668,6 +687,9 @@ std::string emit_ninja_string(const BuildPlan& plan) {
668687
defaults += " " + escape_ninja_path(alias);
669688
}
670689
}
690+
for (auto const& d : plan.runtimeDeployFiles) {
691+
defaults += " " + escape_ninja_path(d.dest);
692+
}
671693
append("default" + defaults + "\n");
672694
}
673695

src/build/plan.cppm

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ struct BuildPlan {
6161
// binary's RUNPATH (e.g. compat.glx-runtime). Kept separate so static/musl
6262
// links don't pull the glibc payload dir.
6363
std::vector<std::filesystem::path> depRuntimeLibraryDirs;
64+
// Windows runtime-DLL deployment. On PE (`supports_rpath` is false) a
65+
// directly-launched .exe cannot RUNPATH-locate a dependency's DLL, so each
66+
// *.dll found in a dependency's [runtime] library_dir is copied beside the
67+
// produced executable (into bin/). The filter is the *.dll extension, not a
68+
// platform `if constexpr`: a real Linux/macOS dependency ships .so/.dylib
69+
// (never .dll), so this list is empty there and non-Windows builds are
70+
// byte-for-byte unchanged; only a Windows prebuilt-DLL package (or a test
71+
// that ships a .dll) populates it. dest is relative to outputDir.
72+
struct DeployFile {
73+
std::filesystem::path source; // absolute source DLL
74+
std::filesystem::path dest; // relative to outputDir, e.g. bin/libopenblas.dll
75+
};
76+
std::vector<DeployFile> runtimeDeployFiles;
6477
// Aggregated host-runtime requirements from dependency packages'
6578
// [runtime] metadata. Capability/provider-driven — no platform special-casing
6679
// in mcpp: providers (e.g. compat.glx-runtime) declare these per platform.
@@ -309,6 +322,26 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
309322
auto abs = dir.is_absolute() ? dir : package.root / dir;
310323
append_unique_path(plan.runtimeLibraryDirs, abs);
311324
append_unique_path(plan.depRuntimeLibraryDirs, abs);
325+
// Windows runtime-DLL deployment: stage each *.dll from this dir
326+
// beside the produced executable (bin/). The *.dll filter — not a
327+
// platform guard — keeps this inert for real .so/.dylib deps, so
328+
// non-Windows builds are unchanged. See BuildPlan::DeployFile.
329+
std::error_code dirEc;
330+
if (std::filesystem::is_directory(abs, dirEc)) {
331+
for (auto const& entry :
332+
std::filesystem::directory_iterator(abs, dirEc)) {
333+
if (!entry.is_regular_file()) continue;
334+
auto ext = entry.path().extension().string();
335+
std::ranges::transform(ext, ext.begin(),
336+
[](unsigned char c){ return std::tolower(c); });
337+
if (ext != ".dll") continue;
338+
std::filesystem::path dest =
339+
std::filesystem::path("bin") / entry.path().filename();
340+
if (std::ranges::none_of(plan.runtimeDeployFiles,
341+
[&](auto const& d){ return d.dest == dest; }))
342+
plan.runtimeDeployFiles.push_back({entry.path(), dest});
343+
}
344+
}
312345
}
313346
for (auto const& lib : package.manifest.runtimeConfig.dlopenLibs) {
314347
if (std::ranges::find(plan.runtimeDlopenLibs, lib) == plan.runtimeDlopenLibs.end())

src/toolchain/fingerprint.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import mcpp.toolchain.detect;
1818

1919
export namespace mcpp::toolchain {
2020

21-
inline constexpr std::string_view MCPP_VERSION = "0.0.72";
21+
inline constexpr std::string_view MCPP_VERSION = "0.0.73";
2222

2323
struct FingerprintInputs {
2424
Toolchain toolchain;

tests/e2e/84_runtime_dll_deploy.sh

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
# 84_runtime_dll_deploy.sh — Windows runtime-DLL deployment, validated MECHANICALLY
3+
# on any host. A dependency's `[runtime] library_dirs` may hold a runtime DLL that
4+
# a directly-launched executable must find beside it (Windows has no RPATH). mcpp
5+
# stages every *.dll from a linked dependency's runtime library_dir into bin/,
6+
# next to the produced executable.
7+
#
8+
# The deploy is filtered by the *.dll extension, NOT by `if constexpr(is_windows)`:
9+
# a real Linux/macOS dependency ships .so/.dylib (never .dll), so the mechanism is
10+
# inert there and non-Windows builds are byte-for-byte unchanged. This test ships a
11+
# dummy `libdummy.dll` so the exact copy-beside-exe path is exercised on the Linux
12+
# CI host — the Windows link/run half is covered by mcpp-index CI (Phase D).
13+
# See .agents/docs/2026-06-29-windows-runtime-dll-deployment-and-openblas.md.
14+
#
15+
# No `requires:` capability → runs on all three CI platforms.
16+
set -e
17+
18+
TMP=$(mktemp -d)
19+
trap "rm -rf $TMP" EXIT
20+
cd "$TMP"
21+
22+
# A "prebuilt" dependency whose runtime artifact is a DLL sitting in bin/. The
23+
# file content is irrelevant — deployment is a file copy, not a link — so a stub
24+
# byte stream named libdummy.dll stands in for a real OpenBLAS-style DLL.
25+
mkdir -p blasish/bin blasish/src
26+
cat > blasish/mcpp.toml <<'EOF'
27+
[package]
28+
name = "blasish"
29+
version = "0.1.0"
30+
31+
[targets.blasish]
32+
kind = "lib"
33+
34+
# The runtime DLL lives in bin/. On Windows mcpp copies it beside the consumer's
35+
# .exe; on RPATH platforms the *.dll filter makes this a no-op.
36+
[runtime]
37+
library_dirs = ["bin"]
38+
EOF
39+
cat > blasish/src/blasish.cppm <<'EOF'
40+
export module blasish;
41+
export int blasish_anchor() { return 0; }
42+
EOF
43+
# Stub runtime DLL (and a sibling non-DLL that must NOT be deployed).
44+
printf 'MZ stub dll payload' > blasish/bin/libdummy.dll
45+
printf 'not a dll' > blasish/bin/readme.txt
46+
47+
mkdir -p app/src
48+
cat > app/mcpp.toml <<'EOF'
49+
[package]
50+
name = "app"
51+
version = "0.1.0"
52+
53+
[dependencies]
54+
blasish = { path = "../blasish" }
55+
EOF
56+
cat > app/src/main.cpp <<'EOF'
57+
import blasish;
58+
int main() { return blasish_anchor(); }
59+
EOF
60+
61+
cd app
62+
"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: build errored"; exit 1; }
63+
64+
# The executable's output directory (bin/, beside the .exe).
65+
BIN=$(find target -type f \( -name app -o -name app.exe \) | head -1)
66+
[ -n "$BIN" ] || { echo "FAIL: built binary not found under target/"; cat b.log; exit 1; }
67+
BINDIR=$(dirname "$BIN")
68+
69+
# The dependency's runtime DLL must have been staged beside the executable.
70+
if [ ! -f "$BINDIR/libdummy.dll" ]; then
71+
echo "FAIL: libdummy.dll was not deployed beside the executable ($BINDIR)"
72+
echo "--- bin/ contents ---"; ls -la "$BINDIR"
73+
echo "--- build.ninja deploy edges ---"; grep -n "libdummy" target/*/*/build.ninja 2>/dev/null || true
74+
exit 1
75+
fi
76+
77+
# Byte-for-byte copy of the source DLL.
78+
cmp -s ../blasish/bin/libdummy.dll "$BINDIR/libdummy.dll" || {
79+
echo "FAIL: deployed libdummy.dll differs from source"; exit 1; }
80+
81+
# The non-DLL sibling must NOT be deployed (only *.dll is staged).
82+
if [ -f "$BINDIR/readme.txt" ]; then
83+
echo "FAIL: non-DLL readme.txt was deployed (only *.dll should be)"; exit 1; fi
84+
85+
echo "OK"

0 commit comments

Comments
 (0)