Skip to content

Commit a557310

Browse files
committed
feat(windows): give the SDK and the runtime the axes the compiler already had
Implements §1 / §2 / §3 of .agents/docs/2026-08-16-windows-toolchain-three-axes-design.md. Three questions were entangled because only one of them had ever been modelled. The compiler got a version axis in the last round; the headers it compiles against and the runtime the artifact loads did not. §2 THE SDK IS BOUND, NOT SEARCHED. `find_windows_sdk()` scanned — WindowsSdkDir, then the sibling store, then the conventional roots — for BOTH origins. So a pinned `msvc@<toolset>` was a pin the environment could overwrite, and two machines could build one manifest against two SDKs with nothing in the log naming either. It is now resolved by origin: a managed toolset takes the SDK payload from its own store and ignores WindowsSdkDir/WindowsSdkVersion *out loud*; `msvc@system` keeps today's chain, because a machine's things can only be found by looking. A managed toolset with no SDK payload beside it still falls back to the machine's — working beats failing — and says so, because that build is no longer reproducible and only that line records it. §2.3 `ucrt@<version>` FILLS A SLOT THAT HAS BEEN RESERVED SINCE THE FIELD EXISTED. `RuntimeBinding::runtimeId`'s comment has documented it from the start and nothing ever wrote one, so the SDK version never reached `runtimeContractHash` and two SDKs shared one build cache. It is NOT isomorphic to `glibc@`, and the comment says so where it will be read: glibc@ binds a payload (headers + .so, patchelf makes the artifact run on that copy), ucrt@ declares a floor (ucrtbase.dll is an OS component from Win10 on; mcpp's windows-sdk payload deliberately carries only half of ucrt and no redistributable). It is therefore not projected into `libc`. The `glibc@`-prefix gates become `runtime_provider()` dispatch, so another provider reads as "no rules here" rather than "no identity". §3.3 `toolchain-coupled` NOW MEANS SOMETHING ON PE. The refusal said the MSVC runtime "ships with the OS/redistributable, not with the toolchain" — true of ucrtbase.dll, false of vcruntime140.dll/msvcp140.dll, which sit in VC\Redist\MSVC\ inside every toolset. That is the relationship gcc has to libstdc++.so, so it takes the same contract; PE has no rpath, so the mechanism is a copy beside the artifact rather than a search path. /MT stays a degradation, and a genuine one: a static CRT leaves no DLL to couple to. The DLL set comes from `vc_redist_dir()` — the single criterion that excludes `debug_nonredist\`, which may not be redistributed. A second, name-shaped rule here could disagree with it, and disagreeing about that is a licensing defect rather than a bug. §1 THE ORIGIN AXIS IS CONTAINED, NOT GENERALISED. - `gcc@system` / `llvm@system` are refused where they are read, naming both things the user might have meant. They used to parse and then fail elsewhere as `xim:gcc@system` → "no such package", sending the reader after a version that was never going to exist. `msvc@system` is a concession to one platform, not a capability the other families lack; the family-less `system` escape hatch is untouched. - `resolve_managed_msvc()` replaces two hand-written copies of "where does a managed toolset live, and why is the fetcher's `root` wrong for it" — the reason existed in only one of them. - `needs_linux_sysroot_payloads()` replaces two spellings of one rule whose comment claimed they mirrored each other. They did not: the PE term was missing from one. Unreachable today, which is how it survived. - The toolchain resolution order was documented twice, as "3 steps" and "4 steps", naming five of the nine inputs and disagreeing about two. One table now, keyed to `TcOrigin` enumerators so it cannot quietly stop matching. `dist::Format` is derived from the target triple before falling back to the host, which only ADDS answers — and makes a Windows contract assertable on the Linux runner that reviews most of this. Tests: 22 new. The SDK-override criterion is the design doc's §6 acceptance test as a unit test (point WindowsSdkDir elsewhere; the payload SDK must still win, and the note must say the variable was ignored); the deploy tests assert reachability twice over, since a copy edge nothing asks for never runs under explicit ninja goals.
1 parent d44bb53 commit a557310

17 files changed

Lines changed: 1204 additions & 107 deletions

src/build/distribution.cppm

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,16 @@ struct Mechanism {
268268
// global constructor that touches std::cout runs before the streams
269269
// exist. Asks the backend for the ordering shim. See issue #336.
270270
bool streamInitShim = false;
271+
// PE + MSVC runtime + toolchain-coupled: the toolset's own redistributable
272+
// CRT DLLs must be STAGED BESIDE the artifact.
273+
//
274+
// On ELF, `toolchain-coupled` needs no files copied — the artifact carries
275+
// an rpath into the toolchain's lib directory and the loader follows it.
276+
// PE has no rpath: a DLL is resolved from the directory of the executable
277+
// (and then PATH), so on this format the mechanism IS the copy. Same
278+
// contract, same meaning, different mechanism — which is exactly the split
279+
// this module's three layers exist to express.
280+
bool deployToolchainRuntime = false;
271281
};
272282

273283
namespace detail {
@@ -426,12 +436,43 @@ Mechanism resolve(const MechanismInput& in) {
426436
"it everywhere; using host-coupled here"
427437
: "";
428438
} else if (in.requested == Contract::ToolchainCoupled) {
429-
// Only reachable from an explicit request: it is never a default.
430-
m.degraded = true;
431-
m.diagnostic = std::format(
432-
"cxx_runtime = \"toolchain-coupled\" has no meaning for the "
433-
"MSVC runtime (it ships with the OS/redistributable, not with "
434-
"the toolchain); using {}", to_string(m.effective));
439+
// THIS USED TO BE A FLAT REFUSAL, and the sentence it refused
440+
// with was half true:
441+
//
442+
// "…has no meaning for the MSVC runtime (it ships with the
443+
// OS/redistributable, not with the toolchain)"
444+
//
445+
// True of `ucrtbase.dll`, which IS an OS component since
446+
// Win10. NOT true of `vcruntime140.dll` / `msvcp140.dll`,
447+
// which are the toolset's own and sit inside every MSVC
448+
// toolset ever shipped:
449+
//
450+
// VC\Redist\MSVC\<ver>\<arch>\Microsoft.VC<N>.CRT\*.dll
451+
//
452+
// That is the same relationship gcc has to libstdc++.so, so it
453+
// takes the same contract — and refusing it left a hole in the
454+
// matrix that had a real cost: the default `/MD` artifact
455+
// depends on DLLs a machine with only a managed toolset does
456+
// not have, and there was no spelling that made them travel.
457+
//
458+
// `/MT` is the one case that stays a degradation, and it is a
459+
// genuine contradiction rather than a missing mechanism: a
460+
// static CRT leaves NO DLL to couple to. Say which one won.
461+
if (in.msvcStaticCrt) {
462+
m.effective = Contract::SelfContained;
463+
m.degraded = true;
464+
m.diagnostic =
465+
"cxx_runtime = \"toolchain-coupled\" cannot apply to a "
466+
"project compiled with the static CRT (/MT): there is "
467+
"no vcruntime140.dll/msvcp140.dll dependency left to "
468+
"couple to. Drop linkage = \"static\" (or the "
469+
"project-wide self-contained contract) if the toolset's "
470+
"CRT should travel beside the artifact instead; using "
471+
"self-contained";
472+
} else {
473+
m.effective = Contract::ToolchainCoupled;
474+
m.deployToolchainRuntime = true;
475+
}
435476
}
436477
return m;
437478
}

src/build/flags.cppm

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ struct CompileFlags {
7777
// macOS + self-contained: link units need the initializer-ordering shim
7878
// object prepended to their inputs (issue #336).
7979
bool needsStreamInitShim = false;
80+
// PE + `toolchain-coupled`: the toolset's own CRT DLLs, to be staged
81+
// beside the artifact. Resolved HERE rather than in the emitter because
82+
// "which files does this contract imply" is a contract question; the
83+
// backend only knows how to spell a copy edge.
84+
//
85+
// A whole-BUILD list, not a per-role one, and that is a property of the
86+
// format rather than a simplification: a PE artifact resolves a DLL from
87+
// its own directory, so one directory holds one answer and two roles in
88+
// one output tree cannot disagree about it. Any built role asking for the
89+
// contract is enough to populate it.
90+
//
91+
// The DIRECTORY comes from `msvc::vc_redist_dir()` via
92+
// `Toolchain::linkRuntimeDirs`, which is what keeps `debug_nonredist\`
93+
// (vcruntime140d.dll & friends — NOT redistributable) out of the list. The
94+
// criterion lives in exactly one place on purpose: a second name-shaped
95+
// rule here could disagree with it, and a copy step that disagrees about
96+
// what may be redistributed is a licensing defect, not a bug.
97+
//
98+
// Already deduped against the plan's own deploy files, so the emitter can
99+
// append without deciding anything: a name the manifest already claims
100+
// stays the manifest's and the conflict is reported through `diagnostics`.
101+
std::vector<BuildPlan::DeployFile> toolchainRuntimeDeploy;
80102
// Non-empty when a requested contract could not be honored. The caller
81103
// MUST surface these — a silent downgrade is the failure mode this whole
82104
// model exists to prevent. Emitted once by the backend, not here, because
@@ -762,6 +784,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
762784
// produces a PE and must take the PE answer.
763785
const dist::Format format = [&] {
764786
if (isMingwTc) return dist::Format::Pe;
787+
// The TARGET's own word, when it has one. `isMingwTc` was the only
788+
// cross case this knew about, so every other question about the
789+
// output format was answered by asking the HOST — which is right
790+
// whenever they agree and unaskable in a test that does not run on
791+
// the platform it is about. A triple that names its OS is a fact;
792+
// the host is a stand-in for one.
793+
//
794+
// Only ADDS answers: a triple that says neither falls through to
795+
// exactly the previous derivation, so no existing build changes.
796+
const auto& t = plan.toolchain.targetTriple;
797+
if (t.find("windows") != std::string::npos
798+
|| t.find("mingw") != std::string::npos)
799+
return dist::Format::Pe;
800+
if (t.find("apple") != std::string::npos
801+
|| t.find("darwin") != std::string::npos)
802+
return dist::Format::MachO;
765803
if constexpr (mcpp::platform::needs_explicit_libcxx)
766804
return dist::Format::MachO;
767805
else if constexpr (mcpp::platform::is_windows)
@@ -896,6 +934,8 @@ CompileFlags compute_flags(const BuildPlan& plan) {
896934
});
897935
};
898936

937+
bool wantsToolchainRuntime = false;
938+
899939
for (auto [role, requested, wasAsked] : {
900940
std::tuple{dist::Role::Distributable, base, explicitBase},
901941
std::tuple{dist::Role::Test, testsContract, explicitTests},
@@ -910,10 +950,65 @@ CompileFlags compute_flags(const BuildPlan& plan) {
910950
f.ldStdlibCByRole[i] = r.unitFlagsC;
911951
f.contractByRole[i] = r.effective;
912952
if (r.streamInitShim) f.needsStreamInitShim = true;
953+
// Only a role this build actually HAS may pull DLLs into the
954+
// output tree. The contract is resolved for every role because
955+
// `ldStdlibByRole` must be total; staging files is a side effect
956+
// on disk, and a project with no test binaries should not get a
957+
// CRT copied beside nothing.
958+
if (r.deployToolchainRuntime && role_is_built(role))
959+
wantsToolchainRuntime = true;
913960
if (!r.diagnostic.empty() && role_is_built(role))
914961
f.diagnostics.push_back(std::format(
915962
"{} target: {}", dist::to_string(role), r.diagnostic));
916963
}
964+
if (wantsToolchainRuntime) {
965+
// `linkRuntimeDirs` is the toolset's own redistributable CRT
966+
// directory and nothing else on this toolchain — `enrich_toolchain
967+
// _from_cl` puts exactly `vc_redist_dir()` there. Guarded on the
968+
// compiler anyway: the field means "the toolchain's private
969+
// runtime" for every provider, and on gcc it holds libstdc++'s
970+
// directory, which has no business being copied into a PE tree.
971+
if (plan.toolchain.compiler == mcpp::toolchain::CompilerId::MSVC) {
972+
std::vector<std::filesystem::path> sources;
973+
std::error_code ec;
974+
for (auto const& dir : plan.toolchain.linkRuntimeDirs) {
975+
for (auto const& e :
976+
std::filesystem::directory_iterator(dir, ec)) {
977+
if (!e.is_regular_file(ec)) continue;
978+
auto ext = e.path().extension().string();
979+
std::ranges::transform(ext, ext.begin(),
980+
[](unsigned char c) { return std::tolower(c); });
981+
if (ext != ".dll") continue;
982+
sources.push_back(e.path());
983+
}
984+
}
985+
// Directory order is not a stable input: this list reaches
986+
// build.ninja, and a graph that differs between two runs of
987+
// the same build re-runs edges for no reason.
988+
std::ranges::sort(sources);
989+
for (auto const& src : sources) {
990+
auto dest = std::filesystem::path("bin") / src.filename();
991+
// An explicit `[runtime] deploy_files` naming the same DLL
992+
// WINS, and says so. A human wrote that one down; this list
993+
// is derived. Silently overwriting a vendored redist with
994+
// the toolset's copy is a different program than the one
995+
// the manifest describes.
996+
auto clash = std::ranges::find_if(plan.runtimeDeployFiles,
997+
[&](auto const& d) { return d.dest == dest; });
998+
if (clash != plan.runtimeDeployFiles.end()) {
999+
if (clash->source.lexically_normal()
1000+
!= src.lexically_normal())
1001+
f.diagnostics.push_back(std::format(
1002+
"toolchain-coupled would stage '{}' beside the "
1003+
"artifact, but this project already deploys "
1004+
"'{}' there; keeping the project's file",
1005+
src.string(), clash->source.string()));
1006+
continue;
1007+
}
1008+
f.toolchainRuntimeDeploy.push_back({src, dest});
1009+
}
1010+
}
1011+
}
9171012
// Two roles usually share a contract, so they usually share a
9181013
// complaint; report each distinct one once.
9191014
std::ranges::sort(f.diagnostics);

src/build/ninja_backend.cppm

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,18 @@ std::string emit_ninja_string(const BuildPlan& plan) {
449449
// All compile/link flags are computed once via flags.cppm.
450450
auto flags = compute_flags(plan);
451451

452+
// Everything that has to sit beside the artifact, from both producers:
453+
// the manifest's `[runtime] deploy_files` (already in the plan) and the
454+
// C++ runtime contract's own answer (`toolchain-coupled` on PE — see
455+
// mcpp.build.distribution). Merged ONCE, here, because three places below
456+
// consume the list — the implicit dependency of each executable, the copy
457+
// edges, and `default` — and a list that is complete in two of them is a
458+
// graph where the DLL is copied only when something else happens to ask.
459+
auto deployFiles = plan.runtimeDeployFiles;
460+
deployFiles.insert(deployFiles.end(),
461+
flags.toolchainRuntimeDeploy.begin(),
462+
flags.toolchainRuntimeDeploy.end());
463+
452464
bool need_c_rule = false, need_asm_rule = false, need_nasm_rule = false;
453465
for (auto& cu : plan.compileUnits) {
454466
if (is_c_source(cu)) need_c_rule = true;
@@ -1770,7 +1782,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
17701782
// beside the .exe before the build is considered done. Empty on RPATH
17711783
// platforms (no *.dll deps), so other targets are unaffected.
17721784
if (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) {
1773-
for (auto const& d : plan.runtimeDeployFiles)
1785+
for (auto const& d : deployFiles)
17741786
implicit += " " + escape_ninja_path(d.dest);
17751787
}
17761788

@@ -1826,13 +1838,13 @@ std::string emit_ninja_string(const BuildPlan& plan) {
18261838
// — which also means a DLL still loaded by a running program from a
18271839
// previous `mcpp run` gets the skip-if-equivalent treatment instead of a
18281840
// hard "cannot copy" failure.
1829-
// Inert on RPATH platforms where runtimeDeployFiles is empty.
1830-
for (auto const& d : plan.runtimeDeployFiles) {
1841+
// Inert on RPATH platforms where the merged deploy list is empty.
1842+
for (auto const& d : deployFiles) {
18311843
append(std::format("build {} : stage_file {}\n",
18321844
escape_ninja_path(d.dest),
18331845
escape_ninja_path(d.source)));
18341846
}
1835-
if (!plan.runtimeDeployFiles.empty())
1847+
if (!deployFiles.empty())
18361848
append("\n");
18371849

18381850
// ── Declared build-graph nodes (`mcpp:action=`) ─────────────────────────
@@ -1899,7 +1911,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
18991911
defaults += " " + escape_ninja_path(alias);
19001912
}
19011913
}
1902-
for (auto const& d : plan.runtimeDeployFiles) {
1914+
for (auto const& d : deployFiles) {
19031915
defaults += " " + escape_ninja_path(d.dest);
19041916
}
19051917
defaults += actionDefaults;

0 commit comments

Comments
 (0)