feat(toolchain): build Linux binaries on Windows (x86_64-linux-musl canadian cross) - #339
Merged
Merged
Conversation
…t one Adds the regression assertions for a bug that exists on HEAD today: mcpp decides whether to emit `-static` from `mcpp::platform::supports_full_static`, a host constant defined as `is_linux`. On a Linux host that coincides with the right answer for every Linux target, so nothing ever caught it — it only bites once a non-Linux host cross-compiles to Linux, where `-static` silently vanishes and the musl targets lose the very property they exist for. `target_supports_full_static` takes the host capability as an explicit parameter so the decision is pinnable from any host: passing false models a Windows/macOS host. The implementation added here is a deliberate STUB reproducing the old behaviour, so these tests go RED now and the next commit turns them green — proving the assertions actually discriminate rather than passing vacuously. Verified red: [ FAILED ] TargetSupportsFullStatic.LinuxTargetFromNonLinuxHost [ FAILED ] TargetSupportsFullStatic.MacosTargetNeverStatic [ FAILED ] TargetSupportsFullStatic.PeTargetsDeferToContractTable 3 FAILED TESTS Refs .agents/docs/2026-08-03-windows-host-linux-cross-design.md §1.3 (B2)
`supports_full_static` is a host constant (`is_linux`) describing what THIS
machine's own binaries can be. flags.cppm read it to decide whether the
ARTIFACT gets `-static` — a different question with a different answer the
moment host != target.
On a Linux host the two coincide for every Linux target, which is why this
survived: it is invisible until a non-Linux host cross-compiles to Linux.
There, `-static` was silently dropped and `x86_64-linux-musl` — a target whose
entire reason to exist is a portable, fully static ELF — produced something
else. No error, no warning; the flag just was not there.
The predicate now reads the parsed triple:
- empty triple → host target, so the host capability IS the answer
- PE targets → false; their `-static` comes from the C++ runtime
distribution contract (dist::Format::Pe), and answering
true here would make both mechanisms emit it
- macOS → false; libSystem must stay dynamic
- linux → true, glibc or musl, native or cross
- unparseable → fall back to the host answer rather than guess
Every path that works today is bit-for-bit unchanged: on a Linux host every
branch returns exactly what the old constant returned. Only the previously
broken host!=target case changes.
Turns the previous commit's assertions green:
[==========] 6 tests from 1 test suite ran.
[ PASSED ] 6 tests.
Refs .agents/docs/2026-08-03-windows-host-linux-cross-design.md §1.3 (B2)
…/target gate
Two changes to one code region, which is why they share a commit — splitting
them would leave a middle state that does not compile.
B1 — the frontend was unfindable on a Windows host.
musl payload candidates were { "<triple>-g++", "g++" }, resolved with
filesystem::exists. On Windows the file is `<triple>-g++.exe`, so exists()
said no and a payload that installed perfectly was then unusable. The mingw
branch has carried the .exe spelling since it shipped; this branch never did,
because nothing had ever installed a musl payload on a Windows host. Same
omission in archive_tool's musl branch (`<triple>-ar`), fixed alongside.
.exe is listed first: on a case-insensitive filesystem both spellings match
and the executable is the one we want.
host_can_serve — one derivation instead of two.
"Can this host serve that target" was computed independently in two places:
registry.cppm picking the xim payload, and lifecycle.cppm deciding whether
`toolchain list` may show a target as available. They had already drifted —
the payload side would resolve a windows-hosted musl package that the
availability side declared impossible. The predicate now lives once, in
registry.cppm next to the payload resolution it has to agree with, and
lifecycle calls it.
It also gains the case this series exists for: a non-Linux host may serve a
Linux target when the payload is self-contained (musl) and built for that
host's own arch. Deliberately narrow — the canadian-cross payload is built
per host arch, and macOS has no Linux-targeting payload at all, so both stay
unserviceable rather than failing at install time.
available_toolchain_indexes now lists the windows-hosted cross under the same
name to_xim_package() derives (`<triple>-gcc`), so the Available listing and
the install path cannot disagree.
Refs .agents/docs/2026-08-03-windows-host-linux-cross-design.md §1.2, §1.3 (B1)
… docs doctor gains the mirror of its existing mingw probe: on a Windows host it now also reports whether the linux-musl cross payload is installed. Same shape, same optional-not-an-error wording, and it derives the package name the same way to_xim_package() does (`<triple>-gcc`) so the two cannot disagree. docs/03-toolchains.md gains the section this feature is for, written as the mirror of "Windows PE via MinGW-w64": the command is spelled identically on both hosts because cross is not a name in mcpp, just host != target. It also states the two limits explicitly rather than leaving users to discover them — linux-gnu from Windows is unsupported (glibc needs the sysroot payloads, which are Linux-only), and cross-arch from Windows is unsupported (the canadian-cross payload is built per host arch).
Adds the mirror of the mingw-cross-wine row. It needs two jobs, not one,
because a Windows runner cannot execute the ELF it just produced and there is
no wine-equivalent in that direction: build on windows-latest, upload the
artefact, download it on ubuntu and run it there.
Static assertions alone were not an option. "It linked" has never implied
"it runs" in this repo — the elfpatch incident is the standing reminder — so
the consumer job executes `--version` and `--help` for real. The artefact is a
fully static musl ELF with no PT_INTERP, so that job needs neither qemu nor a
matching loader.
The B2 gate lives here: before the fix a Windows host emitted a NON-static
binary, so `file | grep -q "statically linked"` plus a "no INTERP segment"
check on readelf is what would have caught it. Both are written as positive
greps on purpose — `! cmd | grep` is exempt from errexit and can never fail.
The build job also asserts that `toolchain list` shows the linux-musl row on a
Windows host, which is the host gate from §1.2 having actually lifted rather
than being eyeballed.
The design doc is updated with everything the P0 spike turned up, including
four things that cost real time and would cost it again:
- -Os implies -fdeclone-ctor-dtor → C4 ctor symbols the mingw libstdc++ does
not define; ELF hides this via symbol aliases, COFF cannot
- --disable-libstdcxx-pch, matching how the shipped musl-gcc payload is built
- do NOT rebuild target libstdc++ — reuse the same-version payload's copy;
four consecutive failures there were all self-inflicted
- -print-sysroot answers <prefix>/ but C++ headers still live under
<prefix>/<triple>/include/c++/<ver>
- libwinpthread-1.dll must land in EVERY directory holding a .exe, including
x86_64-linux-musl/bin/ (as.exe) — the easy one to miss
All six P0 criteria are recorded green, verified locally through wine.
Bumps the BUILDING pair only (mcpp.toml + fingerprint.cppm). The bootstrap pin in .xlings.json stays at 2026.8.3.1 on purpose — it is the self-hosting starting point, not a mirror of the version being built. Bumping it in the same commit would point every CI job at a release that does not exist yet.
The cross-build itself succeeded on the first real Windows run — 'Finished release [optimized] in 119.09s', resolving through x86_64-linux-musl-g++.exe, which is B1's fix working. The job still failed, for two reasons worth separating: 1. target/ holds the host build from the previous step alongside the cross build, and both are named mcpp*. The search now starts at target/x86_64-linux-musl/ so it cannot pick up the wrong one. 2. The artefact is named mcpp.exe despite being an ELF. plan.cppm's target_output() spells the suffix from mcpp::platform::exe_suffix — a HOST constant — so this is the same host-decides-target confusion as B2, and it is symmetric: a Linux→Windows cross produces a PE with no .exe today. B3 is filed in the design doc (§6.5) rather than fixed here. Renaming build outputs is a behaviour change that reaches the mingw e2e, the release packaging paths and any user script, and neither direction is actually broken today — folding a regression-prone rename into this PR would defeat the layered-commit discipline the series was built around. The job matches both spellings so it is correct before and after that fix. Also prints the tree on failure, so the next person does not have to fetch the job log to find out what was actually produced.
B1 changed the musl frontend candidate list on Windows hosts (.exe first), and these two assertions pinned the bare spelling as front(). They pass on Linux either way, which is exactly why the break only showed up in Windows CI — the same blind spot that let B1 itself survive. Adds expected_musl_frontend() next to the existing expected_musl_xim() helper, mirroring how the mingw test already handles the host split. The helper states why .exe comes first rather than leaving a bare constant.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last empty quadrant of
host ≠ target: a Windows machine producing a fullystatic Linux ELF, with no WSL, no container, and nothing installed system-wide.
mcpp build --target x86_64-linux-musl # spelled identically on Windows and LinuxDesign:
.agents/docs/2026-08-03-windows-host-linux-cross-design.mdTwo real bugs that already existed on HEAD
Neither is new to this feature — both were sitting in
main, invisible because nonon-Linux host had ever cross-compiled to Linux.
B2 —
-staticwas decided by a HOST constant.supports_full_staticisis_linux, describing what this machine's binaries can be;flags.cppmused it todecide what the artifact gets. On a Linux host the two coincide for every Linux
target, so it never surfaced. From a Windows host
-staticwas silently dropped andx86_64-linux-musl— a target that exists precisely to yield a portable static ELF —quietly produced something else.
B1 — the frontend was unfindable on Windows. Candidates were
{ "<triple>-g++", "g++" }resolved viafilesystem::exists, but the file is<triple>-g++.exe. The payload would install perfectly and then be unusable. Sameomission in
archive_tool's musl branch.Commit shape
Deliberate, and the first two are the point:
host_can_serveCommit 1 exists so "the test actually discriminates" is a checkable fact in history
rather than a claim. Verified red:
then green at commit 2 (6/6). Full suite: 52 passed, 0 failed.
One derivation instead of two
"Can this host serve that target" was computed independently in
registry.cppm(payload selection) and
lifecycle.cppm(thetoolchain listavailability column),and the two had already drifted — the payload side would resolve a windows-hosted
musl package that the availability side declared impossible. Now one predicate,
host_can_serve, living next to the payload resolution it must agree with.Verification
CI gains two jobs, because a Windows runner cannot execute the ELF it just produced and
there is no wine-equivalent in that direction: build on
windows-latest, upload, thenreally run it on ubuntu.
file | grep -q "statically linked"plus a no-PT_INTERPcheck is the B2 regression gate. Both written as positive greps —
! cmd | grepisexempt from errexit and can never fail.
The toolchain itself (P0) was verified locally through wine, all six criteria green:
Scope
Not included, deliberately: the clang cross axis (a different axis — mcpp never passes
-targetto a compiler today),linux-gnufrom Windows (glibc needs the Linux-onlysysroot payloads), and cross-arch from Windows (the payload is built per host arch).
Windows first-run defaults are untouched; this is entirely opt-in.
B3 — found here, deliberately not fixed here
The first real Windows cross-build surfaced a third instance of the same pattern:
plan.cppm'starget_output()names build artifacts frommcpp::platform::{exe_suffix,lib_prefix,static_lib_ext,shared_lib_ext}— all hostconstants. So it is wrong symmetrically:
mcpp.exe(an ELF)mcppmcpp(a PE)mcpp.exeNeither direction is actually broken — the suffix is meaningless on Linux, and Windows
runs a suffix-less PE fine from a shell — but renaming build outputs reaches the mingw
e2e, the release packaging paths and any user script. Folding a regression-prone rename
into this PR would defeat the layered-commit discipline the series is built around, so
it is written up in the design doc (§6.5) with the
runtime_aliases_for_target()coupling flagged, and the CI job matches both spellings so it stays correct across that
future fix.
That makes three occurrences of "a
platform::constant decides a property of theartifact". Worth treating as a review smell.