Skip to content

WIP: multi-target GHC bindist (native + wasm + JS in one)#184

Merged
angerman merged 14 commits into
feat/wasm-cross-ghcupfrom
feat/multi-target-bindist
Jun 8, 2026
Merged

WIP: multi-target GHC bindist (native + wasm + JS in one)#184
angerman merged 14 commits into
feat/wasm-cross-ghcupfrom
feat/multi-target-bindist

Conversation

@angerman

@angerman angerman commented Jun 1, 2026

Copy link
Copy Markdown

Stacked on PR #181 (the .dyn_hi shipping work for stable.12).

Builds a single GHC bindist that exposes three compilation targets through
the same bin/ghc binary via argv[0] dispatch:

  • bin/ghc — native
  • bin/wasm32-unknown-wasi-ghc — wasm cross
  • bin/javascript-unknown-ghcjs-ghc — JS cross

End-user install (planned):

ghcup install ghc multi-9.14.0.stable.0
ghcup set    ghc multi-9.14.0.stable.0

Channel YAML uses the Installer DSL 0.1.0 schema's exeSymLinked with
targetPattern: "bin/**" to symlink all per-target binaries into
~/.ghcup/bin/ automatically.

What's in this PR

  • Makefile: new $(DIST_DIR)/ghc-multi-target.tar.gz rule that depends
    on stage3-wasm + stage3-js + native stage2; uses tar -czhf to
    dereference per-target symlinks into standalone binaries
  • mk/multi-target-{configure.sh,bindist-Makefile,relocate.sh}:
    autoconf-shaped install path that handles all 3 per-target package
    caches at install time
  • .github/workflows/nix-ci.yml: new Cross: MULTI job (matrixed across
    all 3 host platforms) that installs wasi-sdk + emscripten + Node 22,
    builds the multi-target tarball, patchelfs the host ELFs with
    $ORIGIN-relative rpath, and uploads to GitHub Release on tags
    matching multi-*
  • lode/multi-target-bindist-design.md: design doc with rationale,
    layout, and open questions

Status

WIP — gating before promoting to LatestPrerelease on the channel YAML.

  • design doc reviewed
  • Makefile rule
  • CI Cross: MULTI job
  • CI green on 3 platforms (Phase 6)
  • channel YAML 0.1.0 schema (Phase 7 — separate ghcup-multi-target-0.1.0.yaml, not destructive to existing ghcup-wasm.yaml)
  • channel-e2e for multi-target install (Phase 9)
  • Thursday EOD GO/NO-GO gate (Phase 10)

Rollback safety: gh-pages tag demo-freeze-2026-06-05 pins the
stable.12 channel state.

Not for merge yet.

angerman added a commit that referenced this pull request Jun 1, 2026
…e2e multi-target steps

Three related changes for the multi-target work:

1. nix-ci.yml cross-multi job: add `env: EMSDK_VERSION: "3.1.74"`.
   Cross: JS declares this at the JOB level (workflow-level env doesn't
   exist in this file). Cross: MULTI inherited nothing → ${{ env.EMSDK_VERSION }}
   expanded to empty → `git clone --branch '' ...` exited 129 with
   git's usage-help output (PR #184 run 26731649704 logs lines 1894-2020).

2. nix-ci.yml on.push.tags: add `multi-*` alongside `wasm32-wasi-*`.
   Future tag pushes for multi-target candidates fire the workflow.

3. channel-e2e.yml: add multi-target test steps after the existing
   stable.12 wasm tests. continue-on-error: true per step so failures
   don't break the wasm signal. Tests:
     * add channel + install multi-9.14.0.stable.0 + ghcup set
     * native hello via ghc
     * wasm hello template via wasm32-unknown-wasi-ghc
       (WASM_VERSION=multi-9.14.0.stable.0 override)
     * JS hello via javascript-unknown-ghcjs-ghc (emscripten install
       included)

The multi-target channel YAML isn't published yet, so the multi-
target steps will fail at `ghcup install` until Phase 11 promotes —
intentionally, the continue-on-error guards against that.
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 2, 2026
…only)

Second class of build-host leak in our darwin bindist: stage2 host
libraries (libHSghc-internal, libHSterminfo, ...) link against the
nix-store install-names devx/clang saw at link time:

  libHSghc-internal-9.1400.0-ghc9.14.dylib references
    /nix/store/...-libiconv-109.100.2/lib/libiconv.2.dylib

  libHSterminfo-0.4.1.7-ghc9.14.dylib references
    /nix/store/...-ncurses-6.5/lib/libncursesw.6.dylib

Off the build runner those paths don't exist; dyld can't load the
.dylib and the binary abort-traps. e2e/aarch64-darwin in PR #184
run 26805774141 caught this:

  dyld[91216]: Library not loaded: /nix/store/...-libiconv-...
    Referenced from: .ghcup/.../libHSghc-internal-...dylib
  Abort trap: 6

Adopts the devx fixup-nix-deps pattern (input-output-hk/devx
static.nix SHA 5f05c1e1af6) — for each Mach-O in the bindist,
walk `otool -L` and `install_name_tool -change` each nix-store
install-name to its /usr/lib equivalent:

  libiconv[.2]    → /usr/lib/libiconv[.2].dylib
  libffi.*        → /usr/lib/libffi.dylib
  libc++.*        → /usr/lib/libc++.dylib
  libz            → /usr/lib/libz.dylib
  libresolv.*     → /usr/lib/libresolv.dylib

Plus an addition over devx's pattern for our haskeline dependency:

  libncurses[w].*  → /usr/lib/libncurses.5.4.dylib
                     (macOS ships only 5.4 in the dyld shared cache;
                      it is ABI-compatible for terminfo lookups)

Merged into the existing rpath-strip step rather than adding a
second darwin-only step. Both classes get one re-sign per file
(skipped if neither rewrite touched the binary).

Verified locally on /tmp/multi-extract/darwin: pattern correctly
rewrites both libiconv (ghc-internal) and libncursesw (terminfo).
angerman added a commit that referenced this pull request Jun 3, 2026
PR #184 channel-e2e MULTI darwin run 79288405270 caught a real bug
in the e2e setup. The 'multi - native hello' step failed at link
time on macos-15:

  clang -cc1as: error: unknown target triple 'arm64-apple-macosx15.0.0'
  'clang' failed in phase 'Assembler'. (Exit code: 1)

Root cause: the wasi-sdk install step appends WASI_BIN to GITHUB_PATH
(via 'echo "$WASI_BIN" >> "$GITHUB_PATH"'). GITHUB_PATH prepends
entries to PATH for subsequent steps, so WASI_BIN ends up ahead of
/usr/bin. WASI_BIN contains a `clang` symlink to a wasm-only LLVM
build that doesn't know any Apple host triple — when GHC's native
code generator on macos-15 invokes unqualified `clang`, it resolves
to wasi-sdk's, which rejects 'arm64-apple-macosx15.0.0' at the
assembler stage.

Linux is unaffected: GHC there invokes `cc` (gcc), not `clang`, so
wasi-sdk's clang never shadows the native toolchain. The WASM-only
workflow on darwin is unaffected because its tests don't exercise
native-host code generation (only wasm32-unknown-wasi-ghc).

Fix: on Darwin, re-prepend /usr/bin to GITHUB_PATH after the
wasi-sdk install step. Apple's clang wins the bare 'clang'
resolution again, while the wasm32-unknown-wasi-clang symlinks in
WASI_BIN (unique names) still resolve correctly for the wasm
compile path.
@angerman angerman force-pushed the feat/wasm-cross-ghcup branch from 3a7cd9c to fa20de3 Compare June 4, 2026 03:01
@angerman angerman force-pushed the feat/multi-target-bindist branch 2 times, most recently from e7722a6 to 3651cf0 Compare June 4, 2026 21:20
@angerman angerman force-pushed the feat/wasm-cross-ghcup branch from c8d6fb3 to 1e74fe9 Compare June 4, 2026 22:04
@angerman angerman closed this Jun 4, 2026
@angerman angerman force-pushed the feat/multi-target-bindist branch from 3651cf0 to 1e74fe9 Compare June 4, 2026 22:05
@mergify

mergify Bot commented Jun 4, 2026

Copy link
Copy Markdown

⚠️ The sha of the head commit of this PR conflicts with #181. Mergify cannot evaluate rules on this PR. Once #181 is merged or closed, Mergify will resume processing this PR. ⚠️

angerman added 6 commits June 5, 2026 07:09
Adds the $(DIST_DIR)/ghc-multi-target.tar.gz Makefile rule which
combines the native stage2 host build with the stage3-wasm and
stage3-js cross trees into a single ghcup-installable bindist:

  * `bin/` contains all three argv[0]-dispatching frontends
    (ghc, wasm32-unknown-wasi-ghc, javascript-unknown-ghcjs-ghc),
    each pointing at the same physical executable.
  * `lib/<host-platform>/` carries the native package db.
  * `lib/targets/<triple>/` carries the per-cross-target package
    db (recached on install by the bundled relocate.sh).
  * mk/multi-target-{configure,relocate,bindist-Makefile} —
    autoconf-shaped install scripts so ghcup's installer-DSL drives
    the unpack via the same `configure` + `make install` flow it
    uses for the wasm-only bindist.

Tar dereferences symlinks (`tar czhf`), so the hardlinked argv[0]
frontends become independent copies in the tarball — argv[0]
dispatch still works after extract.
Refines the R7 path-i template: instead of unconditionally
emitting `shared: True` / `executable-dynamic: True` for every
stage3 target, gate them on `if arch(wasm32)` so cabal applies
them ONLY when --with-compiler points at the wasm cross-compiler.
The JS target (arch=javascript) and native build-side packages
(happy-lib, alex, deriveConstants, Setup.hs) skip the conditional
and stay static, fixing two regressions multi-target exposed:

  * JS target: emcc/wasm-ld can't produce .so output, and
    DYNAMIC=1 with shared:True hit
    `wasm-ld: error: unknown argument: -h` when the setting
    flowed into JS link lines.
  * Native build-side: with shared:True applied, happy-lib /
    alex emitted -dynamic-too, which then failed when stage2's
    dynamic1 dist lacked .dyn_hi for native-only deps.

Hardcodes the per-package field instead of @ALL_PACKAGES@
substitution — the wasm target always needs shared:True
regardless of how stage2 was built, and the conditional handles
JS/native exclusion cleanly without per-build template
substitution.
Cross: MULTI builds native stage2 + stage3-wasm + stage3-js into a
combined multi-target tarball. Reuses Cross: WASM setup (dynamic1
stage2 download, devx shell, wasi-sdk + Node 22 install, patchelf
for Linux ELF rpath rewrite) and adds emscripten install for the JS
target. Single make invocation drives all three. On multi-* tag
push, uploads to the matching GitHub Release alongside Cross: WASM.

The darwin Mach-O cleanup (LC_RPATH strip + nix-store LC_LOAD_DYLIB
rewrite) is done CONSTRUCTIVELY in the Makefile's stage2.dist phase
(CLEAN_DARWIN_DIST macro, see "build(stage2.dist): clean darwin
Mach-O at construction time" on the base branch). This step here
just VERIFIES the bindist is clean — if the construction-site fix
regresses, CI fails loud rather than shipping a broken bindist that
abort-traps on end-user macOS 15 hosts.
Bumps the stable-haskell/Cabal pin across cabal.project.stage{0,1,2}
to a patched SHA (6a5ce8161) carrying stable-haskell/cabal#368:
a one-function change in Distribution.Simple.GHC.Build.Link
that rewrites absolute library rpaths to a `shortRelativePath`-
computed @loader_path / \$ORIGIN-relative form.

Background: depLibraryPaths returns absolute build-store paths
whenever the dep's libdir isn't under the package's own install
prefix — which is essentially always, in a cabal-store layout
where each package gets its own hash-suffixed subdir. The
unpatched getRPaths only prefixed @loader_path/\$ORIGIN to
already-RELATIVE paths, so those absolute store paths went
straight into LC_RPATH / DT_RUNPATH, with macOS 15 dyld treating
them as fatal (= abort-trap on stable-haskell GHC bindist launch).

The Cabal patch partially mitigates this at link time (sibling
deps in the same store still get baked relative); the Cross:
MULTI darwin install_name_tool step picks up the rest. configure.ac
notes the explicit decision NOT to set `relocatable: True` —
that flag triggers cabal-install's checkRelocatable +
\${pkgroot}-prefixed library-dirs in .conf, neither of which is
compatible with the stable-haskell GHC bindist-assembly Makefile.
… cron canary

Four refinements landed iteratively while bringing multi-9.14.0.stable.1
through the release cycle:

  * Multi-target install gate (channel-e2e-multi.yml) — strict
    \`set -euo pipefail\` install of the multi-target GHC + argv[0]
    dispatch verification (Target platform per frontend). Any
    post-install failure (broken symlink, missing binary) now
    fails the job loud rather than silently skipping.

  * Darwin PATH fix — on macos-15, the wasi-sdk install step
    prepends WASI_BIN to PATH, which puts wasi-sdk's wasm-only
    \`clang\` ahead of /usr/bin/clang. GHC's native code generator
    then invoked the wrong clang and hit
    \`clang -cc1as: error: unknown target triple 'arm64-apple-macosx15.0.0'\`.
    Re-prepend /usr/bin after the wasi-sdk install so Apple's
    clang wins for unqualified \`clang\` lookups while the
    wasm32-unknown-wasi-clang symlinks still resolve.

  * Add wasm channel for cabal — the multi-target channel YAML
    intentionally ships only GHC frontends; cabal lives in
    ghcup-wasm.yaml. The multi workflow now adds both channels.

  * Fail-loud cabal install — drop the stale soft-fallback that
    used to silently set cabal_installed=false on "Unable to find
    a download for Tool" errors. cabal-3.17.0.0.stable.0 is
    universally available now; any install failure is a real bug.
    Removes the now-meaningless gate from downstream steps.

  * Weekly cron canary — added to both workflows so passive drift
    (release asset re-uploaded with different bytes, channel YAML
    mis-deployed, NodeSource setup_22.x breaking, ghcup-runner
    image quirks) gets caught between manual release events.
    Fires Mondays 06:00 UTC on the workflows' home default branch.
…AML draft

  * multi-target-bindist-design.md — design doc covering the
    argv[0]-dispatch model, per-target lib/targets/<triple>/
    layout, ghcup Installer DSL 0.1.0 exeSymLinked pattern, and
    the bindist install flow.
  * rpath-leak-investigation.md — root-cause analysis of the
    darwin LC_RPATH leak that motivated the patched Cabal
    (stable-haskell/cabal#368) and the post-build install_name_tool
    fixup step. Documents the unpatched depLibraryPaths /
    getRPaths interaction, the macOS 14 vs 15 dyld behavior
    difference, and the four ranked fix options (A: post-strip,
    B: relPath patch, C: depLibraryPaths patch, D: full
    relocatable+restructure).
  * draft-ghcup-multi-target-0.1.0.yaml — the channel YAML draft
    that became the published gh-pages
    ghcup-multi-target-0.1.0.yaml (kept here as design archive;
    the live file lives on the gh-pages branch).
…GHC Dynamic) (#187)

* stage3(JS): Path C — emit .dyn_hi via -dynamic-too ghc-option

#66 — symmetric to the wasm-side Path C in cabal.project.stage3.
settings.in.

The shared stage2 GHC binary in the multi-target bindist was
compiled DYNAMIC=1 (required for wasm). cabal-install reads its
`GHC Dynamic: YES` per `ghc --info` and auto-enables library-dynamic
for whichever target you compile against. Without `.dyn_hi` files
in the JS-target sysroot the end-user `cabal build` of a TH-using
package (miso, aeson, lens) fails reading e.g. `Prelude.dyn_hi`.

For wasm we ship `.dyn_hi` + `.so` via `shared: True +
executable-dynamic: True` (the existing arch(wasm32) arm). For JS
the same incantation fails the .so link step:
  wasm-ld: error: unknown argument: -h
because emcc/wasm-ld can't produce a real .so for the JS backend
(and we don't need one — there's no dlopen in the JS runtime).

Path C for JS: drop down to `ghc-options: -dynamic-too`. That asks
GHC to emit `.dyn_o + .dyn_hi` alongside `.o + .hi` during compile,
without invoking cabal's library-dynamic .so-link step. The
`.dyn_hi` files are what cabal-install actually needs to find when
later building TH-using packages against this target's sysroot.
The .so byproducts are skipped — irrelevant for JS anyway.

This is a per-package fix; the constraint `rts +dynamic` stays.
Header comment updated to reflect the per-target dial. Companion
follow-up #67 will make `GHC Dynamic` itself target-aware (read
from per-target settings file), at which point the JS bindist
could drop the .dyn_hi shipping if we want to slim it.

Builds + ships verified end-to-end via the multi-target Cross: MULTI
CI job + a follow-on multi-9.14.0.stable.3 candidate tag (TBD —
that's the next commit).

* ghc: target-aware GHC Dynamic via per-target settings key (#67)

Adds a per-target dial — `"target ships dynamic libraries"` —
read from `lib/targets/<triple>/lib/settings`. `ghc --info`'s
`GHC Dynamic` is now `hostIsDynamic && sTargetShipsDynLibs`, so on a
multi-target bindist different targets in one binary can correctly
disagree even though the shared stage2 GHC's compile-time
`DYNAMIC` macro (`rts_isDynamic()`) is fixed.

Why: cabal-install reads `GHC Dynamic` to decide whether to enable
`library-dynamic` by default. Pre-this-change, the multi-target
bindist's one stage2 GHC binary always reported YES (because it
was built `DYNAMIC=1` for wasm), causing cabal to demand .dyn_hi
files even for targets whose lib tree doesn't ship them — concretely
the symptom that motivated this work was `Prelude.dyn_hi: does not
exist` when an end-user `cabal build`-ed a TH-using miso app
against the JS target. The companion commit (#66, ship .dyn_hi for
the JS target too) makes the immediate symptom go away, but the
proper architectural fix is to make `GHC Dynamic` per-target so
*any* future target can opt out cleanly without breaking cabal.

Implementation:

  * compiler/GHC/Platform.hs
      add `platformMisc_targetShipsDynLibs :: Bool` to PlatformMisc.

  * compiler/GHC/Settings/IO.hs
      read the new settings key. Default `True` (via Either-fallback)
      for backward compatibility with older bindist settings files
      that predate the key — matches the historical behaviour of
      always reporting YES when the GHC binary is dyn-built.

  * compiler/GHC/Settings.hs
      expose `sTargetShipsDynLibs` accessor.

  * compiler/GHC/Driver/Session.hs (compilerInfo, L3573)
      `("GHC Dynamic", showBool (hostIsDynamic && sTargetShipsDynLibs (settings dflags)))`.
      hostIsDynamic stays for the GHC binary's own RTS introspection
      (e.g. the internal-interpreter linker decisions in
      Linker/Deps.hs and Downsweep.hs — both of which already gate
      on `internalInterpreter`, so external-interpreter cross flows
      are unaffected).

  * Makefile (stage3-$(1) rule)
      sed-inject the key into ghc-toolchain's generated settings
      file. Per-target override via `STAGE3_<triple>_TARGET_SHIPS_DYN_LIBS`
      Make variable; defaults to YES for every target we currently
      ship (wasm Path C ships .dyn_hi+.so; JS Path C — sibling commit —
      ships .dyn_hi via -dynamic-too; native inherits from host).

The per-target settings file is hand-editable so end-users can flip
the dial without rebuilding — e.g. testing a "JS without dyn libs"
scenario by setting the key to NO on an installed bindist.

* fix(Makefile): $$$$ in template recipe to preserve sed end-anchor

Spotted on PR #187 attempt 1: my sed expression
  s/\]$$/,("target ships dynamic libraries","YES")]/
inside the `define stage3` template lost its end-of-line anchor.
After template expansion `$$` collapsed to `$`, and Make then read
the lone `$/` in the recipe as a variable lookup (which is empty),
dropping the anchor entirely. The shell saw:
  s/\],("target ships dynamic libraries","YES")]/
which sed rejected as `unterminated 's' command`.

Fix: write `$$$$` (four dollars) — collapses through both layers
(define-template expansion AND recipe-execution expansion) into a
literal `$` at shell time, which is sed's end-of-line anchor.
Comment in the recipe records the gotcha so it doesn't get
re-introduced.

* revert(stage3): JS Path C via -dynamic-too leaks to BUILD-side native compiles

Retro from PR #187 first push to feat/multi-target-per-target-dyn:

The Cross: WASM linux jobs both went green ✅ (so the Makefile
sed-injection of the new settings key worked), but Cross: JS
aarch64-darwin failed at alex's first .hs module:

  src/DFS.hs:24:8: error: [GHC-47808]
      Failed to load dynamic interface file for Prelude:
        Exception when reading interface file
          .../base-4.22.0.0/Prelude.dyn_hi: does not exist

alex is a NATIVE BUILD-side tool, compiled with the native stage2
GHC (built DYNAMIC=0, no .dyn_hi). The `ghc-options: -dynamic-too`
in the `if arch(javascript) package *` arm leaked to that compile.

`shared: True` (the wasm-side analogue) is properly per-target-arch
in cabal's dual-compiler split — only host packages see it. But
`ghc-options` propagates to BUILD compiles regardless of the arch
conditional. Confirmed: alex emitted both .o and .dyn_o per
module (the `-dynamic-too` signature), and the path was
`aarch64-apple-darwin` (native, not javascript-unknown-ghcjs).

Reverting the arm and documenting the gotcha inline so a future
attempt doesn't trip over the same cabal-side asymmetry.

The companion #67 commit (target-aware GHC Dynamic) becomes the
unblock for the JS demo instead — set the JS-target value to NO
via the new Makefile var STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_DYN_LIBS.
That tells cabal-install to stop enabling library-dynamic by default
on the JS target, so end-user `cabal build` of TH-using packages
(miso, aeson, lens, …) doesn't demand non-existent .dyn_hi files.

Path C for JS (actually ship .dyn_hi alongside) still wants doing
eventually, but it needs deeper cabal work — a `host-ghc-options`
field or a Makefile-side .dyn_hi copy after the fact. Tracking
remains in task #66; this PR pivots to the #67 unblock.

* ghc: GHC Dynamic = sTargetIsDynamic && sTargetShipsDynLibs

Per review feedback on PR #187: the target's settings file should
COMPLETELY control GHC Dynamic. hostIsDynamic (rts_isDynamic()'s
CPP DYNAMIC macro) is a property of the GHC binary, not of the
target it's currently compiling for; consulting it leaks a
non-per-target signal into a per-target answer.

Drop hostIsDynamic from the GHC Dynamic computation. Split the
single `sTargetShipsDynLibs` into two orthogonal dials:

  sTargetIsDynamic     ← "target is dynamic" settings key
                         (the GHC for this target is capable of
                          producing dynamic output — -dynamic /
                          -dynamic-too honoured)

  sTargetShipsDynLibs  ← "target ships dynamic libraries" key
                         (the lib tree actually has .dyn_hi / .so)

GHC Dynamic = (sTargetIsDynamic && sTargetShipsDynLibs)

The axes are independent on purpose — a target can be dynamic-
capable but not currently ship dyn artifacts (a slimmed bindist),
or have shipped artifacts but a vanilla iserv that doesn't load
them. Both default True for backward compatibility with bindists
that predate the keys.

Per-target Makefile knobs:
  STAGE3_<triple>_TARGET_IS_DYNAMIC       = YES|NO (default YES)
  STAGE3_<triple>_TARGET_SHIPS_DYN_LIBS   = YES|NO (default YES)

JS target overridden to NO/NO — the JS iserv runs vanilla (no
dlopen in the JS runtime) and the lib tree ships no .dyn_hi
(Path C doesn't apply to JS yet, see #66). Both reported as NO,
so end-user cabal-install stops auto-enabling library-dynamic on
JS and TH-using packages compile without demanding .dyn_hi files.

Settings file end-users can hand-edit both keys to flip the dial
for an installed bindist (testing slimmed scenarios, etc.).

* ghc: GHC Profiled + Support dynamic-too also target-settings driven

Round out the per-target dial work from the previous commit:

  Support dynamic-too:
    was   `not isWindows`
    now   `not isWindows && sTargetIsDynamic`

  GHC Profiled:
    was   `hostIsProfiled` (RTS-baked-in)
    now   `sTargetIsProfiled && sTargetShipsProfLibs`

`Support dynamic-too` keeps the Windows guard as defence-in-depth
for pre-this-patch bindists on Windows that lack the key (default
sTargetIsDynamic=True would otherwise regress them to YES).

`GHC Profiled` mirrors `GHC Dynamic`'s two-dial design exactly,
with two new per-target settings keys:
  "target is profiled"               (sTargetIsProfiled)
  "target ships profiling libraries" (sTargetShipsProfLibs)

(Note: `Debug on` stays `debugIsOn`. That's a CPP constant set at
GHC binary compile-time describing whether the COMPILER itself was
built with -DDEBUG. It's legitimately a property of the binary, not
of the target — unlike Dynamic/Profiled which had a multi-target
asymmetry.)

Makefile side:
  * The existing per-target sed-injection for the cross targets'
    lib/targets/<triple>/lib/settings now writes all four keys
    in one go.
  * NEW: native lib/settings injection (line ~617, in the stage1
    rule that calls ghc-toolchain-bin --output-settings for the
    host platform). Native gets dyn dials = YES iff DYNAMIC=1, prof
    dials always NO (stage2 isn't built -prof). Without this the
    native target's settings file would be missing the keys and
    fall back to the IO.hs default of True for prof — which would
    have wrongly reported GHC Profiled=YES on our non-prof native.

JS target overrides extended:
  STAGE3_javascript-unknown-ghcjs_TARGET_IS_PROFILED     = NO
  STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_PROF_LIBS = NO
(matches the JS reality: vanilla iserv, no .p_hi shipped).

Defaults in the Makefile injection: dyn dials YES, prof dials NO.
Reflects the typical post-this-patch bindist where dyn libs ship
but prof libs don't (since stage2 isn't built -prof). Per-target
overrides via Make vars if any target needs to disagree.

* ci: drop standalone Cross: WASM + Cross: JS jobs (cross-multi is sole)

The multi-target bindist subsumes both targets — wasm32-unknown-wasi
and javascript-unknown-ghcjs ship together in one tarball via the
ghcup-multi-target-0.1.0.yaml channel. The standalone wasm + JS
bindist channels are not maintained anymore; there's no reason to
double-build them in CI.

Removed:
  * job `cross-js` (146 lines) — was darwin-only (vestige predating
    the host-matrix expansion).
  * job `cross-wasm` (442 lines) — matrixed across all 3 hosts with
    its own wasm32-wasi-* tag-trigger release upload.

Kept:
  * job `cross-multi` (matrixed across all 3 hosts) — builds the
    multi-target bindist + uploads to the matching multi-* GitHub
    Release on tag push. This is the single Cross job now.

Trigger header updated:
  * tags: only `multi-*` (was `wasm32-wasi-*` + `multi-*`)
  * Comment rewritten to describe the new single-channel reality.

Internal cross-references (`same as Cross: WASM`, `same as Cross: JS`,
etc.) inside cross-multi stripped — those were narrative decorations
pointing at jobs that no longer exist.

Net diff: -576 lines.

Final DAG:
  build (matrix:6) ──┬─> test         (matrix:6, needs:build)
                     └─> cross-multi  (matrix:3, needs:build)
Comment thread cabal.project.stage0
type: git
location: https://github.com/stable-haskell/Cabal.git
tag: 44817477ff6d22de4bfa4307e061df58f319d3b6
tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What tag/branch is this? Why do we use this commit?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

44817477ff6d22de4bfa4307e061df58f319d3b6 used to be wip/angerman/compile-less.

It looks like in build: pin patched Cabal that emits relocatable rpaths you then changed to 723df03ec80a1e1b9b2fefde71f1ace8ba216287 and finally to 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d in build: rev Cabal pin to unconditional rpath relativization (6a5ce816)

Comment thread cabal.project.stage3.settings.in Outdated
Comment on lines +47 to +63
-- NOTE: an earlier draft of #66 (PR #187) attempted Path C for the
-- JS target via:
-- if arch(javascript)
-- package *
-- ghc-options: -dynamic-too
-- but cabal-install applies `ghc-options` differently from `shared`
-- in the dual-compiler split: `shared` is properly per-target-arch
-- (only host packages see it; native build-side packages like alex,
-- happy-lib do NOT), but `ghc-options` leaks to build-side compiles
-- regardless of the arch conditional. The native stage2 is built
-- DYNAMIC=0 (no .dyn_hi for the host arch), so alex's first .hs
-- module failed with
-- Prelude.dyn_hi: does not exist (No such file or directory)
-- when compiled by the build compiler with -dynamic-too.
-- Path C for JS therefore needs deeper cabal work (or a Makefile-
-- side .dyn_hi copy after the fact); the GHC Dynamic settings dial
-- in the sibling commit (#67) is the proper long-term answer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this?

Comment on lines -11 to -12
package *
@ALL_PACKAGES@@STAGE3_EXTRA_PKG@

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we drop this?

Comment thread cabal.project.stage3.settings.in Outdated

constraints:
@CONSTRAINTS@
rts +dynamic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why hardcode this? We do set it in configure.ac?

angerman added 7 commits June 7, 2026 11:26
Four review comments from PR #184 after the squash-merge of #187:

1. cabal.project.stage0:4 ("What tag/branch is this? Why this commit?")
   The Cabal SHA 6a5ce816 is the head of stable-haskell/Cabal branch
   `feat/rpath-relativize-absolute` (= stable-haskell/Cabal PR #368)
   patching Link.hs to relativize absolute rpaths unconditionally,
   fixing the darwin LC_RPATH leak from task #51 without flipping
   cabal's `relocatable: True` (which also breaks our bindist's
   post-stage2 path rewriting). Add explanatory headers to stage0
   and back-reference from stage1 + stage2 (same pin) + stage3
   (deliberately different pin, since cross-build doesn't hit the
   macOS rpath issue and we don't want to validate the rpath patch
   on the wasm path).

2. stage3.settings.in:63 ("Do we still need this?")
   The 17-line NOTE about the failed JS Path C `ghc-options:
   -dynamic-too` attempt is commit-message-grade history, not
   code-comment material. The reasoning lives in #187's commit
   message and the lode plan doc. Removed.

3. stage3.settings.in:12 ("Why did we drop this?")
   #187 replaced the original clean 4-line JS section description
   ("FALSE → settings do NOT apply") with a 17-line narrative
   claiming `arch=javascript → conditional TRUE (ghc-options
   branch)` — but the file has no `if arch(javascript)` block, so
   the comment misdescribed the code. Restored the accurate
   FALSE-based description and added a one-line pointer to the
   per-target settings dials from #67 (where JS .dyn_hi shipping
   actually gets driven from).

4. stage3.settings.in:66 ("Why hardcode this? configure.ac sets it.")
   The `rts +dynamic` constraint is wasm-only (RTS needs the
   dynamic flag because wasm32 builds shared libs); it has nothing
   to do with the JS or native build-side paths. Moved inside
   the `if arch(wasm32)` block alongside `shared: True` and
   `executable-dynamic: True` so the wasm-only intent is visible
   in the code, not just the comment. Verified cabal-install
   accepts `constraints:` inside an `if arch(...)` block.
Address review feedback on the two-Cabal split. The diff between the
old stage3 SHA (44817477) and the unified SHA (6a5ce816) is exactly
two commits, +46/-1 line, all in `Distribution.Simple.GHC.Build.Link`
— the host-linker rpath handling.  wasm-ld doesn't honour rpaths and
neither does the JS backend, so the patch is a true no-op on stage3's
cross-build outputs.  Carrying two Cabals just adds maintenance
overhead (every upstream Cabal change has to be re-applied or
re-validated twice) for no real isolation.

Unifies the pin and rewrites the cabal.project.stage0/stage3 header
comments to reflect this.
Carrying a `.in` file + autoconf substitution machinery but
hard-coding the package fields and constraints in the .in defeats
the point of having both.  Re-use the same APPEND_PKG_FIELD /
APPEND_CONSTRAINT data that stage2.settings.in already consumes.

The reason stage3 went hardcoded before was indentation: stage2 puts
@ALL_PACKAGES@ at column 0 / @Constraints@ under `constraints:`, both
of which line up with the 2-space indent the substitution variables
ship with.  Stage3 wraps in `if arch(wasm32)`, so the same content
needs to be 4-space-indented instead.

Add 4-space-indented variants in configure.ac (@ALL_PACKAGES_STAGE3@
and @CONSTRAINTS_STAGE3@, re-indented from the existing ALL_PACKAGES
/ CONSTRAINTS), and use them in stage3.settings.in under the
`if arch(wasm32)` block.  Now `--enable-dynamic` (DYNAMIC=1) flips
stage2 and stage3 in lockstep, with one source of truth for the
package-fields / constraint list.

NOTE: this couples stage3-wasm builds to DYNAMIC=1 — the wasm
target genuinely needs `shared: True` to produce .dyn_hi/.so for
end-user TH, so `make stage3-wasm... DYNAMIC=1` is now the
required invocation.  CI already passes it; document in commit
message so anyone running it locally knows.
autoconf substitutes @ALL_PACKAGES_STAGE3@ / @CONSTRAINTS_STAGE3@
wherever they appear in the input — including inside Haskell-style
`-- ...` comments — and the multi-line replacement body broke across
the comment boundary, producing invalid cabal syntax at
stage3.settings:33:30.

CI caught this on the first run of the @ALL_PACKAGES_STAGE3@
substitution against the wasm cross-build:

  cabal.project.stage3.settings:33:30: error:
    unexpected '/'
    expecting space or end of input
       33 |     executable-dynamic: True /     rts +dynamic variants — so passing

The fix is the same one used elsewhere in this repo (commit 3f4aa3b
on stage3.settings.in for the same hazard with the older @ALL_PACKAGES@
token): refer to the substitution mechanism without writing the literal
autoconf marker.
The legacy `Channel e2e (WASM)` workflow tested the single-target
`ghcup-wasm.yaml` channel by building both the hello and miso-counter
templates against the shipped wasm cross-compiler.  The newer
`Channel e2e (MULTI)` workflow already covers hello-worlds for all
three frontends (native / wasm / JS) via the multi-target bindist,
but lacked the TH-at-scale miso-counter coverage — and that's the
fragile, end-user-relevant signal: hello-worlds don't exercise the
dyld / JSFFI / cabal-dual-compiler chain at any meaningful depth.

Fold the miso-counter step (with its 60s diagnostic monitor) into
e2e-MULTI, run it via the wasm frontend of the multi-target bindist,
and bump the matrix timeout 45 → 60 min to fit it.  Delete
channel-e2e-wasm.yml: the multi-target bindist supersedes the legacy
single-target wasm channel and we don't want to maintain two near-
identical CI surfaces.

JS-target miso coverage is a follow-up — the existing
stable-haskell-wasm-miso-counter template's Makefile is wasm-specific
and a parallel JS template (or a target-agnostic cabal.project) doesn't
exist yet.  The per-target dial from #67 makes JS-target miso buildable
in principle (it no longer demands .dyn_hi from a JS sysroot that
doesn't ship them), but the templating work is independent of this PR.
Follow-up to the e2e-WASM retirement in the previous commit
(d17a78c) — that commit deleted the workflow but didn't manage to
land its companion edits to channel-e2e-multi.yml in the same commit
(amended local, but the prior SHA had already been pushed and we don't
auto-force-push).

This commit adds the miso-counter step we discussed (50+ TH-heavy
deps, 60s diagnostic monitor, .wasm artifact verification), bumps the
matrix timeout 45 → 60 min to fit it, and refreshes the workflow
header / comments so they no longer cross-reference the now-deleted
e2e-WASM workflow.
The first run of the new miso-counter step (run 27091838526, all 3
matrix entries) failed at the solver stage with

  [__1] unknown package: host:jsaddle-wasm (dependency of host:myapp)

The cabal package index was never populated in this workflow — the
hello steps above don't touch Hackage so they don't reveal the gap.
The retired e2e-WASM workflow had `cabal update` in its sanity step;
when its miso-counter step ran, the index was already populated.

Add an explicit `cabal update` immediately before the miso fetch.
angerman added a commit to stable-haskell/notebooks that referenced this pull request Jun 7, 2026
Use stable-haskell/ghc#184's multi-target bindist (native + wasm + JS in one
ghcup install) instead of the separate wasm32-wasi cross-compiler + upstream
native GHC:
- §2 installs from ghcup-multi-target-0.1.0.yaml: ghcup install/set ghc
  multi-9.14.0.stable.1. One install provides ghc, wasm32-unknown-wasi-ghc, and
  javascript-unknown-ghcjs-ghc, symlinked into ~/.ghcup/bin (exeSymLinked bin/**).
- drop the separate 'ensure native ghc' step — multi includes native.
- WASM_VERSION points at the multi GHC dir so the template Makefile finds
  post-link.mjs; PATH simplifies to ~/.ghcup/bin.
- §2.4 wasi-sdk retained: still not bundled (channel viPreInstall says so).
- recreate the notebook generator as a committed wasm-hello.nb/notebook.py
  (+ 'make notebook') so regeneration is reproducible.
@angerman angerman merged commit ee9cc12 into feat/wasm-cross-ghcup Jun 8, 2026
18 checks passed
@angerman angerman deleted the feat/multi-target-bindist branch June 8, 2026 00:43
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.

2 participants