feat(wasm): wasm32 + JS multi-target cross-compiler via ghcup (Phases 0–7 + MULTI)#181
feat(wasm): wasm32 + JS multi-target cross-compiler via ghcup (Phases 0–7 + MULTI)#181angerman wants to merge 22 commits into
Conversation
stable-haskell/cabal#359 (R8 patches) and #181 (Phase 0-2 branch) are open. Phase 3 ghcup-compatible bindist achieved locally — tar czhf + mk/wasm-relocate.sh. Verified self-contained tarball extracts and works from a fresh prefix.
…ains Three commits to PR #181 extend stable-haskell's --enable-dynamic rearchitecture symmetrically to stage3 cross targets: cd815eb build: extend --enable-dynamic to stage3 cross targets 534c6af rts: add missing wasm32 exclusion to promoteBootLibrariesToGlobal call 7375275 compiler: disable overzealous wasm makeDynFlagsConsistent rule Result: stage3-wasm32-unknown-wasi DYNAMIC=1 produces 2121 .dyn_hi + 40 .so. End-user cabal build with shared:True now compiles miso through 67/70 modules. NEW blocker for Phase 6.5: TH evaluation via wasm iserv (node + dyld.mjs) hangs at 0% CPU on IPC pipe — fragile area, needs debugging instrumentation.
Records the end-to-end Phase 4 verification: - GitHub release wasm32-wasi-9.14.0.stable.0 (pre-release) with the 216MB bindist + autoconf-shaped install stubs. - gh-pages branch publishes the ghcup-wasm.yaml custom channel at https://stable-haskell.github.io/ghc/ghcup-wasm.yaml. - ghcup-metadata schema: 0.0.9 flat (custom-channel parser doesn't accept the 0.1.0 toolVersions indirection). - 'ghcup install ghc wasm32-wasi-9.14.0.stable.0' succeeds end-to-end; installed compiler builds non-TH and TH hello-worlds. Linux hosts will join once PR #181 CI verifies them.
PR #181 run 26494711606 — all 6 Build jobs PASS (aarch64-darwin, x86_64-linux, aarch64-linux × dynamic=0/1). Native stage2 bindists upload as workflow artifacts (260-303 MB per host/dynamic combo). Test + Cross jobs in flight; persistent CI monitor running locally watches for transitions and reports per-job state changes. To extend the ghcup channel with Linux wasm cross-compiler bindists, follow-up work needs to wire make stage3-wasm32-unknown-wasi-tarball into the Linux Build jobs and upload the wasm bindist as an artifact the same way the native ones already are. Also records the live in-browser miso demo: https://stable-haskell.github.io/ghc/demos/miso-counter/ Pre-built mountpoint variant (mountPoint = Just "miso-root") so the demo page's explanatory HTML survives miso's body diff. wasm 2.8 MB, SHA d3a4b8dbb78592761f891577774f5c714e34e624b6e7f2f22ced00d3d8a43f65.
…ble) stable.1's Linux bindists had their PT_INTERP pinned to a /nix/store glibc-derived ld-linux-* path because Cross: WASM CI runs inside devx (a nix-shell). On any non-nix Linux system the kernel ENOENT's the interpreter at load time, manifesting as 'wasm32-unknown-wasi-ghc: cannot execute: required file not found'. stable.2 adds a patchelf step to the Cross: WASM Package phase (.github/workflows/nix-ci.yml d442c26) that rewrites the interpreter to the canonical system path (/lib64/ld-linux-x86-64.so.2 on x86_64, /lib/ld-linux-aarch64.so.1 on aarch64) and drops the RPATH so shared libs resolve via system ldconfig. Verified locally: file → interpreter /lib64/ld-linux-x86-64.so.2 ✓ SHA256s of the stable.2 release assets: aarch64-darwin cfe4af3a164ec1cb84b0f7bdc79855fcc602c7bc58651176d84a88c18572a734 x86_64-linux d8643882ed983b695731e8ceb1cd3e7661e6c5d1fe1e2f78a467da370f4a513e aarch64-linux b3161f4cd45a5dea5ddbbbd79e4f9c08ccb4e8db7bc26d0dc3e11a6c4177261e LatestPrerelease tag moves stable.1 → stable.2. stable.1 retained for back-compat but its broken Linux viArch entries dropped — ghcup users on Linux now get "not available for this platform" rather than a binary that won't load. Darwin stable.1 stays (was fine). Found by the channel-e2e workflow's wasm-GHC probe step (run 26570659141 on PR #181).
…ch(wasm32) Multi-target Cross: MULTI on PR #184 (runs 26731492182 → 26735573736) hit `wasm-ld: error: unknown argument: -h` when building host:rts-fs for the javascript-unknown-ghcjs target. Root cause: DYNAMIC=1 expanded cabal.project.stage3.settings to a package-wide `shared: True` + `executable-dynamic: True`, which then flows through to the JS-side rts-fs build → emcc → wasm-ld → unknown -h. JS doesn't support shared libraries the same way wasm does (emcc/wasm-ld refuse the `-Bsymbolic -h` pattern ghc emits for shared targets). Fix: scope the settings to wasm32 only via `if arch(wasm32)`. Cabal evaluates this conditional against `--with-compiler`'s target arch per-invocation: * wasm cross compiler: arch=wasm32 → TRUE → shared applies * JS cross compiler: arch=javascript → FALSE → shared does NOT apply * native build-side (happy-lib, alex, …): arch=native → FALSE Hardcoded (no autoconf substitution); DYNAMIC=1 has no effect on stage3 anymore. cabal-install at SHA 44817477 (stable-haskell/cabal) evaluates arch() per-cabal-call so this scoping is per-target. PR #181 (wasm-only) continues to work — for wasm targets the conditional fires TRUE same as before. dynamic1 stage2 supplies native .dyn_hi for build-side packages (happy-lib etc.) so they link cleanly against vanilla native base.
The base branch (PR #181) landed the channel-e2e split too — same structural change, but with the darwin runner spec as ['macos-15'] (github-hosted) instead of the self-hosted Tart spec my version used. Take the base's version of both workflow files via 'git checkout --theirs' since the base also carries the preceding macos-15 + 'Diagnose wasm-ghc' improvements (8a2f179, 0fa8b97) that should be preserved going forward.
ee9cc12 to
b6b96f5
Compare
Lands the build pipeline for wasm32-unknown-wasi as a stage3 cross
target shipped as a relocatable ghcup-installable bindist.
* flake.nix / flake.lock — bundles wasi-sdk via ghc-wasm-meta so
the wasm cross-compile environment is reproducible end-to-end
(clang, ld.lld, llvm tools all pinned).
* Makefile — adds cross-build support (stage3-{wasm32-unknown-wasi,
javascript-unknown-ghcjs}) with dist-based configuration,
stamp-file dependency model for single-invocation builds, and
proper PHONY/order-only ordering to fix hackage race conditions.
* configure.ac — autoconf-shaped install layout for ghcup
compatibility; @ALL_PACKAGES@ / @Constraints@ accumulators for
stage{2,3} settings; --enable-dynamic toggle.
* cabal.project.stage{0,1,2,3} — wire stage1/2/3 to use the
accumulator-driven settings; stage3 imports cabal.project.common.
* mk/wasm-{configure,relocate,bindist-Makefile} — autoconf-shaped
`configure` stub, `relocate.sh` that recaches the per-target
package db and warns on missing Node.js, and a bindist install
Makefile that ghcup's installer-DSL drives via `make install`.
* build-wasm-*.sh — remote-build helpers driving `nix develop` +
git worktree for off-host cross-compile iteration.
* USAGE.md — wasi-sdk + libffi setup notes for end-users.
Five focused changes needed for the wasm32-unknown-wasi target to
link and run end-to-end:
* rts/linker/elf_got.c — handle undefined symbols referenced only
by R_*_NONE relocations. The RTS linker previously failed on
these even though they require no actual resolution.
* rts/RtsStartup.c — add the missing wasm32 exclusion to the
promoteBootLibrariesToGlobal call (mirrors the JS / Hadrian
arch guards).
* compiler/GHC/Driver/Session.hs — disable the overzealous wasm
makeDynFlagsConsistent rule that forced -dynamic on libraries
even when stage3 explicitly opted out, breaking the
static-host / shared-wasm hybrid we ship.
* compiler/GHC/Linker/Dynamic.hs — for wasm32 .so dep
construction, force rts back in even when -no-rts is set
(otherwise libHSghc-internal.so links without the rts
transitive symbols at all).
* compiler/GHC/Runtime/Interpreter/Wasm.hs — detect missing
Node.js at iserv spawn and emit a clear error message instead
of silently hanging.
* compiler/Setup.hs, libraries/ghc-boot/Setup.hs — accept
GIT_COMMIT_ID from env for hermetic git-less builds (CI
shallow-checkout case).
* utils/jsffi/dyld.mjs — one-line dlopen logging tweak.
Mirrors stage2's @ALL_PACKAGES@ accumulator template into a stage3 settings file so --enable-dynamic propagates symmetrically — but scoped via `if arch(wasm32)` in the consumer .cabal projects so that only the wasm target gets `shared: True` / `executable-dynamic: True`. The JS target and native build-side stay static (avoids emcc/wasm-ld "unknown argument: -h" when shared:True flowed into the JS build, and avoids dynamic-too codepath breaks for native build-side packages like happy-lib / alex / deriveConstants). Final approach is R7 *path-i* (wasm-only `shared: True`, dynamic0 stage2 baseline) plus *Path C* host-dylib shipping (host libHS*.so / .dylib + matching .dyn_hi shipped under lib/$HOST_PLATFORM/ so the dyn-linked wasm-ghc binary can find its host runtime via @rpath at runtime). Several iterations were necessary before this design crystallised — see lode/wasm-cross-ghcup-plan.md "R7" thread for the full post-mortem.
Renames .github/workflows/ci.yml → nix-ci.yml and restructures
the single monolithic build into a build/test/cross matrix:
* Build / <plat> / dynamic={0,1} — stage0+stage1 then stage2,
with aggressive intermediate cleanup between stages. dynamic=1
artifact is what Cross consumers download.
* Test / <plat> / dynamic={0,1} — runs the testsuite against
the stage2 bindist.
* Cross: WASM / <plat> — pulls the dynamic=1 stage2 dist, sets
up wasi-sdk via ghc-wasm-meta bootstrap, prepends WASI_BIN to
PATH, builds stage3-wasm32-unknown-wasi-tarball with DYNAMIC=1
so the bindist ships .dyn_hi + host dylibs. On Linux, patchelfs
the bindist's nix-store ELF interpreter to the standard system
path and rewrites the rpath to $ORIGIN-relative.
* Cross: JS / aarch64-darwin — same shape, emcc-driven.
On tag push matching `wasm32-wasi-*`, uploads each platform's
ghc-wasm32-unknown-wasi-<plat>.tar.gz to the matching GitHub
Release (drives the stable-haskell ghcup channel).
Sized to fit the runner constraints — see docs/ for the 41 GB
APFS / 28 GB WorkSpace split on darwin Tart and the 145 GB linux
runner budget; intermediate cleanups keep both within limits.
New workflow that builds the stable-haskell cabal-install for the
Linux variants and uploads the resulting cabal-<version>-<plat>.tar.gz
to the matching GitHub Release on cabal-* tag push. Closes the
channel gap (cabal channel YAML entries pointed at the wrong asset
names before).
* Triggers on cabal-* tag pushes and on PRs that touch this
workflow file or the r12 patch (so workflow self-tests itself
before merge).
Two separate end-to-end workflows that exercise the SHIPPED ghcup
channel YAML by following the exact flow an end-user does:
* Channel e2e (WASM) — wasm-only single-target channel
(`ghcup-wasm.yaml`). Triggers on wasm32-wasi-* tag push, on PR
edits to this file, on workflow_dispatch with a wasm_version
input, and on a weekly Monday 06:00 UTC cron canary. Installs
ghcup fresh, adds the channel, installs the wasm32-wasi-*
GHC + cabal, then builds the published `hello` and
`miso-counter` templates and verifies the artefacts.
* Channel e2e (MULTI) — multi-target tri-frontend channel
(`ghcup-multi-target-0.1.0.yaml`). Triggers on multi-* tag
push, PR edits, workflow_dispatch with a multi_version input,
weekly cron. Installs the multi-target GHC, then compiles +
runs native / wasm / JS hello-worlds to verify argv[0]
dispatch works for all three frontends.
Both workflows test on github-hosted ubuntu-latest,
ubuntu-24.04-arm, and macos-15 — the macOS leg deliberately runs
on the stock image (Xcode + CLT preinstalled, no nix) to mirror
what real end-users have. Self-hosted Tart darwin VMs stay
reserved for the nix-based in-tree builds in nix-ci.yml.
Splits avoid mixed pass/fail attribution: a WASM channel
regression no longer cascades into skipping MULTI tests, and PR
checks show separate ✓/✗ for each channel.
Workspace directory holding the initiative's living plan, phase
gates, design notes, root-cause analyses, and the cabal-install
patches that the build pipeline depends on:
* wasm-cross-ghcup-plan.md — the running plan: Phase 0 (planning)
through Phase 7 (documentation), with the R1..R12 risk log,
R7 path-i / Path C decision threads, and end-of-phase status
pins.
* phase3-relocate-sh-draft.sh — initial relocate.sh draft (the
final lands in mk/wasm-relocate.sh).
* phase6-trivial-reactor-poc/ — proof of concept reactor app
that drove discovery of the JSFFI invocation pattern.
* phase6-miso-template-draft/ — full miso template (counter +
REVIEW.md) that became the basis for the published miso-counter
example.
* r8-cabal-ghcjs-removal.patch — patch against
stable-haskell/cabal removing dead GHCJS references in
ProjectPlanning.hs + binDirectoryFor that prevented stage1
from building.
* r12-cabal-target-prefix-aware-tool-guess.patch — patch fixing
cabal-install's dual-compiler tool lookup
(guessGhcPkgFromGhcPath) so it doesn't fail on a wasm
cross-compiler bindist that ships ghc-pkg at a non-default
prefix.
Strip the two classes of build-host leak from every Mach-O artefact
in the stage2 dist tree BEFORE tarball assembly, so the bindist
ships clean without needing a post-build install_name_tool pass in
CI:
(1) Absolute LC_RPATH entries pointing at the build store
(`/Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/...`).
The bundled Cabal's depLibraryPaths bakes these into the
link line. macOS 14 dyld silently falls through to the
portable @executable_path/../lib/<host> rpath SET_RPATH
adds; macOS 15 dyld treats the unresolvable absolute path
as fatal and SIGABRTs at launch.
(2) nix-store LC_LOAD_DYLIB install names for libiconv, libffi,
libc++, libz, libresolv, libncurses. The devx-provided
build runner has these visible at link time, but the
install names baked into the linked binary point at
/nix/store paths that don't exist on end-user hosts.
Rewrite each to its /usr/lib equivalent (Apple stub-cache,
ABI-compatible).
Mutating a Mach-O invalidates its linker signature; re-sign
ad-hoc afterwards so dyld accepts the binary on Apple Silicon.
Implementation lives in mk/clean-darwin-macho.sh rather than a
Makefile `define`/`$(if ...)` macro: the case-statement body
contains `)` characters that Make's $(if X,Y) parser interprets
as function-argument boundaries, expanding the body even on
non-Darwin hosts and tripping bash on the leaked closing parens.
A standalone script with its own `[ "$(uname -s)" = "Darwin" ]`
guard sidesteps the parser dance entirely.
Critical sequencing detail: the cleanup runs BEFORE the
ghc-pkg recache step further down in stage2.dist, otherwise
recache itself abort-traps on macOS 15 (the binary it invokes
has the same leak it's supposed to fix).
Pattern adapted from input-output-hk/devx static.nix
(fixup-nix-deps), SHA 5f05c1e1af6. Obsoletes the per-bindist
install_name_tool step in nix-ci.yml's Cross: MULTI darwin
path; that step can degrade to a verification-only scan.
Bumps cabal.project.stage{0,1,2,3} to stable-haskell/Cabal
6a5ce8161ca76356a9ea43f2e9e09483e6f5849d (PR #368,
feat/rpath-relativize-absolute branch). Patches Cabal's Link.hs to
unconditionally relativize absolute rpaths via shortRelativePath
against the artifact's bindir/libdir, fixing the darwin LC_RPATH leak
without flipping cabal's relocatable: True flag (which also emits
library-dirs: ${pkgroot}/... entries that our post-stage2 bindist
path rewriting can't cope with).
All four stages share the same Cabal — the rpath patch only touches
host-linker codepaths (wasm-ld/JS don't use rpaths), so it's a no-op
for stage3's cross outputs but unifying avoids carrying two Cabals.
See lode/rpath-leak-investigation.md (added later in this series).
…TAGE3@
Adds a parallel pair of autoconf accumulators (ALL_PACKAGES_STAGE3 /
CONSTRAINTS_STAGE3) to configure.ac, indented one extra level beyond
the stage2 versions so the substituted block can nest INSIDE an
`if arch(wasm32)` conditional in cabal.project.stage3.settings.in.
Rationale: stage3 builds three targets from one project file —
wasm32-unknown-wasi (--with-compiler=wasm32-...-ghc),
javascript-unknown-ghcjs (--with-compiler=javascript-...-ghc), and
native build-side packages (--with-build-compiler=ghc). Only wasm32
needs `shared: True` / `executable-dynamic: True` (Path C: ship
.dyn_hi + .so so end-user TH packages like miso/jsaddle build). For
JS, `shared: True` flows into emcc/wasm-ld which can't produce .so
("wasm-ld: error: unknown argument: -h"); for the native build-side
deps, no shared libs are needed at all.
`if arch(wasm32)` is cabal's per-invocation conditional — it
evaluates against the active --with-compiler's target arch, so a
single project file produces the right thing for all three
sub-builds.
JS-side .dyn_hi shipping is a separate concern, addressed by the
per-target settings dial (next commit, GHC issue #67).
Lets a single stage2 GHC binary report different `GHC Dynamic` /
`GHC Profiled` / `Support dynamic-too` values depending on which
target it's invoked as (via argv[0] dispatch into
lib/targets/<triple>/lib/settings).
Adds four new fields to PlatformMisc, threaded through Settings
and read in Settings.IO from the target's settings file:
* target is dynamic — GHC capable of -dynamic /
-dynamic-too output
* target ships dynamic libraries — lib tree has .dyn_hi / .so
* target is profiled — GHC capable of -prof output
* target ships profiling libraries — lib tree has .p_hi / .p_a
Reported pairs in `ghc --info` (Driver/Session.hs):
GHC Dynamic = (target is dynamic) && (target ships dynamic libs)
GHC Profiled = (target is profiled) && (target ships prof libs)
Support dynamic-too = (target is dynamic) && (target ships dynamic libs)
These two-dial-per-way splits keep "capable" and "currently shipping"
orthogonal — a target can be dynamic-capable without actively
shipping .dyn_hi (or vice versa). cabal-install reads these to decide
whether to enable library-dynamic / library-profiling by default, so
JS (which doesn't ship .dyn_hi in this series) can correctly report
all-NO while the wasm target reports YES for both dynamic dials.
Settings.IO falls back to sane defaults (`YES` for dynamic if the RTS
itself is dynamic, `NO` for prof) when the keys are missing, so this
commit is no-op until the Makefile injects the new keys.
Refs GHC issue #67.
Two concerns in the build-system layer:
1. Inject per-target dial keys into lib/settings files:
* Native settings (HOST_PLATFORM/lib/settings): four dials
reflecting current DYNAMIC=0/1 invocation; prof=NO (stage2
isn't built -prof).
* Stage3 cross-target settings (TARGET_DIR/lib/settings) via
defaults YES/YES/NO/NO, overridable per triple via
STAGE3_<triple>_TARGET_{IS_DYNAMIC,SHIPS_DYN_LIBS,
IS_PROFILED,SHIPS_PROF_LIBS}.
* JS target overrides all four to NO (no .dyn_hi, no .p_hi).
sed-end-of-line anchor: needs `$$$$` (four dollars) to survive
define-template + recipe-time Make expansion — verified vs.
`$$` which gets eaten and produces invalid settings files.
2. New $(DIST_DIR)/ghc-multi-target.tar.gz rule:
Packages native (lib/$(HOST_PLATFORM)) + wasm32-unknown-wasi +
javascript-unknown-ghcjs into a single bindist consumed via
argv[0] dispatch (bin/ghc -> native, bin/wasm32-...-ghc -> wasm,
bin/javascript-...-ghc -> JS — same physical binary, three
targets).
Uses `tar czhf` (dereference symlinks) so the cross-prefixed
bin entries become standalone copies — ~30 MB cost in exchange
for predictable behaviour with ghcup's targetPattern glob.
Filters ghc-iserv out of the JS bin list (JS backend has its
own evaluator).
New mk/multi-target-{configure,relocate,bindist-Makefile}
scripts ride along in the tarball for end-user install.
Adds a new Cross: MULTI job to nix-ci.yml that builds the ghc-multi-target.tar.gz bindist on aarch64-darwin (alongside the existing Cross: WASM and Cross: JS jobs). Runs `make stage3-wasm32-unknown-wasi stage3-javascript-unknown-ghcjs` followed by `make _build/dist/ghc-multi-target.tar.gz`, then uploads the multi-target tarball as a workflow artifact for downstream channel-e2e validation and ghcup release pickup. WASM-specific Cross job kept for backward compat while we trial the unified MULTI flow.
Channel end-to-end test gets a heavier exercise: after ghcup install of the multi-target compiler, build the full miso-counter app (50+ deps incl. aeson, jsaddle-wasm, TH-heavy packages) for the wasm target, exercising the Path C .dyn_hi shipping for end-user TH compilation. Also retires the legacy channel-e2e-wasm.yml workflow: that one tested the wasm-only ghcup-wasm.yaml channel (now deprecated in favour of ghcup-multi-target-0.1.0.yaml). Its miso coverage is subsumed by the new step here, and we no longer want to gate on the legacy channel.
Three new lode docs covering the design + investigations behind
this series:
* lode/multi-target-bindist-design.md
Design doc for the argv[0]-dispatched single-binary
multi-target bindist (native + wasm32 + JS in one tarball),
consumed via ghcup-multi-target-0.1.0.yaml channel.
* lode/rpath-leak-investigation.md
Investigation log for the darwin LC_RPATH leak that motivated
the Cabal PR #368 rpath-relativize-absolute patch (commit 1).
Documents why we didn't flip cabal's relocatable: True flag
(it also emits library-dirs: ${pkgroot}/... that breaks our
post-stage2 path rewriting).
* lode/draft-ghcup-multi-target-0.1.0.yaml
Draft ghcup channel YAML for the new multi-target format —
successor to ghcup-wasm.yaml. Lives in lode/ until promoted
to gh-pages once Cross: MULTI is green on all platforms.
The stat-layout probe block in libraries/ghc-internal/configure.ac
was guarded by an exact-string compare:
if test "$host" = "javascript-ghcjs"
But the multi-target JS triple is `javascript-unknown-ghcjs`, so the
block was silently skipped, leaving SIZEOF_STRUCT_STAT /
OFFSET_STAT_ST_* as #undef. The JS shim then expanded
h$base_sizeof_stat() to a bare identifier, producing:
ReferenceError: SIZEOF_STRUCT_STAT is not defined
at TH-evaluation time for any package using Posix stat (e.g. miso /
jsaddle).
Asymmetry that pinned root cause: the non-guarded HTYPE_* probes
above the same file were defined correctly; only the guarded block
went missing. emcc confirms sizeof(struct stat)=96 — the probes are
viable, they just weren't being run.
Fix: replace the string compare with a case glob `javascript*)` so
both legacy (javascript-ghcjs) and multi-target
(javascript-unknown-ghcjs) hosts trigger the block.
Drops the local r8/r12 .patch files from lode/ in favour of pinning
a stable-haskell/cabal branch that already carries both fixes:
stable-haskell/cabal:stable-haskell/feature/wasm-cross-ghcup-stack
= 8b8433b736d45ec53a103baf4e4aabb8010ca2ed
6a5ce8161 #368 rpath relativize (was already pinned)
8b8433b73 #361 target-prefix-aware tool guess (cherry-picked,
was previously applied as
lode/r12-cabal-target-prefix-aware-tool-guess.patch)
r8 (GHCJS removal) was already absorbed by upstream master cleanup
before the #368 base, so it's not part of the stack.
Effect on the build:
* cabal.project.stage{0,1,2,3}: tag bump only (no semantic change
— the new SHA is just #368 + a clean cherry-pick of #361 that
was previously applied locally only in cabal-release.yml).
* cabal-release.yml: drops the "checkout-this-repo" + "git apply
r12" steps and the patch path-trigger; cabal-install ships
identically because the patch is already in CABAL_SHA.
Patches retired (now wholly carried by upstream PR branches):
* lode/r8-cabal-ghcjs-removal.patch (= stable-haskell/cabal #359)
* lode/r12-cabal-target-prefix-aware-tool-guess.patch
(= stable-haskell/cabal #361)
GitHub deprecated Node20 runners starting June 16th, 2026, and removed Node20 entirely on September 16th, 2026. Three actions families were still on @v4 (the Node20-runtime line) and triggered deprecation annotations on every job: * actions/checkout@v4 → @v5 (Node24) * actions/upload-artifact@v4 → @v5 (Node24) * actions/download-artifact@v4 → @v5 (Node24) actions/cache was already on @v5; no other Node20-runtime actions are referenced from this repo. The bump is mechanical; behaviour and API surface are unchanged across v4→v5 for our usage (checkout fetch-depth and path inputs, artifact name + path, basic compression). Workflows touched: * nix-ci.yml (11 v5 references) * reusable-release.yml (12 v5 references) * release.yml (2) * cabal-release.yml (2; also tidies the file header comment to match the new branch-based cabal pin)
… DAG
Before: channel-e2e-multi.yml and cabal-release.yml were standalone
workflows fired on their own push/PR triggers. They ran in parallel
with nix-ci and tested whatever bytes were on the live ghcup channel
— so a PR-introduced regression in GHC packaging, cabal patches, or
wasm sysroot wiring only surfaced at release time, not at PR time.
After: two new reusable workflows + DAG edges in nix-ci:
reusable-cabal-release.yml (extracted from cabal-release.yml)
on: workflow_call
inputs: cabal_sha, cabal_ver, bootstrap_ghc, release_tag
Build x86_64-linux + aarch64-linux cabal bindists, upload artifact,
optionally upload to a GitHub Release.
reusable-channel-e2e.yml (extracted from channel-e2e-multi.yml)
on: workflow_call
inputs: install_mode (channel|artifact), multi_version, cabal_version
install_mode=channel — install GHC + cabal via the live ghcup
channel YAML (post-release smoke test;
same behaviour as before)
install_mode=artifact — download multi-target GHC tarball from
cross-multi + cabal bindist from
cabal-release IN THE SAME WORKFLOW RUN,
install locally via the bundled
./configure + make install, run the same
hello / miso / JS-hello test surface
channel-e2e-multi.yml (now a thin wrapper)
Same triggers as before; calls reusable with install_mode=channel.
cabal-release.yml (now a thin wrapper)
Same triggers; calls reusable; tag pushes still drive
release-asset upload.
nix-ci.yml (two new DAG nodes appended)
cabal-release : parallel with build (no needs:)
e2e-multi : needs [cross-multi, cabal-release], install_mode=
artifact — downloads THIS run's artifacts and
tests them. Skipped if either dependency failed.
End-to-end effect: a PR that breaks cabal (e.g. a bad patch in the
upstream branch SHA), wasm sysroot, or multi-target packaging now
fails inside nix-ci instead of slipping through to release. The
standalone channel-e2e-multi.yml + cabal-release.yml workflows
continue to fire on tag pushes / dispatch / weekly canary so the
live channel keeps getting validated independently.
Cross: MULTI in nix-ci.yml has `continue-on-error: true` so a single
platform failure (e.g. the transient github.com 504 from this PR's
first CI cycle) doesn't block the other platforms. As a side effect,
`needs.cross-multi.result` at the e2e-multi caller level resolves
"success" even when one matrix entry didn't upload an artifact — so
the previously-existing job-level gate
`needs.cross-multi.result == 'success'` wasn't actually gating
anything per-platform, and a missing artifact cascaded into an
e2e-multi job failure on download.
Fix the same problem at both artifact-download points (cabal +
multi-target tarball) in the reusable workflow:
* download-artifact step gets continue-on-error: true + an id
* install step gates on `steps.<id>.outcome == 'success'`
* for cabal: a fallback step installs cabal from the wasm ghcup
channel when the artifact is missing (clean recovery — cabal
is downstream of every test)
* for multi-target: a warning-only step fires when the artifact
is missing; the test steps already gate on
`steps.install_artifact.outputs.installed == 'true'`, which
stays empty when install_artifact is skipped, so the tests
cleanly skip rather than failing
Net effect: cross-multi failing on one platform now produces a clean
e2e-multi skip on that platform with a warning, not a cascade
failure on top of the original cross-multi failure.
Refs task #70.
Andrea's base commit 4ad586b ("stage2: select static/dynamic build via project files instead of configure") removed the --enable-dynamic autoconf toggle, m4/accumulate.m4, and the generated cabal.project.stage2.settings in favour of explicit cabal.project.stage2.{common,static,dynamic}. Our stage3 wasm-shared support was built on top of that now-removed machinery: configure derived ALL_PACKAGES_STAGE3 from ALL_PACKAGES (populated by APPEND_PKG_FIELD in m4/accumulate.m4) and substituted it into cabal.project.stage3.settings.in. With the machinery gone, port the stage3 settings to the same explicit-project-file model: * Inline the wasm-only dynamic block directly into cabal.project.stage3 (shared: True / executable-dynamic: True / rts +dynamic, guarded by `if arch(wasm32)`). This is exactly what the generated settings produced under DYNAMIC=1 — the only mode the Cross: MULTI build runs. * Delete cabal.project.stage3.settings.in (no longer generated). * Drop the cabal.project.stage3.settings prerequisite from the two STAGE3_<plat>_PREREQS Makefile variants. * Drop the now-obsolete .gitignore entry. configure.ac, Makefile stage2 selection, and the stage2 project files are taken from base unchanged. The per-target `settings` dials (target is dynamic / ships dynamic libraries = YES for wasm) — added earlier in this branch and orthogonal to base's change — are what make GHC's link pipeline accept the inlined dynamic block.
c32fc11 to
1752b7b
Compare
Current state (updated 2026-06-12) — for review, not for auto-mergeHeads-up for reviewers: the original PR description ("Phases 0–2 … 3–7 follow in subsequent PRs") is outdated. This branch now carries the entire initiative end-to-end:
Rebased onto Andrea's stage2 refactor (today)
Merging this also un-breaks the base branch's nightly Validation done locally
Note on
|
843fceb (stable-haskell/master tip) lacks the Host-only `package *` handling,
so cabal.project.stage2.dynamic's `package * { shared: True }` also built the
BUILD-stage packages shared. Those failed to link against the bootstrap GHC on
essentially every release platform (Mac, deb11, RockyLinux, Alpine:
"ld: cannot find -lHSbytestring-…-ghc9.8.4") — incl. glibc hosts that built
green on #181. 8b8433b (feature/wasm-cross-ghcup-stack) carries the fix and is
the pin #181 uses to build all platforms green with DYNAMIC=1. Detailed
rationale in cabal.project.stage0. Also drops the invalid per-package
`configure-options` field (cabal: "Unknown field"); the Rocky libffi fix is the
valid -optc-DFFI_NO_RAW_API=1.
…tack) base listed `hooks-exe` in the Cabal source-repo subdirs (it exists on stable-haskell/master), but the 8b8433b feature/wasm-cross-ghcup-stack tip we now pin predates it, so stage0 cabal-install bootstrap failed: Cabal-<hash>/hooks-exe: getDirectoryContents:openDirStream: does not exist Drop hooks-exe to match the proven stage0 setup on stable-haskell/ghc #181.
base adapted compiler/Setup.hs and libraries/ghc-boot/Setup.hs to Cabal 3.17 (commit edb808a0b8b) which split Verbosity into VerbosityFlags/VerbosityHandles (mkVerbosity/defaultVerbosityHandles). The 8b8433b feature/wasm-cross-ghcup-stack Cabal predates that split, so stage1 failed building these Custom Setups: Variable not in scope: mkVerbosity / defaultVerbosityHandles (GHC-88464) Revert to the pre-3.17 form `fromFlagOrDefault minBound (configVerbosity cfg)` (matching the proven Setup.hs on stable-haskell/ghc #181). Pairs with the 8b8433b pin; revert alongside it when master regains the host-only handling.
…ll guard Two of the fixes #181 (feat/wasm-cross-ghcup) carries but the stable-ghc-9.14 / #188 base lacks, needed to build stage3-wasm32-unknown-wasi: * cabal.project.stage3: replace the stale `if os(wasi) { package * shared:True }` (which alone leaves GHC's wasm link pipeline inconsistent — fails at the ghc-internal link with [GHC-74335]) with #181's `if arch(wasm32)` triple (shared + executable-dynamic + rts +dynamic). * rts/RtsStartup.c: guard the promoteBootLibrariesToGlobal() *call site* with !defined(wasm32_HOST_ARCH) to match its definition (which is already wasm-excluded; it uses dladdr/dlopen). Without this the wasm rts fails to compile ("call to undeclared function 'promoteBootLibrariesToGlobal'"). NOTE: these are necessary but NOT sufficient. stage3-wasm still fails at the ghc-internal shared-lib link ([GHC-74335] "-dynamic ignored when linking binaries on WASM" -> mismatched interface profile tag) because the real fix is in the GHC COMPILER, on #181 but not here: - 4d84ace "compiler: per-target settings drive GHC Dynamic / Profiled" (Platform/Settings/Settings.IO/Driver.Session: targetIsDynamic etc.) - 7396909 "rts+compiler: wasm32 cross-target patches" (esp. compiler/GHC/Linker/Dynamic.hs — the wasm dynamic-link handling) Porting those is the #181 <-> stable-ghc-9.14 wasm integration, tracked separately. (7396909 also rewrites compiler/Setup.hs + ghc-boot/Setup.hs, which conflicts with the modern-pin VerbosityHandles restore here.)
Ports the two GHC-source commits #181 (feat/wasm-cross-ghcup) carries but the stable-ghc-9.14 / #188 base lacks, which are the real fix for the stage3-wasm ghc-internal link failure ([GHC-74335] "-dynamic ignored when linking binaries on WASM" -> mismatched interface profile tag): - 7396909 "rts+compiler: wasm32 cross-target patches" (GHC/Linker/Dynamic.hs wasm dynamic-link handling, Driver/Session.hs, Runtime/Interpreter/Wasm.hs, rts/linker/elf_got.c, rts/RtsStartup.c) - 4d84ace "compiler: per-target settings drive GHC Dynamic / Profiled" (Platform/Settings/Settings.IO/Driver.Session: platformMisc_targetIsDynamic et al., defaulting True so the wasm target is dynamic-capable) compiler/Setup.hs + libraries/ghc-boot/Setup.hs were KEPT at the modern-pin VerbosityHandles form (7396's pre-split Setup.hs revert was discarded — it's incompatible with the post-split cabal pin).
…ings stack" This reverts commit d89d51e.
…strap RCA: stage3 cross builds (wasm32-unknown-wasi, javascript-unknown-ghcjs) die at `primops.txt:139:31` while compiling the `ghc` library. Confirmed by direct reproduction: $ <bootstrap ghc-9.8.4>/bin/genprimopcode --data-decl < primops.txt genprimopcode-ghc-9.8.4: parse error at "Parse error at line 139, column 31" Line 139 is `effect = NoEffect` -- the primop effect-classification grammar, which the bootstrap GHC 9.8.4's genprimopcode predates. compiler/Setup.hs invokes genprimopcode by bare name (`readProcess "genprimopcode"`), i.e. via PATH, and the devx/nix bootstrap GHC's genprimopcode wins. The CC preprocessing of primops.txt.pp is *not* at fault: host clang and wasm32-wasi-clang produce byte-identical output (4465 lines), and the freshly-built genprimopcode parses it cleanly. stage1/stage2 escape this only because cabal's build-tool-depends happens to inject the fresh tool for native builds; the cross stage3 build does not get that for the genprimopcode Setup hook. Fix (build-orchestration layer, no compiler/Setup.hs change -- keeps #384 reconciliation surface minimal): - Makefile: add GENPRIMOPCODE_BIN (mirrors DERIVE_CONSTANTS_BIN/GENAPPLY_BIN) and prepend its dir to PATH in the stage3 cabal-build env so the fresh genprimopcode shadows the bootstrap one. --with-compiler/--with-build-compiler /--with-hsc2hs are explicit, so prepending stage1/bin cannot mis-shadow ghc/ghc-pkg/hsc2hs. - Makefile: copy genprimopcode into the dist bindist (alongside deriveConstants /genapply) so DIST_BUILD (CI) cross builds have it. - nix-ci.yml: pass GENPRIMOPCODE_BIN=$PWD/_build/dist/bin/genprimopcode in the multi-target DIST_BUILD invocation. Verified: with stage1/bin prepended, `genprimopcode` resolves to the fresh build and parses the effect grammar (exit 0); without it, the bootstrap one fails at 139:31.
Summary
End-to-end work on the stable-haskell wasm cross-compiler via ghcup initiative. This PR delivers Phases 0–2 of a 7-phase plan to ship
wasm32-wasi-ghc(a wasm cross-compiler) + astable-haskell/cabalbinary via ghcup, enabling Haskell developers toghcup installand build miso apps for the browser.Scope of this PR: Phase 0 (consolidate WASM build infrastructure from feature branches) + Phase 1 verification (locally proves the wasm cross compiler builds and produces valid wasm) + Phase 2 (CI integration is already in
nix-ci.ymlfrom cherry-picks and verified working). Phases 3–7 (bindist packaging, ghcup channel, cabal binary, miso template, docs) follow in subsequent PRs.The full plan, decisions, risks, and status log live in
lode/wasm-cross-ghcup-plan.md.What's included
Phase 0 — Consolidate WASM build infrastructure (12 commits)
flake.nix+flake.lockpullingwasi-sdkfrom upstreamghc-wasm-meta(commits frombuild/wasm-nix-environment).Makefilecross-build refactor for stage3 targets (wasm/js) + RTS fix for undefined symbols referenced only byR_*_NONErelocations (commits fromfeat/nix-ci-split).build/test/cross-js/cross-wasmjobs with full platform matrix (thecross-wasmjob already does the smart JSFFI-vs-pure-WASI runtime smoke test).Phase 1 — Local verification
_build/dist/ghc.tar.gz(258MB) — native GHC 9.14 (Stable Haskell Edition) bindist_build/dist/bin/wasm32-unknown-wasi-ghc— wasm cross-compiler that produces validWebAssembly (wasm) binary module v0x1 (MVP)for hello-world inputsCabal source-repo pin
cabal.project.stage{0,1,2,3}reverts the chore commite148c1c059's switch from explicit SHA to branch-name pin. Re-pins to SHA44817477ff6d22de4bfa4307e061df58f319d3b6(the same SHA the green April-23 CI used). See "Why CI has been failing" below.Phase 2 — CI integration (already in
nix-ci.yml)The
cross-wasmjob (cherry-picked fromfeat/nix-ci-split) is fully functional and does:wasi-sdkvia upstreamghc-wasm-metabootstrap.sh(FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm)stage3-wasm32-unknown-wasiusing_build/dist/bin/cabalfrom thebuildjob's artifactwasm-objdumpand chooses betweenwasmtime(pure WASI) andnode + post-link.mjs(JSFFI-using modules) — handles the GHC wasm runtime story end-to-endDocumentation
lode/wasm-cross-ghcup-plan.md— full 7-phase plan, all decisions (D1–D7) and risks (R1–R10) with root-cause analyses, status log of this session.lode/r8-cabal-ghcjs-removal.patch— the 2 cabal-install patches that unblock building stable-haskell/cabal from current master HEAD. Upstreamed as Remove dead GHCJS import + fix stale binDirectoryFor call cabal#359.lode/phase3-relocate-sh-draft.sh+lode/phase6-miso-template-draft/— draft artifacts for Phases 3 and 6 (not active in this PR, but ready for the follow-up work).Why CI has been failing daily since 2026-05-17
Investigation traced through
gh run list --branch stable-ghc-9.14:d8f0caefe58"Switch to wip/angerman/compile-less Cabal branch"e148c1ca059"chore: use stable-haskell/master for Cabal branch"e148c1c059changedcabal.project.stage*tag:from explicit SHA44817477…to branch namestable-haskell/master. Subsequent drift on that branch introduced two regressions incabal-install:ProjectPlanning.hs:224still importsDistribution.Simple.GHCJSthoughCompilerFlavorno longer has aGHCJSconstructor and the module no longer exists.ProjectOrchestration.hs:544callsbinDirectoryFor distDirLayout elaboratedShared elab exeNamethoughbinDirectoryForwas renamed/refactored tobinDirectorieswith a different signature.The CI cache key (
hashFiles('cabal.project.stage0')) was invalidated bye148c1c059, forcing a from-scratchmake stable-cabalrebuild that hits these errors.This PR's pin restores the working state. Once stable-haskell/cabal#359 lands, the pin can be removed (follow-up).
Test plan
make stable-cabalsucceeds with devx ghc98 + SHA44817477pin (no patches needed)make _build/dist/ghc.tar.gzproduces 258MB bindist; nativeghccompiles + runshello.hsmake stage3-wasm32-unknown-wasiproduces wasm cross-compiler + libraries + JS shimswasm32-unknown-wasi-ghc hello.hs -o hello.wasmyields validWebAssembly (wasm) binary module v0x1 (MVP)release.ymlworkflow) — verifies separately after mergeWhat's NOT in this PR (deferred)
relocate.sh+.tar.xz). Design drafted atlode/phase3-relocate-sh-draft.sh+ lode plan §6.stable-haskell/ghc-wasm-metarepo +ghcup-stable-wasm-0.0.1.yamlchannel. Design drafted at lode plan §8.stable-haskell/cabalbinary releases for ghcup. Blocked on Remove dead GHCJS import + fix stale binDirectoryFor call cabal#359 merging.stable-haskell/miso-wasm-templaterepo with browser launcher. Draft files atlode/phase6-miso-template-draft/(including theindex.jsthat closes the ecosystem-wide documentation gap onbrowser_wasi_shimusage).Notes for reviewer
lode/directory holds living planning docs + drafts. If you'd prefer they live elsewhere (e.g. a separate branch, a wiki, or removed entirely), happy to adjust. They were valuable for tracking the complex root-cause investigation but aren't strictly required to merge.lode/r8-cabal-ghcjs-removal.patch) are tracked in Remove dead GHCJS import + fix stale binDirectoryFor call cabal#359. Once merged + a new tag cut, we can revert this PR's SHA pin to a branch-name pin again.