Skip to content

feat(wasm): wasm32 + JS multi-target cross-compiler via ghcup (Phases 0–7 + MULTI)#181

Open
angerman wants to merge 22 commits into
stable-ghc-9.14from
feat/wasm-cross-ghcup
Open

feat(wasm): wasm32 + JS multi-target cross-compiler via ghcup (Phases 0–7 + MULTI)#181
angerman wants to merge 22 commits into
stable-ghc-9.14from
feat/wasm-cross-ghcup

Conversation

@angerman

Copy link
Copy Markdown

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) + a stable-haskell/cabal binary via ghcup, enabling Haskell developers to ghcup install and 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.yml from 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.lock pulling wasi-sdk from upstream ghc-wasm-meta (commits from build/wasm-nix-environment).
  • Makefile cross-build refactor for stage3 targets (wasm/js) + RTS fix for undefined symbols referenced only by R_*_NONE relocations (commits from feat/nix-ci-split).
  • CI workflow split into build/test/cross-js/cross-wasm jobs with full platform matrix (the cross-wasm job already does the smart JSFFI-vs-pure-WASI runtime smoke test).
  • Build infrastructure fixes (stamp-based deps, PHONY ordering, hackage race condition).

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 valid WebAssembly (wasm) binary module v0x1 (MVP) for hello-world inputs
  • Total local build time: ~80 min (stage0+1+2 = ~45 min, stage3-wasm32-unknown-wasi = ~35 min)

Cabal source-repo pin

  • cabal.project.stage{0,1,2,3} reverts the chore commit e148c1c059's switch from explicit SHA to branch-name pin. Re-pins to SHA 44817477ff6d22de4bfa4307e061df58f319d3b6 (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-wasm job (cherry-picked from feat/nix-ci-split) is fully functional and does:

  • Pulls wasi-sdk via upstream ghc-wasm-meta bootstrap.sh (FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm)
  • Builds stage3-wasm32-unknown-wasi using _build/dist/bin/cabal from the build job's artifact
  • Runs a smart smoke test that detects JSFFI imports via wasm-objdump and chooses between wasmtime (pure WASI) and node + post-link.mjs (JSFFI-using modules) — handles the GHC wasm runtime story end-to-end

Documentation

Why CI has been failing daily since 2026-05-17

Investigation traced through gh run list --branch stable-ghc-9.14:

Date SHA Result
2026-04-23 (last green) d8f0caefe58 "Switch to wip/angerman/compile-less Cabal branch"
2026-05-17+ (all failures) e148c1ca059 "chore: use stable-haskell/master for Cabal branch"

e148c1c059 changed cabal.project.stage* tag: from explicit SHA 44817477… to branch name stable-haskell/master. Subsequent drift on that branch introduced two regressions in cabal-install:

  1. ProjectPlanning.hs:224 still imports Distribution.Simple.GHCJS though CompilerFlavor no longer has a GHCJS constructor and the module no longer exists.
  2. ProjectOrchestration.hs:544 calls binDirectoryFor distDirLayout elaboratedShared elab exeName though binDirectoryFor was renamed/refactored to binDirectories with a different signature.

The CI cache key (hashFiles('cabal.project.stage0')) was invalidated by e148c1c059, forcing a from-scratch make stable-cabal rebuild 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-cabal succeeds with devx ghc98 + SHA 44817477 pin (no patches needed)
  • make _build/dist/ghc.tar.gz produces 258MB bindist; native ghc compiles + runs hello.hs
  • make stage3-wasm32-unknown-wasi produces wasm cross-compiler + libraries + JS shims
  • wasm32-unknown-wasi-ghc hello.hs -o hello.wasm yields valid WebAssembly (wasm) binary module v0x1 (MVP)
  • CI green on x86_64-linux + aarch64-linux + aarch64-darwin (build + test + cross-wasm) — kicks off when this PR opens
  • Daily release CI green (the failing release.yml workflow) — verifies separately after merge

What's NOT in this PR (deferred)

  • Phase 3 — Ghcup-compatible bindist packaging (relocate.sh + .tar.xz). Design drafted at lode/phase3-relocate-sh-draft.sh + lode plan §6.
  • Phase 4stable-haskell/ghc-wasm-meta repo + ghcup-stable-wasm-0.0.1.yaml channel. Design drafted at lode plan §8.
  • Phase 5stable-haskell/cabal binary releases for ghcup. Blocked on Remove dead GHCJS import + fix stale binDirectoryFor call cabal#359 merging.
  • Phase 6stable-haskell/miso-wasm-template repo with browser launcher. Draft files at lode/phase6-miso-template-draft/ (including the index.js that closes the ecosystem-wide documentation gap on browser_wasi_shim usage).
  • Phase 7 — README + migration docs.

Notes for reviewer

  • The 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.
  • The R8/R8.5 cabal patches (committed at 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.

angerman added a commit that referenced this pull request May 26, 2026
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.
angerman added a commit that referenced this pull request May 27, 2026
…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.
angerman added a commit that referenced this pull request May 27, 2026
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.
angerman added a commit that referenced this pull request May 27, 2026
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.
angerman added a commit that referenced this pull request May 28, 2026
…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).
angerman added a commit that referenced this pull request Jun 1, 2026
…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.
angerman added a commit that referenced this pull request Jun 3, 2026
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.
@angerman angerman force-pushed the feat/wasm-cross-ghcup branch 3 times, most recently from ee9cc12 to b6b96f5 Compare June 8, 2026 01:13
angerman and others added 18 commits June 12, 2026 09:37
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)
angerman added 3 commits June 12, 2026 09:37
… 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.
@angerman angerman force-pushed the feat/wasm-cross-ghcup branch from c32fc11 to 1752b7b Compare June 12, 2026 02:24
@angerman angerman changed the title feat(wasm): consolidate cross-compile infra + Phase 2 CI integration feat(wasm): wasm32 + JS multi-target cross-compiler via ghcup (Phases 0–7 + MULTI) Jun 12, 2026
@angerman

Copy link
Copy Markdown
Author

Current state (updated 2026-06-12) — for review, not for auto-merge

Heads-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:

  • Phases 0–7 of the wasm-cross-ghcup plan (lode/wasm-cross-ghcup-plan.md), all gates met.
  • The newer MULTI multi-target rework (native + wasm32 + JS in one bindist), replacing the separate wasm32-wasi / JS standalone channels.
  • A CI refactor extracting reusable-* workflows and wiring cabal-release + channel-e2e into the Nix CI DAG.

Rebased onto Andrea's stage2 refactor (today)

stable-ghc-9.14 advanced with 4ad586bbc2e ("select static/dynamic build via project files instead of configure"), which removed the --enable-dynamic autoconf toggle, m4/accumulate.m4, and generated cabal.project.stage2.settings in favour of explicit cabal.project.stage2.{common,static,dynamic}. Our stage3 wasm-shared support was built on that now-removed machinery, so the branch was rebased and the stage3 settings ported onto the new project-file model (1 dedicated commit):

  • configure.ac is now byte-identical to base (no accumulation machinery).
  • cabal.project.stage3 carries a single self-contained if arch(wasm32) dynamic block (shared:True / executable-dynamic:True / rts +dynamic) — exactly the DYNAMIC=1 form the Cross: MULTI build always uses — instead of configure-generated settings. cabal.project.stage3.settings.in is deleted.
  • The Cabal source-repo SHA pin 8b8433b… (PR #368 stack, carries the #361 target-prefix-aware tool-guess fix the cross compiler needs) is preserved across all stages, overriding base's moving stable-haskell/master branch pin.

Merging this also un-breaks the base branch's nightly Build and release (currently failing on the e148c1c059 Cabal-pin drift).

Validation done locally

autoreconf + ./configure --no-create + per-file config.status regenerate cleanly with no leftover autoconf tokens; range-diff vs the pre-rebase tip shows only the intended build-config divergence.

Note on Build and release (release.yml)

Its FreeBSD / RockyLinux-glibc / Alpine-ARM jobs fail with pre-existing platform/toolchain issues (e.g. FreeBSD "Failed to find C preprocessor") that also fail on stable-ghc-9.14 — not regressions from this PR. The canonical Nix CI DAG (Build × 6, Test × 6, Cross: MULTI × 3, e2e × 3, cabal-release × 2) is the gate to watch.

angerman added a commit that referenced this pull request Jun 12, 2026
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.
angerman added a commit that referenced this pull request Jun 13, 2026
…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.
angerman added a commit that referenced this pull request Jun 13, 2026
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.
angerman added a commit that referenced this pull request Jun 15, 2026
…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.)
angerman added a commit that referenced this pull request Jun 15, 2026
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).
angerman added a commit that referenced this pull request Jun 16, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant