Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.<os>` 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

Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
22 changes: 22 additions & 0 deletions src/build/ninja_backend.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dll>), 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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
}

Expand Down
33 changes: 33 additions & 0 deletions src/build/plan.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::filesystem::path> 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<DeployFile> 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.
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion src/toolchain/fingerprint.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 85 additions & 0 deletions tests/e2e/84_runtime_dll_deploy.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading