From 63ccd14bca798940ccc47b314a2fb2871599bdf5 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 4 Jun 2026 11:44:40 +0900 Subject: [PATCH 01/14] build: multi-target bindist (native + wasm + JS in one tarball) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//` carries the native package db. * `lib/targets//` 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. --- Makefile | 40 ++++++++++++++ mk/multi-target-bindist-Makefile | 45 +++++++++++++++ mk/multi-target-configure.sh | 36 ++++++++++++ mk/multi-target-relocate.sh | 94 ++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 mk/multi-target-bindist-Makefile create mode 100755 mk/multi-target-configure.sh create mode 100755 mk/multi-target-relocate.sh diff --git a/Makefile b/Makefile index 4e5b8645d6a0..e24f99eb626a 100644 --- a/Makefile +++ b/Makefile @@ -1170,6 +1170,46 @@ $(DIST_DIR)/haskell-toolchain.tar.gz: $(STAGE2_STAMP) | stable-cabal stage3-java bin/cabal @echo "::endgroup::" +# Multi-target GHC bindist: +# native (lib/$(HOST_PLATFORM)) + wasm32-unknown-wasi + javascript-unknown-ghcjs +# +# The stage2 native ghc binary dispatches via argv[0]: invoking +# bin/wasm32-unknown-wasi-ghc reads lib/targets/wasm32-unknown-wasi/lib/settings, +# bin/javascript-unknown-ghcjs-ghc reads .../javascript-unknown-ghcjs/lib/settings, +# bin/ghc reads lib/settings. Same physical binary, three targets. +# +# We use `tar czhf` (dereference symlinks) so the cross-prefixed bin entries +# (which are symlinks to bin/ per the stage3 rule's $(LN_SF) loop) become +# standalone copies in the tarball. The native bin/ entries are real +# files. Cost: ~30 MB extra vs preserving symlinks; predictability gain: +# ghcup's targetPattern glob picks up real files reliably regardless of +# its symlink-following behaviour. +# +# JS doesn't use ghc-iserv (the JS backend has its own evaluator); filter +# it out of the JS bin list so we don't ship a useless prefixed copy. +$(DIST_DIR)/ghc-multi-target.tar.gz: | stage3-wasm32-unknown-wasi stage3-javascript-unknown-ghcjs + @echo "::group::Creating ghc-multi-target.tar.gz..." + @cp -f mk/multi-target-relocate.sh $(DIST_DIR)/relocate.sh + @chmod +x $(DIST_DIR)/relocate.sh + @cp -f mk/multi-target-configure.sh $(DIST_DIR)/configure + @chmod +x $(DIST_DIR)/configure + @cp -f mk/multi-target-bindist-Makefile $(DIST_DIR)/Makefile + tar czhf $@ \ + --directory=$(DIST_DIR) \ + $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \ + $(foreach exe,$(STAGE3_EXECUTABLES),bin/wasm32-unknown-wasi-$(exe)$(EXE_EXT)) \ + $(foreach exe,$(filter-out ghc-iserv,$(STAGE3_EXECUTABLES)),bin/javascript-unknown-ghcjs-$(exe)$(EXE_EXT)) \ + lib/ghc-usage.txt \ + lib/ghci-usage.txt \ + lib/package.conf.d \ + lib/settings \ + lib/template-hsc.h \ + lib/$(HOST_PLATFORM) \ + lib/targets/wasm32-unknown-wasi \ + lib/targets/javascript-unknown-ghcjs \ + relocate.sh configure Makefile + @echo "::endgroup::" + $(DIST_DIR)/tests.tar.gz: @echo "::group::Creating tests.tar.gz..." @tar czf $@ \ diff --git a/mk/multi-target-bindist-Makefile b/mk/multi-target-bindist-Makefile new file mode 100644 index 000000000000..8c9874c15bc5 --- /dev/null +++ b/mk/multi-target-bindist-Makefile @@ -0,0 +1,45 @@ +# Makefile — bundled with the stable-haskell multi-target GHC bindist. +# +# ghcup's `install ghc ` flow is "./configure --prefix=DIR && +# make install". The bindist is already laid out as bin/ + lib/ + +# relocate.sh; install just copies that layout into the prefix and +# refreshes the per-target package-db caches. + +include config.mk + +.PHONY: install + +install: + @mkdir -p "$(DESTDIR)$(prefix)" + @cp -PR bin "$(DESTDIR)$(prefix)/" + @cp -PR lib "$(DESTDIR)$(prefix)/" + @if [ -f relocate.sh ]; then \ + cp relocate.sh "$(DESTDIR)$(prefix)/relocate.sh"; \ + chmod +x "$(DESTDIR)$(prefix)/relocate.sh"; \ + echo "Running relocate.sh to recache all per-target package dbs..."; \ + "$(DESTDIR)$(prefix)/relocate.sh" || \ + { echo "warning: relocate.sh failed; package.cache may be stale" >&2 ; }; \ + fi + @echo "" + @echo "Tool prerequisites for each shipped target:" + @if ! command -v node >/dev/null 2>&1; then \ + echo " [missing] node >= 22 — needed by wasm32 + javascript target TH eval"; \ + else \ + echo " [ok] node ($$(node --version 2>/dev/null))"; \ + fi + @if ! command -v wasm32-unknown-wasi-clang >/dev/null 2>&1; then \ + echo " [missing] wasm32-unknown-wasi-clang — needed by wasm32 target (install wasi-sdk via ghc-wasm-meta bootstrap)"; \ + else \ + echo " [ok] wasm32-unknown-wasi-clang"; \ + fi + @if ! command -v emcc >/dev/null 2>&1; then \ + echo " [missing] emcc — needed by javascript-unknown-ghcjs target (install emscripten)"; \ + else \ + echo " [ok] emcc ($$(emcc --version 2>/dev/null | head -1))"; \ + fi + @echo "" + @echo "Installed stable-haskell multi-target GHC to: $(DESTDIR)$(prefix)" + @echo "Invocations:" + @echo " $(prefix)/bin/ghc (native)" + @echo " $(prefix)/bin/wasm32-unknown-wasi-ghc (wasm cross)" + @echo " $(prefix)/bin/javascript-unknown-ghcjs-ghc (JS cross)" diff --git a/mk/multi-target-configure.sh b/mk/multi-target-configure.sh new file mode 100755 index 000000000000..516d10e26071 --- /dev/null +++ b/mk/multi-target-configure.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# configure — bundled with the stable-haskell multi-target GHC bindist. +# +# Multi-target bindist combines: native ghc, wasm32-unknown-wasi-ghc, +# javascript-unknown-ghcjs-ghc. All three are the same physical binary +# (stage2 native ghc), dispatched at runtime via argv[0]: +# ghc -> uses lib/settings + lib/$HOST_PLATFORM +# wasm32-unknown-wasi-ghc -> uses lib/targets/wasm32-unknown-wasi/lib/settings +# javascript-unknown-ghcjs-ghc -> uses lib/targets/javascript-unknown-ghcjs/lib/settings +# +# Minimal autoconf-style stub so that ghcup's standard +# "./configure --prefix=DIR && make install" install path works. + +set -e + +prefix= +while [ $# -gt 0 ]; do + case "$1" in + --prefix=*) prefix="${1#--prefix=}" ;; + --prefix) shift; prefix="$1" ;; + *) ;; # accept and ignore other autoconf args + esac + shift +done + +if [ -z "$prefix" ]; then + echo "error: --prefix=DIR is required" >&2 + exit 1 +fi + +cat > config.mk </ subdirectories — keeps +# this script tolerant if some build flavour omits one of the targets. +if [ -d "$PREFIX/lib/targets" ]; then + for target_dir in "$PREFIX/lib/targets"/*/; do + [ -d "$target_dir" ] || continue + triple="$(basename "$target_dir")" + cross_pkg="$PREFIX/bin/${triple}-ghc-pkg" + cross_db="$target_dir/lib/package.conf.d" + if [ -x "$cross_pkg" ] && [ -d "$cross_db" ]; then + echo "[$triple] recaching $cross_db" + "$cross_pkg" recache --package-db "$cross_db" + elif [ -d "$cross_db" ]; then + # Some cross targets ship no per-target ghc-pkg (e.g. older JS bindists). + # Fall back to the native ghc-pkg; the .conf files are arch-agnostic + # ASCII so the native binary can read+recache them. + echo "[$triple] using native ghc-pkg to recache $cross_db (no $cross_pkg)" + "$NATIVE_GHC_PKG" recache --package-db "$cross_db" || \ + echo "[$triple] recache failed — package.cache may be stale" >&2 + fi + done +fi + +# ---------------------------------------------------------------------------- +# Tool prerequisites — warn but don't fail +# ---------------------------------------------------------------------------- +need_node= +need_emcc= +need_wasi_sdk= +[ -d "$PREFIX/lib/targets/wasm32-unknown-wasi" ] && { need_node=1; need_wasi_sdk=1; } +[ -d "$PREFIX/lib/targets/javascript-unknown-ghcjs" ] && { need_node=1; need_emcc=1; } + +warn_missing= +if [ -n "$need_node" ] && ! command -v node >/dev/null 2>&1; then + warn_missing="${warn_missing} node (Node.js >= 22)" +fi +if [ -n "$need_wasi_sdk" ] && ! command -v wasm32-unknown-wasi-clang >/dev/null 2>&1; then + warn_missing="${warn_missing} wasm32-unknown-wasi-clang (wasi-sdk)" +fi +if [ -n "$need_emcc" ] && ! command -v emcc >/dev/null 2>&1; then + warn_missing="${warn_missing} emcc (emscripten)" +fi + +if [ -n "$warn_missing" ]; then + cat >&2 < Date: Thu, 4 Jun 2026 11:44:56 +0900 Subject: [PATCH 02/14] stage3: scope shared+executable-dynamic to wasm32 via if arch() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cabal.project.stage3.settings.in | 40 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/cabal.project.stage3.settings.in b/cabal.project.stage3.settings.in index 4b4bbd0db205..0be702118ae4 100644 --- a/cabal.project.stage3.settings.in +++ b/cabal.project.stage3.settings.in @@ -1,15 +1,37 @@ -- cabal.project.stage3.settings - generated by configure from .in template -- Do not edit this file directly; edit cabal.project.stage3.settings.in instead. --- Empty (or comment-only) blocks are fine if features are disabled. -- --- This mirrors cabal.project.stage2.settings.in so that --enable-dynamic --- propagates symmetrically to the stage3 (cross-target) build. Without this, --- stage3 wasm-cross would produce no .dyn_hi files even with DYNAMIC=1, so --- end-user TH-heavy apps (miso, jsaddle, aeson, ...) couldn't compile against --- the wasm bindist. +-- Multi-target stage3: applies dynamic library settings ONLY to wasm32 +-- via cabal's `if arch(wasm32)` conditional, which cabal evaluates against +-- the --with-compiler's target arch per-invocation. +-- +-- * stage3-wasm32-unknown-wasi (--with-compiler=wasm32-...-ghc): +-- arch=wasm32 → conditional TRUE → shared+executable-dynamic apply +-- → builds wasm-target libs with .dyn_hi + .so for end-user TH +-- (miso, jsaddle, aeson, …) +-- +-- * stage3-javascript-unknown-ghcjs (--with-compiler=javascript-...-ghc): +-- arch=javascript → conditional FALSE → settings do NOT apply +-- → no shared:True flowing into emcc/wasm-ld (which can't produce +-- .so for the JS backend; the alternative breaks with +-- `wasm-ld: error: unknown argument: -h`) +-- +-- * Native build-side packages (compiled with --with-build-compiler=ghc, +-- i.e. happy-lib, alex, deriveConstants, Setup.hs scripts): +-- arch=native (x86_64-linux / aarch64-darwin / etc.) → FALSE +-- → no shared, no -dynamic-too codepath +-- → builds against vanilla native base (no need for native .dyn_hi) +-- +-- Hardcoded — no per-package autoconf substitution (we deliberately +-- avoid the literal variable name in this comment so autoconf does +-- not expand it). DYNAMIC=1 / DYNAMIC=0 has no effect on stage3 +-- (the wasm target ALWAYS needs shared:True regardless of how +-- stage2 was built; the conditional handles JS/native exclusion). -package * -@ALL_PACKAGES@@STAGE3_EXTRA_PKG@ +if arch(wasm32) + package * + shared: True + executable-dynamic: True constraints: -@CONSTRAINTS@ + rts +dynamic From 7c474751a4c310f33298d33774d953772c05ecd4 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 4 Jun 2026 11:45:26 +0900 Subject: [PATCH 03/14] ci(nix-ci): add Cross: MULTI job + darwin bindist verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/nix-ci.yml | 326 ++++++++++++++++++++++++++++++++++- 1 file changed, 325 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml index be601a7254d9..63d22798e29c 100644 --- a/.github/workflows/nix-ci.yml +++ b/.github/workflows/nix-ci.yml @@ -10,7 +10,11 @@ on: # Tag pushes matching wasm32-wasi-* trigger the Cross: WASM jobs to # additionally upload their bindist tarballs to the matching GitHub # Release, populating the stable-haskell ghcup channel. - tags: [wasm32-wasi-*] + # multi-* trigger the Cross: MULTI job for the same reason (separate + # tag namespace, separate channel YAML). + tags: + - 'wasm32-wasi-*' + - 'multi-*' workflow_dispatch: @@ -1133,3 +1137,323 @@ jobs: tag_name: ${{ github.ref_name }} files: _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz fail_on_unmatched_files: true + + # --------------------------------------------------------------------------- + # Cross: MULTI — multi-target bindist combining native + wasm + JS. + # + # Reuses the Cross: WASM machinery (dynamic1 stage2 download, devx shell, + # wasi-sdk + Node install, patchelf for ELF interpreter + $ORIGIN rpath) + # and adds emscripten install for the JS target. The combined Makefile + # rule `_build/dist/ghc-multi-target.tar.gz` depends on both + # stage3-wasm32-unknown-wasi AND stage3-javascript-unknown-ghcjs, so + # this job builds BOTH cross trees + the multi-target tarball in one + # CI cycle. + # + # `continue-on-error: true` matches Cross: WASM — failures are + # informative but don't block the main pipeline. + # --------------------------------------------------------------------------- + cross-multi: + name: "Cross: MULTI / ${{ matrix.plat }}" + needs: [build] + if: ${{ !cancelled() && contains(fromJSON('["success", "failure"]'), needs.build.result) }} + runs-on: ${{ fromJSON(matrix.runner) }} + continue-on-error: true + + env: + # Pinned same as Cross: JS — emsdk's git tag, used in the + # `Install emscripten` step's `git clone --branch ${{ env.EMSDK_VERSION }}`. + # Workflow-level env doesn't exist on this workflow; each job + # needing EMSDK_VERSION declares it itself. + EMSDK_VERSION: "3.1.74" + + strategy: + fail-fast: false + matrix: + include: + - { plat: aarch64-darwin, devx-plat: aarch64-darwin, runner: '["self-hosted", "nix"]' } + - { plat: x86_64-linux, devx-plat: x86_64-linux, runner: '"ubuntu-latest"' } + - { plat: aarch64-linux, devx-plat: aarch64-linux, runner: '"ubuntu-24.04-arm"' } + + steps: + - name: Clean workspace + run: | + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/usr/local/bin" >> "$GITHUB_PATH" + + echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV" + + echo "=== Disk usage before cleanup ===" + df -h / || true + df -h "$GITHUB_WORKSPACE" || true + rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true + rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true + rm -rf ~/.ghc-wasm ~/.emsdk || true + sudo rm -f /usr/local/bin/devx 2>/dev/null || true + export PATH="/nix/var/nix/profiles/default/bin:$PATH" + nix-collect-garbage -d 2>/dev/null || true + nix-store --gc 2>/dev/null || true + echo "=== Disk usage after cleanup ===" + df -h / || true + + - uses: actions/checkout@v4 + with: + submodules: "recursive" + fetch-depth: 1 + + - name: Minimize source tree + run: | + GIT_COMMIT_ID=$(git rev-parse HEAD) + echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV" + rm -rf .git testsuite docs || true + find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true + df -h / || true + + - name: Ensure devx prerequisites + run: | + export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" + command -v zstd || nix-env -iA nixpkgs.zstd + sudo mkdir -p /usr/local/bin + + - uses: input-output-hk/actions/devx@latest + with: + platform: ${{ matrix.devx-plat }} + compiler-nix-name: 'ghc98' + minimal: true + ghc: true + + - name: Download dist + uses: actions/download-artifact@v4 + with: + # dynamic1 stage2 (same rationale as Cross: WASM): + # 1. happy-lib / alex etc. need native Prelude.dyn_hi at build-side + # 2. the dyn-linked native binary becomes bin/ghc + bin/-ghc + # in the multi-target bindist and needs lib/$(HOST_PLATFORM) + # shipped alongside. + name: ${{ matrix.plat }}-dynamic1-dist + path: _build/dist + + - name: Set up stage2 from dist + run: | + chmod +x _build/dist/bin/* + mkdir -p _build/stage2/bin _build/stage2/lib + for exe in _build/dist/bin/*; do + ln -sf "$(pwd)/$exe" "_build/stage2/bin/$(basename $exe)" + done + if [[ -f _build/dist/lib/settings ]]; then + cp -rfp _build/dist/lib/settings _build/stage2/lib/ + fi + + - name: Update hackage + shell: devx {0} + run: | + set -eo pipefail + export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" + mkdir -p "$CABAL_DIR" + _build/dist/bin/cabal update + + - name: Free disk for cross builds + run: | + rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages" "${CABAL_DIR:-~/.cabal-devx}/logs" "${CABAL_DIR:-~/.cabal-devx}/store" || true + rm -rf ~/.cabal/store || true + df -h / || true + + # Install wasi-sdk (wasm-target C toolchain) + Node 22 + wasmtime, same + # logic as Cross: WASM since this job builds the wasm half of the + # multi-target bindist. + - name: Install wasi-sdk + Node 22 + shell: devx {0} + run: | + set -eux + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + case "$(uname -s)" in + Linux) + export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH" + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends jq unzip zstd wabt + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y --no-install-recommends nodejs + ;; + Darwin) + export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" + for tool in jq unzip zstd node; do + if ! command -v "$tool" >/dev/null 2>&1; then + pkg="$tool" + [ "$tool" = "node" ] && pkg="nodejs" + /nix/var/nix/profiles/default/bin/nix-env -iA "nixpkgs.$pkg" + fi + done + ;; + esac + curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh | \ + FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh + + # Install emscripten (JS-target C toolchain). EMSDK_VERSION is set in + # the workflow env (same as Cross: JS). + - name: Install emscripten + shell: devx {0} + run: | + set -eux + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + git clone --depth 1 --branch ${{ env.EMSDK_VERSION }} https://github.com/emscripten-core/emsdk.git + cd emsdk + ./emsdk install ${{ env.EMSDK_VERSION }} + ./emsdk activate ${{ env.EMSDK_VERSION }} + + - name: Build multi-target bindist + shell: devx {0} + run: | + set -eux + export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + # Source emscripten env (provides emcc on PATH) + source emsdk/emsdk_env.sh + # Symlink wasi-sdk's wasm32-wasi-* tools as wasm32-unknown-wasi-* + # (same as Cross: WASM — autoconf canonical triple bridging). + WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin" + for tool in ar nm ranlib strip; do + ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" + done + for tool in clang clang++; do + ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" + done + export PATH=$PATH:$WASI_BIN + # DYNAMIC=1 for the same reason as Cross: WASM — both wasm and JS + # targets need .dyn_hi for end-user TH builds (miso, aeson, ...). + make DYNAMIC=1 DIST_BUILD=1 \ + CABAL=$PWD/_build/dist/bin/cabal \ + GHC_TOOLCHAIN_BIN=$PWD/_build/dist/bin/ghc-toolchain-bin \ + DERIVE_CONSTANTS_BIN=$PWD/_build/dist/bin/deriveConstants \ + GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \ + HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ + _build/dist/ghc-multi-target.tar.gz + # Rename to add platform suffix (matches Cross: WASM scheme). + cp _build/dist/ghc-multi-target.tar.gz \ + _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + ls -lh _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + echo "SHA256:" + shasum -a 256 _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + + # Patchelf only the host-arch ELFs (bin/* native binaries + lib// + # libHS*.so). The wasm32 and JS target libraries aren't ELFs — they're + # .wasm modules and .js files respectively — patchelf skips them. + - name: Normalize ELF interpreters for portability (Linux only) + if: ${{ !cancelled() && contains(matrix.plat, 'linux') }} + shell: devx {0} + run: | + set -eux + export PATH="$PATH:/usr/bin:/usr/sbin" + if ! command -v patchelf >/dev/null 2>&1; then + sudo apt-get install -y --no-install-recommends patchelf + fi + case "${{ matrix.plat }}" in + x86_64-linux) INTERP=/lib64/ld-linux-x86-64.so.2; HOST_DIR=x86_64-unknown-linux ;; + aarch64-linux) INTERP=/lib/ld-linux-aarch64.so.1; HOST_DIR=aarch64-unknown-linux ;; + *) echo "::error::unexpected matrix.plat=${{ matrix.plat }}"; exit 1 ;; + esac + + TGZ=_build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + STAGE="$(mktemp -d)" + tar -C "$STAGE" -xzf "$TGZ" + + echo "═══ Patchelfing binaries to interpreter $INTERP ═══" + patched=0 + while IFS= read -r bin; do + if file -L "$bin" 2>/dev/null \ + | grep -q "ELF.*dynamically linked.*interpreter /nix/store"; then + echo " patching: ${bin#$STAGE/}" + patchelf --set-interpreter "$INTERP" "$bin" + patchelf --remove-rpath "$bin" + patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$HOST_DIR" "$bin" + patched=$((patched + 1)) + fi + done < <(find "$STAGE" -type f -executable) + echo "═══ Patched $patched binaries ═══" + test "$patched" -gt 0 || { echo "::error::no ELF binaries patched"; exit 1; } + + # Fix rpath on the shipped host .so files (same as Cross: WASM). + echo "═══ Setting rpath on lib/$HOST_DIR/*.so ═══" + so_patched=0 + for so in "$STAGE"/lib/"$HOST_DIR"/*.so; do + [ -f "$so" ] || continue + patchelf --force-rpath --set-rpath "\$ORIGIN" "$so" 2>/dev/null || true + so_patched=$((so_patched + 1)) + done + echo "═══ Set rpath on $so_patched shared libs ═══" + + rm "$TGZ" + tar -C "$STAGE" -czf "$TGZ" . + ls -lh "$TGZ" + echo "SHA256 after patchelf:" + shasum -a 256 "$TGZ" + + # Darwin: verify the bindist is clean of build-host leaks. + # + # The Makefile's `stage2.dist` phase (CLEAN_DARWIN_DIST macro) + # strips two classes of leak at construction time, BEFORE the + # bindist tarball is built: + # + # (1) Absolute LC_RPATH entries like + # `/Volumes/WorkSpace/_work/ghc/ghc/_build/...` that + # macOS 15 dyld treats as fatal. + # (2) nix-store LC_LOAD_DYLIB install names for libiconv, + # libffi, libc++, libz, libresolv, libncurses — rewritten + # to their /usr/lib equivalents. + # + # This step just verifies — if the construction-site fix + # regresses, CI fails loud here rather than shipping a broken + # bindist that abort-traps on end-user macOS 15 hosts. + - name: Verify darwin bindist is clean (no /Volumes or nix-store leaks) + if: ${{ !cancelled() && matrix.plat == 'aarch64-darwin' }} + shell: devx {0} + run: | + set -euo pipefail + TGZ=_build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + STAGE="$GITHUB_WORKSPACE/.bindist-verify-$$" + rm -rf "$STAGE"; mkdir -p "$STAGE" + trap 'rm -rf "$STAGE"' EXIT + tar -C "$STAGE" -xzf "$TGZ" + + echo "═══ Scanning bindist Mach-O artefacts for build-host leaks ═══" + leaked=0 + while IFS= read -r f; do + file -L "$f" 2>/dev/null | grep -q 'Mach-O' || continue + if otool -l "$f" 2>/dev/null \ + | awk '/cmd LC_RPATH/{flag=1; next} flag && /path \/Volumes\//{found=1; exit} flag && /path /{flag=0} END{exit !found}'; then + echo "::error::leaked /Volumes/ LC_RPATH in $f" + otool -l "$f" | awk '/cmd LC_RPATH/{f=1; next} f && /path /{print; f=0}' + leaked=$((leaked + 1)) + fi + if otool -L "$f" 2>/dev/null | awk '/nix\/store/{exit 0} END{exit 1}'; then + echo "::error::leaked /nix/store LC_LOAD_DYLIB in $f" + otool -L "$f" | grep 'nix/store' || true + leaked=$((leaked + 1)) + fi + done < <(find "$STAGE" -type f \( -perm -u+x -o -name '*.dylib' \)) + + if [ "$leaked" -gt 0 ]; then + echo "::error::$leaked Mach-O files have build-host leaks. The Makefile's CLEAN_DARWIN_DIST step should have stripped these at construction time. Did stage2.dist run?" + exit 1 + fi + echo "::notice::darwin bindist is clean — CLEAN_DARWIN_DIST did its job." + + - name: Upload MULTI cross artifacts + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: ${{ matrix.plat }}-cross-multi + retention-days: 30 + path: _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + + # On a tag push matching multi-*, upload to that GitHub Release. + # (Separate tag namespace from wasm32-wasi-* so the two channels + # stay independent.) + - name: Upload bindist to release (on tag push) + if: ${{ startsWith(github.ref, 'refs/tags/multi-') }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz + fail_on_unmatched_files: true From daa20017c53c1f8ec095c353dcff21f95e0710ac Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 4 Jun 2026 11:45:43 +0900 Subject: [PATCH 04/14] build: pin patched Cabal (relocatable rpath relativization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cabal.project.stage0 | 2 +- cabal.project.stage1 | 2 +- cabal.project.stage2 | 2 +- configure.ac | 12 ++++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cabal.project.stage0 b/cabal.project.stage0 index d0dd66c25244..e5af03d9c5e1 100644 --- a/cabal.project.stage0 +++ b/cabal.project.stage0 @@ -1,7 +1,7 @@ source-repository-package type: git location: https://github.com/stable-haskell/Cabal.git - tag: 44817477ff6d22de4bfa4307e061df58f319d3b6 + tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d subdir: Cabal Cabal-syntax cabal-install diff --git a/cabal.project.stage1 b/cabal.project.stage1 index 06be903a3ffd..74cd7f353308 100644 --- a/cabal.project.stage1 +++ b/cabal.project.stage1 @@ -45,7 +45,7 @@ packages: source-repository-package type: git location: https://github.com/stable-haskell/Cabal.git - tag: 44817477ff6d22de4bfa4307e061df58f319d3b6 + tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d subdir: Cabal Cabal-syntax diff --git a/cabal.project.stage2 b/cabal.project.stage2 index 3de5c51778f3..8bcf5d474a60 100644 --- a/cabal.project.stage2 +++ b/cabal.project.stage2 @@ -88,7 +88,7 @@ packages: source-repository-package type: git location: https://github.com/stable-haskell/Cabal.git - tag: 44817477ff6d22de4bfa4307e061df58f319d3b6 + tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d subdir: Cabal Cabal-syntax diff --git a/configure.ac b/configure.ac index 007f7c78df80..753d52595095 100644 --- a/configure.ac +++ b/configure.ac @@ -45,6 +45,18 @@ AS_IF([test "x$enable_dynamic" = "xyes"], [ ALL_PACKAGES="" APPEND_PKG_FIELD([shared: True]) APPEND_PKG_FIELD([executable-dynamic: True]) + # NOTE: do NOT add `relocatable: True` here — even though it + # would activate the gated relativization codepath in our + # patched Cabal (libraries/Cabal feat/rpath-relativize-absolute), + # it ALSO makes cabal-install emit `library-dirs: ${pkgroot}/...` + # entries in the .conf files that the post-stage2 Makefile + # bindist-assembly rewriting can't cope with — producing + # paths like `_build/dist/lib/lib/...` (doubled `lib/`) that + # then break stage3 / Cross: MULTI consumers. + # + # The patched Cabal therefore relativizes absolute rpaths + # unconditionally (not gated on `relocatable`); see lode/ + # rpath-leak-investigation.md for the full rationale. APPEND_CONSTRAINT([rts +dynamic]) ]) From c01b19f8b172d3e31fa69e0e12b83585a299631c Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 4 Jun 2026 11:46:03 +0900 Subject: [PATCH 05/14] ci(channel-e2e): multi-target gate, darwin PATH fix, fail-loud cabal, cron canary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/channel-e2e-multi.yml | 54 ++++++++++++++++++------- .github/workflows/channel-e2e-wasm.yml | 43 +++++++++----------- 2 files changed, 59 insertions(+), 38 deletions(-) diff --git a/.github/workflows/channel-e2e-multi.yml b/.github/workflows/channel-e2e-multi.yml index be36a7aa4358..64749a9323ba 100644 --- a/.github/workflows/channel-e2e-multi.yml +++ b/.github/workflows/channel-e2e-multi.yml @@ -27,6 +27,14 @@ on: multi_version: description: 'Multi-target GHC version to test (e.g. multi-9.14.0.stable.1)' default: '' + # Weekly silent-regression canary on the default branch. Catches drift: + # release asset re-uploaded with different bytes, channel YAML + # mis-deployed, NodeSource setup_22.x breaking, ghcup-runner-image + # quirks, wasi-sdk bootstrap.sh layout changes, emscripten 3.1.74 + # download server moves, etc. Cron only fires on the workflow's home + # default branch (stable-ghc-9.14 once this lands there). + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC # Per-ref concurrency so multiple pushes coalesce. concurrency: @@ -125,27 +133,44 @@ jobs: ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" 2>/dev/null || true done echo "$WASI_BIN" >> "$GITHUB_PATH" + # macos-15: WASI_BIN contains a wasm-only `clang` that, if it + # wins PATH lookup over /usr/bin/clang, gets invoked by GHC's + # native code generator and fails with + # clang -cc1as: error: unknown target triple 'arm64-apple-macosx15.0.0' + # GITHUB_PATH prepends entries, so we re-prepend /usr/bin here + # to put Apple's clang back ahead of the wasm-only one. Linux + # is unaffected — there GHC's C compiler is `cc` (gcc), not + # `clang`. + if [ "$(uname -s)" = "Darwin" ]; then + echo "/usr/bin" >> "$GITHUB_PATH" + fi # --------------------------------------------------------------------- - # 4. cabal — the wasm hello template's Makefile drives cabal-install. + # 4. Add the wasm channel — that's where the stable-haskell `cabal` + # download entry lives. The multi-target channel YAML itself + # intentionally ships only the GHC frontends; cabal is shared + # with the wasm channel rather than duplicated. + # --------------------------------------------------------------------- + - name: Add stable-haskell wasm channel (for cabal) + run: | + set -euo pipefail + ghcup config add-release-channel \ + https://stable-haskell.github.io/ghc/ghcup-wasm.yaml + + # --------------------------------------------------------------------- + # 5. cabal — the wasm hello template's Makefile drives cabal-install. # JS hello compiles directly with javascript-unknown-ghcjs-ghc, no - # cabal needed there. + # cabal needed there. cabal-$CABAL_VER MUST install successfully + # on every supported platform; any failure (channel YAML missing + # the entry, dlHash mismatch, platform classification drift) is a + # real bug — fail loud rather than silently skip downstream tests. # --------------------------------------------------------------------- - name: Install cabal - id: cabal run: | set -euo pipefail - if ! ghcup install cabal "$CABAL_VER" 2>&1 | tee /tmp/cabal-install.log; then - if grep -q "Unable to find a download for Tool" /tmp/cabal-install.log; then - echo "::warning::cabal-$CABAL_VER not yet available for this platform" - echo "cabal_installed=false" >> "$GITHUB_OUTPUT" - exit 0 - else - exit 1 - fi - fi - ghcup set cabal "$CABAL_VER" - echo "cabal_installed=true" >> "$GITHUB_OUTPUT" + ghcup install cabal "$CABAL_VER" + ghcup set cabal "$CABAL_VER" + cabal --version # --------------------------------------------------------------------- # 5. Install the multi-target GHC from the live channel. This is the @@ -155,7 +180,6 @@ jobs: # --------------------------------------------------------------------- - name: multi — try install (sets a flag) id: multi_install - if: steps.cabal.outputs.cabal_installed == 'true' run: | # Use full strict mode (-euo pipefail). The two `if ! …; then …; fi` # blocks below intentionally CAPTURE failures (channel YAML missing, diff --git a/.github/workflows/channel-e2e-wasm.yml b/.github/workflows/channel-e2e-wasm.yml index 22da18c580a5..5a589de15375 100644 --- a/.github/workflows/channel-e2e-wasm.yml +++ b/.github/workflows/channel-e2e-wasm.yml @@ -48,12 +48,13 @@ on: wasm_version: description: 'GHC wasm version to test (e.g. wasm32-wasi-9.14.0.stable.12)' default: '' - # NOTE: cron only fires on the repo's default branch. Once this workflow - # lands on `stable-ghc-9.14`, uncomment to add a weekly silent-regression - # canary that catches drift (release asset re-uploaded, channel YAML - # mis-deployed, NodeSource setup_22.x breaking, etc.): - # schedule: - # - cron: '0 6 * * 1' # Monday 06:00 UTC + # Weekly silent-regression canary on the default branch. Catches drift: + # release asset re-uploaded, channel YAML mis-deployed, NodeSource + # setup_22.x breaking, ghcup-runner-image quirks, wasi-sdk bootstrap.sh + # layout changes, etc. Cron only fires on the workflow's home default + # branch (stable-ghc-9.14 once this lands there). + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC # Only run one at a time per ref — avoids racing on cabal-store side effects # when the cron and a manual dispatch happen close together. @@ -254,24 +255,23 @@ jobs: ls "$WASI_BIN" | grep -E "wasm32-unknown-wasi-(clang|ar|nm|ranlib|strip)" | head -10 # --------------------------------------------------------------------- - # 3c. Install cabal from the channel. Should succeed on every platform - # we test (channel was extended with Linux variants in stable.1). + # 3c. Install cabal from the channel. cabal-$CABAL_VER MUST install + # successfully on every supported platform; any failure (channel + # YAML missing the entry, dlHash mismatch, platform classification + # drift, network issue) is a real bug — fail loud rather than + # silently skip downstream tests. + # + # Earlier iterations of this step had a soft fallback that set + # cabal_installed=false and exited 0 when ghcup reported "Unable + # to find a download for Tool", from the days when cabal wasn't + # yet on every Linux variant. Removed: stale + masks real bugs. # --------------------------------------------------------------------- - name: Install cabal - id: cabal run: | set -euo pipefail - if ! ghcup install cabal "$CABAL_VER" 2>&1 | tee /tmp/cabal-install.log; then - if grep -q "Unable to find a download for Tool" /tmp/cabal-install.log; then - echo "::warning::cabal-$CABAL_VER not yet available for this platform" - echo "cabal_installed=false" >> "$GITHUB_OUTPUT" - exit 0 - else - exit 1 - fi - fi - ghcup set cabal "$CABAL_VER" - echo "cabal_installed=true" >> "$GITHUB_OUTPUT" + ghcup install cabal "$CABAL_VER" + ghcup set cabal "$CABAL_VER" + cabal --version # --------------------------------------------------------------------- # 4. Sanity: verify the cabal binary and a standalone single-compiler @@ -280,7 +280,6 @@ jobs: # (gcc / pkg-config / ghc) all work in the basic case. # --------------------------------------------------------------------- - name: Sanity — single-compiler cabal build - if: steps.cabal.outputs.cabal_installed == 'true' run: | set -euo pipefail cabal --version @@ -344,7 +343,6 @@ jobs: # bring-up + ghc_wasm_jsffi_init + hs_start sequence. # --------------------------------------------------------------------- - name: hello template — build + run-node - if: steps.cabal.outputs.cabal_installed == 'true' run: | set -euo pipefail curl -fL -o hello.tar.gz \ @@ -370,7 +368,6 @@ jobs: # regressions. # --------------------------------------------------------------------- - name: miso-counter template — build + verify wasm artifact - if: steps.cabal.outputs.cabal_installed == 'true' run: | set -euo pipefail curl -fL -o miso.tar.gz \ From ffd83fa58c5e10bc1c4093ccf074abd489b63e03 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 4 Jun 2026 11:46:19 +0900 Subject: [PATCH 06/14] lode: multi-target bindist design + rpath leak root-cause + channel YAML draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * multi-target-bindist-design.md — design doc covering the argv[0]-dispatch model, per-target lib/targets// 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). --- lode/draft-ghcup-multi-target-0.1.0.yaml | 137 +++++++++++++++ lode/multi-target-bindist-design.md | 207 +++++++++++++++++++++++ lode/rpath-leak-investigation.md | 137 +++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 lode/draft-ghcup-multi-target-0.1.0.yaml create mode 100644 lode/multi-target-bindist-design.md create mode 100644 lode/rpath-leak-investigation.md diff --git a/lode/draft-ghcup-multi-target-0.1.0.yaml b/lode/draft-ghcup-multi-target-0.1.0.yaml new file mode 100644 index 000000000000..2dcc682de7a7 --- /dev/null +++ b/lode/draft-ghcup-multi-target-0.1.0.yaml @@ -0,0 +1,137 @@ +--- +# stable-haskell custom ghcup channel — MULTI-TARGET (schema 0.1.0). +# +# Distinct from ghcup-wasm.yaml (which uses schema 0.0.9 and has the +# single-target wasm32-wasi-9.14.0.stable.X entries). This channel is +# for the multi-target bindist that ships native + wasm + JS in one +# install. +# +# Usage: +# ghcup config add-release-channel \ +# https://stable-haskell.github.io/ghc/ghcup-multi-target-0.1.0.yaml +# ghcup install ghc multi-9.14.0.stable.0 +# ghcup set ghc multi-9.14.0.stable.0 +# +# After install, all three targets are on PATH: +# ghc — native +# wasm32-unknown-wasi-ghc — wasm cross +# javascript-unknown-ghcjs-ghc — JS cross +# +# Same physical binary; ghc dispatches per-target via argv[0]. +# +# Schema: ghcup-0.1.0 (Installer DSL). Requires ghcup >= 0.2.5. + +toolRequirements: {} + +ghcupDownloads: + GHC: + multi-9.14.0.stable.0: + viTags: + - LatestPrerelease + viChangeLog: https://github.com/stable-haskell/ghc/releases/tag/multi-9.14.0.stable.0 + viPreInstall: | + Multi-target GHC bindist (~700 MB per platform). Requires on PATH: + * Node.js >= 22 — wasm + JS dynamic linker shim + * wasi-sdk — wasm target C toolchain + * emscripten — JS target C toolchain + + Install wasi-sdk via ghc-wasm-meta bootstrap: + curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh \ + | FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh + export PATH="$HOME/.ghc-wasm/wasi-sdk/bin:$PATH" + + Install emscripten: + git clone --depth 1 --branch 3.1.74 https://github.com/emscripten-core/emsdk.git + cd emsdk && ./emsdk install 3.1.74 && ./emsdk activate 3.1.74 + source emsdk_env.sh + + viPostInstall: | + Multi-target GHC installed. argv[0] dispatch: + ghc — native compilation (this host) + wasm32-unknown-wasi-ghc — wasm cross + javascript-unknown-ghcjs-ghc — JS cross + + Per-target package databases live under + lib/ (native) + lib/targets/wasm32-unknown-wasi/lib/ (wasm) + lib/targets/javascript-unknown-ghcjs/lib/ (JS) + Each is auto-recached on first use by ghc-pkg (mtime-based) or + eagerly by relocate.sh. + + Run `ghcup set ghc multi-9.14.0.stable.0` to activate the + unversioned ghc / wasm32-…-ghc / javascript-…-ghc symlinks. + + viArch: + A_64: + Linux_UnknownLinux: + unknown_versioning: + dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-x86_64-linux.tar.gz + dlHash: "FILL_AFTER_BUILD_x86_64_linux" + dlInstallSpec: + configure: + configArgs: + - --prefix=${PREFIX} + configEnv: null + configFile: configure + make: + makeArgs: + - DESTDIR=${TMPDIR} + - install + dataRules: [] + exeRules: [] + exeSymLinked: + # Single pattern-based spec creates ~/.ghcup/bin/ entries + # for every shipped binary. ${TARGETFN} resolves to each + # matched filename (e.g. "ghc", "wasm32-unknown-wasi-ghc", + # "javascript-unknown-ghcjs-ghc-pkg"). Versioned + setName + # forms both created. + - linkName: "${TARGETFN}-${PKGVER}" + pVPMajorLinks: true + setName: "${TARGETFN}" + targetPattern: ["bin/**"] + preserveMtimes: false + A_ARM64: + Darwin: + unknown_versioning: + dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-aarch64-darwin.tar.gz + dlHash: "FILL_AFTER_BUILD_aarch64_darwin" + dlInstallSpec: + configure: + configArgs: + - --prefix=${PREFIX} + configEnv: null + configFile: configure + make: + makeArgs: + - DESTDIR=${TMPDIR} + - install + dataRules: [] + exeRules: [] + exeSymLinked: + - linkName: "${TARGETFN}-${PKGVER}" + pVPMajorLinks: true + setName: "${TARGETFN}" + targetPattern: ["bin/**"] + preserveMtimes: false + Linux_UnknownLinux: + unknown_versioning: + dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-aarch64-linux.tar.gz + dlHash: "FILL_AFTER_BUILD_aarch64_linux" + dlInstallSpec: + configure: + configArgs: + - --prefix=${PREFIX} + configEnv: null + configFile: configure + make: + makeArgs: + - DESTDIR=${TMPDIR} + - install + dataRules: [] + exeRules: [] + exeSymLinked: + - linkName: "${TARGETFN}-${PKGVER}" + pVPMajorLinks: true + setName: "${TARGETFN}" + targetPattern: ["bin/**"] + preserveMtimes: false diff --git a/lode/multi-target-bindist-design.md b/lode/multi-target-bindist-design.md new file mode 100644 index 000000000000..ff013904573f --- /dev/null +++ b/lode/multi-target-bindist-design.md @@ -0,0 +1,207 @@ +# Multi-target GHC bindist — design + +**Goal.** One ghcup channel entry, one downloaded tarball, one extracted directory; on extraction the user has working `bin/ghc` (native), `bin/wasm32-unknown-wasi-ghc`, and `bin/javascript-unknown-ghcjs-ghc` — all the same physical binary, dispatched via `argv[0]` to the appropriate per-target settings + library set. + +**Working branch.** `feat/multi-target-bindist` off `feat/wasm-cross-ghcup` HEAD (`4bd31cb4316`). The .dyn_hi shipping work from Path C is the foundation: shared libraries + host dylibs + `$ORIGIN`-relative rpath are already in place from stable.12. + +**Rollback.** gh-pages tag `demo-freeze-2026-06-05` points at the gh-pages commit advertising stable.12 as LatestPrerelease. One-command revert: + +``` +git push origin demo-freeze-2026-06-05:gh-pages -f +``` + +--- + +## 1. Why this works (the GHC architecture) + +GHC's stage2 native binary is itself the cross compiler. It inspects `argv[0]` at startup, strips the executable's basename, looks for a leading triple prefix (`-`), and if found uses `lib/targets//lib/settings` instead of `lib/settings`. Same binary, different target. + +The stage3 build, parametric in `STAGE3_PLATFORMS`, already creates these per-platform invocation entrypoints — at line ~979 of the top-level Makefile each stage3 run does: + +```make +$(foreach exe,$(STAGE3_EXECUTABLES),$(LN_SF) $$(exe) $(DIST_DIR)/bin/$(1)-$$(exe);) +``` + +i.e. for `$(1) = wasm32-unknown-wasi`, the rule creates `bin/wasm32-unknown-wasi-ghc → bin/ghc`, `bin/wasm32-unknown-wasi-ghc-pkg → bin/ghc-pkg`, etc. Same for JS. + +The per-platform support files (`lib/targets//`) get populated by the same `stage3-` rule. + +The `STAGE2_EXECUTABLES` and `STAGE3_EXECUTABLES` lists are identical 9-element sets: `ghc`, `ghc-iserv`, `ghc-pkg`, `hp2ps`, `hpc`, `hsc2hs`, `runghc`, `unlit`, `haddock`. JS doesn't use `ghc-iserv` (the JS backend has its own evaluator); we filter it out for that target. + +--- + +## 2. Bindist tar layout (target state) + +``` +bin/ + ghc, ghc-iserv, ghc-pkg, … # native (STAGE2_EXECUTABLES = 9) + wasm32-unknown-wasi-ghc, … # wasm cross (9 entries) + javascript-unknown-ghcjs-ghc, … # JS cross (8 entries — no ghc-iserv) +lib/ + ghc-usage.txt, ghci-usage.txt + template-hsc.h + settings # NATIVE settings file (argv[0] dispatches to per-target settings if prefix found) + package.conf.d/ # NATIVE package db (base, ghc-internal, etc.) + / # NATIVE libs (libHS*.so, .a, .hi, .dyn_hi) + targets/ + wasm32-unknown-wasi/lib/ + settings # wasm-target settings + package.conf.d/ # wasm-target package db + wasm32-unknown-wasi/ # wasm-target libs (.so + .a + .hi + .dyn_hi) + dyld.mjs, post-link.mjs, prelude.mjs, ghc-interp.js # wasm runtime shims + javascript-unknown-ghcjs/lib/ + settings # JS-target settings + package.conf.d/ # JS-target package db + javascript-unknown-ghcjs/ # JS-target libs + dyld.mjs, post-link.mjs, prelude.mjs, ghc-interp.js # JS runtime shims (same family) +relocate.sh # generalised — recaches ALL three package dbs +configure, Makefile # autoconf-shaped stubs (legacy install path) +``` + +Sizes (estimated): native lib// ~200 MB + wasm target ~230 MB + JS target ~250 MB + binaries ~30 MB. **Total ~700 MB** per platform tarball. + +--- + +## 3. Makefile rule (Phase 2) + +Modeled on the existing `$(DIST_DIR)/haskell-toolchain.tar.gz` (line 1149, which already does native + JS). We add wasm + use `tar -czhf` (dereference) so each cross-prefixed binary is a standalone copy (predictable for ghcup's symlink pattern matcher). + +```make +$(DIST_DIR)/ghc-multi-target.tar.gz: $(STAGE2_STAMP) \ + | stage3-wasm32-unknown-wasi stage3-javascript-unknown-ghcjs + @echo "::group::Creating ghc-multi-target.tar.gz..." + @cp -f mk/multi-target-relocate.sh $(DIST_DIR)/relocate.sh + @chmod +x $(DIST_DIR)/relocate.sh + @cp -f mk/multi-target-configure.sh $(DIST_DIR)/configure + @chmod +x $(DIST_DIR)/configure + @cp -f mk/multi-target-bindist-Makefile $(DIST_DIR)/Makefile + tar czhf $@ \ + --directory=$(DIST_DIR) \ + $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \ + $(foreach exe,$(STAGE3_EXECUTABLES),bin/wasm32-unknown-wasi-$(exe)$(EXE_EXT)) \ + $(foreach exe,$(filter-out ghc-iserv,$(STAGE3_EXECUTABLES)),bin/javascript-unknown-ghcjs-$(exe)$(EXE_EXT)) \ + lib/ghc-usage.txt lib/ghci-usage.txt lib/package.conf.d lib/settings lib/template-hsc.h \ + lib/$(HOST_PLATFORM) \ + lib/targets/wasm32-unknown-wasi \ + lib/targets/javascript-unknown-ghcjs \ + relocate.sh configure Makefile + @echo "::endgroup::" +``` + +**`-h` rationale.** Stage3 creates `bin/-` as symlinks pointing at `bin/`. Wasm bindist uses `-czhf` (deref) because the wasm tarball doesn't include `bin/ghc` — we needed the cross-prefixed binaries to be standalone copies. The multi-target tarball INCLUDES `bin/ghc`, so we could in principle preserve the symlinks (smaller tarball). However ghcup's `targetPattern: "bin/**"` uses `getDirectoryFilesIgnore` which lists symlinks-as-files — so we'd need to be confident the symlinks are preserved end-to-end and pass through ghcup's unpack. Keeping `-h` (deref) is more predictable; cost is ~30 MB extra (8 ghc-iserv + 8 ghc-pkg + … copies vs symlinks of ~3 MB each). Acceptable. + +--- + +## 4. relocate.sh (Phase 2 — supporting script) + +Generalises the wasm-only version to recache all three package databases: + +```sh +#!/bin/sh +set -e +PREFIX="$(cd "$(dirname "$0")" && pwd)" + +# Native ghc-pkg (HOST_PLATFORM target via empty triple prefix) +"$PREFIX/bin/ghc-pkg" recache --package-db "$PREFIX/lib/package.conf.d" + +# Per-cross-target ghc-pkg +for plat in wasm32-unknown-wasi javascript-unknown-ghcjs; do + pkg_db="$PREFIX/lib/targets/$plat/lib/package.conf.d" + if [ -d "$pkg_db" ]; then + "$PREFIX/bin/$plat-ghc-pkg" recache --package-db "$pkg_db" + fi +done + +# wasm + JS both need node ≥ 22 on PATH. emscripten needed for JS link step. +if ! command -v node >/dev/null 2>&1; then + echo "NOTE: node not on PATH — wasm/JS TH evaluation will fail." >&2 +fi +if ! command -v emcc >/dev/null 2>&1; then + echo "NOTE: emcc (emscripten) not on PATH — JS linking will fail." >&2 +fi +``` + +--- + +## 5. CI Cross: MULTI job (Phase 5) + +Copy/adapt `Cross: WASM` job in `nix-ci.yml`: +- needs: `[build]` (downloads `${plat}-dynamic1-dist` stage2 artifact — same as current wasm-cross) +- installs wasi-sdk AND emscripten (the JS path needs emcc on PATH at compile/link time) +- runs `make DYNAMIC=1 DIST_BUILD=1 _build/dist/ghc-multi-target.tar.gz` (DYNAMIC=1 because we need .dyn_hi for both wasm and JS targets, same logic as Cross: WASM) +- rename tarball with host-triple suffix +- patchelf step for Linux: same as Cross: WASM — set `$ORIGIN/../lib/$HOST_DIR` rpath on the binary, `$ORIGIN` on the .so files +- upload as workflow artifact +- on tag-push (matching `multi-*` tags): also upload to GitHub Release via `softprops/action-gh-release` + +Matrix: same 3-platform set as Cross: WASM. aarch64-darwin is the slow self-hosted runner. + +Expected build time: ~30 min for the multi-target tar after stage2 is ready (wasm + JS in parallel via cabal's build plan parallelism — possibly). + +--- + +## 6. Channel YAML (Phase 7) + +Schema bump from 0.0.9 → 0.1.0 (Installer DSL). New entry, drafted as a parallel entry to the existing stable.12 one (additive, not destructive): + +```yaml +ghcupDownloads: + GHC: + multi-9.14.0.stable.0: + viTags: + - LatestPrerelease # only set this AT promotion gate (Phase 11), not before + viChangeLog: https://github.com/stable-haskell/ghc/releases/tag/multi-9.14.0.stable.0 + viPreInstall: | + Requires Node.js ≥ 22 (wasm runtime), wasi-sdk (wasm-target C tools), + and emscripten (JS-target C tools) on PATH. See + https://stable-haskell.github.io/ghc/install/ for setup. + viPostInstall: | + Multi-target GHC installed. The same compiler invokes via argv[0]: + ghc — native compilation (this host) + wasm32-unknown-wasi-ghc — wasm cross + javascript-unknown-ghcjs-ghc — JS cross + All three share the same package db conventions but each maintains + its own per-target lib tree under lib/targets//. + viArch: + A_64: + Linux_UnknownLinux: + unknown_versioning: + dlUri: https://github.com/stable-haskell/ghc/releases/download/multi-9.14.0.stable.0/ghc-multi-target-x86_64-linux.tar.gz + dlHash: ... + dlInstallSpec: + bindistFiles: + exeRules: + - installSource: configure + - installSource: Makefile + - installSource: relocate.sh + exeSymLinked: + - targetPattern: "bin/**" + targetPatternIgnore: [] + linkName: "${TARGETFN}-${PKGVER}" + setName: "${TARGETFN}" + dataRules: + - installPattern: ["lib/**"] + preserveMtimes: false + # ... same shape for A_ARM64 / Darwin + Linux_UnknownLinux + wasm32-wasi-9.14.0.stable.12: + viTags: [] # demoted ONLY at Phase 11 + # ... existing entry preserved verbatim +``` + +**Schema 0.1.0 vs 0.0.9.** ghcup-0.0.9 clients can't parse the new DSL. The channel must declare schema version somewhere; if not, old clients silently ignore the new fields (defaulting to legacy install). **Need to verify**: does ghcup-0.2.5.0 parse 0.0.9-schema YAML too? If yes, all good. If not, we'd need separate channel URLs per schema (a 0.0.9 channel and a 0.1.0 channel). + +This is the **highest risk** part — Phase 7 GATE explicitly tests schema parsing. + +--- + +## 7. Sequencing + gates (already in #38..#50) + +The phases are queued. Each ends with a hard gate. NO-GO at any gate means we stop, document, and the Friday demo runs on stable.12 — no functional loss. + +--- + +## 8. Open questions + +- **JS bindist needs emscripten in `viPreInstall`** — but emscripten is heavy and version-pinned. Should we make JS optional (separate channel entry) and ship native+wasm in the "multi" bindist? This would simplify the install story. +- **Schema migration**: if we go 0.1.0 schema, every stable-haskell channel user needs ghcup ≥ 0.2.5. The bootstrap.haskell.org installer ships latest, so new installs are fine. Existing users with older ghcup would silently lose access. Acceptable for a pre-release channel. +- **Naming**: `multi-9.14.0.stable.0` makes the namespace clean. Alternative: just `9.14.0.stable.0` (no triple prefix, treated as a native entry by ghcup, with the cross targets as bonus symlinks). The latter slots into the canonical ghcup ghc track. **Decision: go with `multi-` prefix initially**; namespace it cleanly so we don't conflict with anyone else's `9.14.0.stable.X`. diff --git a/lode/rpath-leak-investigation.md b/lode/rpath-leak-investigation.md new file mode 100644 index 000000000000..298e69256eec --- /dev/null +++ b/lode/rpath-leak-investigation.md @@ -0,0 +1,137 @@ +# `/Volumes/WorkSpace` LC_RPATH leak — root-cause investigation + +**Date:** 2026-06-02 +**Symptom:** every host arm64 Mach-O in `_build/dist/ghc-multi-target-aarch64-darwin.tar.gz` ships with an unresolvable absolute LC_RPATH: + + /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/aarch64-apple-darwin/lib + +coexisting with the portable `@executable_path/../lib/aarch64-apple-darwin`. macOS 14 dyld silently falls through; macos-15 (Sequoia) dyld treats it as fatal and aborts the binary on launch. + +Background workaround already in place: CI commit **010b365582c** post-processes the darwin bindist with `install_name_tool -delete_rpath` + ad-hoc re-sign. This document describes the upstream defect; the workaround stays in place until a real fix lands. + +--- + +## Where the absolute path enters the link line + +The leak originates in the bundled Cabal (not in GHC, not in our Makefile), specifically in two collaborating functions: + +### 1. `depLibraryPaths` — returns absolute store paths + +`libraries/Cabal/Cabal/src/Distribution/Simple/LocalBuildInfo.hs:256-351` + +```haskell +depLibraryPaths + :: Bool -- ^ Building for inplace? + -> Bool -- ^ Generate prefix-relative library paths + -> LocalBuildInfo + -> ComponentLocalBuildInfo + -> IO [FilePath] +depLibraryPaths inplace relative lbi clbi = do + ... + let allDepLibDirs = concatMap getDynDir external_ipkgs + allDepLibDirsC <- traverse canonicalizePathNoFail allDepLibDirs' + let p = prefix installDirs + prefixRelative l = isJust (stripPrefix p l) + libPaths + | relative && prefixRelative relDir = + map (\l -> if prefixRelative l + then shortRelativePath relDir l + else l) + allDepLibDirsC + | otherwise = allDepLibDirsC -- absolute paths returned as-is +``` + +Two gate conditions are needed to shorten paths to relative: +* `relative == True` — driven by the per-package `relocatable :: Bool` field of `LocalBuildInfo`, which itself is set by `--enable-relocatable` (or `relocatable: True` in a project file). We do **not** pass this flag from `cabal.project.stage2.settings`. +* The dep's libdir `l` must already be under the package's prefix `p`. In a cabal-store layout each package gets its own subdir of the store, so a sibling dep's `lib/` directory is **never** under `p`. `prefixRelative l` is False for every cross-package dep, even with `--enable-relocatable`. + +Both gates fail, so `depLibraryPaths` returns absolute store paths. + +### 2. `relPath` — only rewrites relative paths + +`libraries/Cabal/Cabal/src/Distribution/Simple/GHC/Build/Link.hs:638-648` + +```haskell +if supportRPaths hostOS + then do + libraryPaths <- liftIO $ depLibraryPaths False (relocatable lbi) lbi clbi + let hostPref = case hostOS of + OSX -> "@loader_path" + _ -> "$ORIGIN" + relPath p = if isRelative p then hostPref p else p + rpaths = toNubListR (map relPath libraryPaths) + <> toNubListR (map getSymbolicPath $ extraLibDirs bi) + return rpaths + else return mempty +``` + +Absolute paths from step 1 pass through `relPath` unchanged. GHC then emits them as `-Wl,-rpath,...` on the link command and the linker writes them into LC_RPATH (darwin) or DT_RUNPATH (Linux ELF). + +### 3. Build sequencing — Makefile rewrites `.conf` files too late + +`Makefile` lines 738–763 (stage2 build): + +1. `$(STAGE2_CABAL_BUILD)` runs cabal which links every executable, baking the absolute store paths from step 2 directly into the binary. +2. **After** all executables are linked, the Makefile rewrites the per-package `.conf` files with `${pkgroot}/../lib/...` placeholders so ghc-pkg can relocate them post-install. + +By the time the `.conf` files become relocatable, the linked binaries already contain absolute LC_RPATH. + +--- + +## Why Linux looks fine + +It isn't — Linux ELFs have the identical defect. They get masked because the Makefile/CI runs `patchelf --force-rpath --set-rpath '$ORIGIN'` over every shipped binary and `.so`: + +* `Makefile:777-779` — host shared libs +* `.github/workflows/nix-ci.yml` Cross: WASM / Cross: MULTI patchelf step — every executable + lib + +`patchelf --set-rpath` writes a fresh DT_RUNPATH wholesale, so it doesn't matter what was baked in. + +The darwin equivalent (install_name_tool can only `-add_rpath` / `-delete_rpath` / `-rpath` one at a time, no wholesale replace) was never wired up until this week. + +--- + +## Fix options, ordered by invasiveness + +| | Approach | Where | Invasiveness | Notes | +|---|---|---|---|---| +| A | Strip post-build with `install_name_tool -delete_rpath` + re-sign | `.github/workflows/nix-ci.yml` Cross: MULTI darwin step | Low (1 step) | **In place** as of commit 010b365582c. Mirrors what patchelf does on Linux. Bandaid but reliable. | +| B | Patch `relPath` in `Link.hs` to also handle absolute paths via `makeRelative bindir p` | `libraries/Cabal/.../Link.hs:644` | Medium | Needs `bindir` (or `dynlibdir`) of the executable's component at the call site — already computed via `absoluteComponentInstallDirs` in `depLibraryPaths`, would need plumbing. Source-side correctness fix. | +| C | Patch `depLibraryPaths` to detect cabal-store-sibling layout and emit relative paths between siblings | `libraries/Cabal/.../LocalBuildInfo.hs:336-346` | Medium-high | More general but every cabal user inherits the change. Likely needs upstream discussion. | +| D | Build stage2 with `relocatable: True` AND restructure store so siblings share a common prefix | `cabal.project.stage2.settings` | High | The prefix restructure is the hard part — cabal's store-by-unitid hash layout is what makes deps land in distinct prefixes. | + +**Status (2026-06-02):** taken option **B**, with one twist — see below. + +`stable-haskell/Cabal feat/rpath-relativize-absolute` (SHA `6a5ce8161`, PR #368) patches `Link.hs:644` to relativize absolute rpaths via `shortRelativePath` against the artifact's `bindir`/`libdir`. **Not gated on `relocatable lbi`** — first version was gated, but setting `relocatable: True` in `cabal.project.stage2.settings` to flip the gate ALSO triggers cabal-install's other relocatable-mode machinery: + +* `checkRelocatable` refuses any cabal-store layout where deps live in sibling prefixes (i.e. essentially always). +* `library-dirs` in installed-package `.conf` files become `${pkgroot}/...`-prefixed, which the stable-haskell GHC bindist-assembly Makefile post-stage2 rewriting then mangles into `_build/dist/lib/lib/...` (doubled `lib/`), breaking every stage3 consumer. + +Both behaviors are independent of rpath generation, so the second iteration of the fix drops the gate. Side effects on non-relocatable cabal users: + +* Cabal-store layouts: relative form works the same as the absolute did (dyld walks `@loader_path/../..//lib`). +* System libs (e.g. `/usr/local/lib/libfoo.dylib`): relative form `@loader_path/../../../../usr/local/lib/libfoo.dylib` works as long as the binary stays at its original location. A binary moved to a host where `/usr/local/lib/libfoo.dylib` exists at the same absolute path no longer finds it — but moving a single binary cross-host with system-lib dependencies was never a documented cabal contract. + +stable-haskell/ghc commit pinning the new Cabal SHA: `9de9f58ce54` on `feat/multi-target-bindist`. Once a tagged release ships with this Cabal and CI confirms macos-15 dyld is happy with the resulting bindist, both the darwin `install_name_tool -delete_rpath` step (commit `010b365582c`) and the Linux `patchelf --set-rpath '$ORIGIN'` step become no-ops and can be retired. + +## Files referenced + +* `libraries/Cabal/Cabal/src/Distribution/Simple/GHC/Build/Link.hs:638-648` +* `libraries/Cabal/Cabal/src/Distribution/Simple/LocalBuildInfo.hs:256-351` +* `libraries/Cabal/Cabal/src/Distribution/Simple/Setup/Config.hs:212-213,370,827-830` (relocatable flag) +* `Makefile:738-779` (stage2 build + post-link patchelf for host .so) +* `.github/workflows/nix-ci.yml:1392-1450` (current darwin install_name_tool workaround) +* `.github/workflows/nix-ci.yml:1339-1389` (current Linux patchelf step in Cross: MULTI) +* `cabal.project.stage2.settings:5-7` (`package * { shared: True; executable-dynamic: True }` — what enables the rpath codepath) + +## Reproducer + +On any darwin host with this branch built: + +```sh +$ otool -l _build/dist/stage2/bin/ghc | awk '/cmd LC_RPATH/{f=1;next} f && /path /{print; f=0}' + path /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/aarch64-apple-darwin/lib (offset 12) + path @executable_path/../lib/aarch64-apple-darwin (offset 12) +``` + +The first entry is the leak. From 7dd9a280718b97100a5203710b4f4c3d804a7d89 Mon Sep 17 00:00:00 2001 From: angerman Date: Sun, 7 Jun 2026 10:59:55 +0900 Subject: [PATCH 07/14] per-target dynamic library settings (#66 JS Path C, #67 target-aware GHC Dynamic) (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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//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__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__TARGET_IS_DYNAMIC = YES|NO (default YES) STAGE3__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//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) --- .github/workflows/nix-ci.yml | 636 ++----------------------------- Makefile | 53 +++ cabal.project.stage3.settings.in | 41 +- compiler/GHC/Driver/Session.hs | 41 +- compiler/GHC/Platform.hs | 27 ++ compiler/GHC/Settings.hs | 27 ++ compiler/GHC/Settings/IO.hs | 32 ++ 7 files changed, 238 insertions(+), 619 deletions(-) diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml index 63d22798e29c..0917894dd061 100644 --- a/.github/workflows/nix-ci.yml +++ b/.github/workflows/nix-ci.yml @@ -7,13 +7,13 @@ on: - synchronize push: branches: [stable-ghc-9.14, stable-master] - # Tag pushes matching wasm32-wasi-* trigger the Cross: WASM jobs to - # additionally upload their bindist tarballs to the matching GitHub - # Release, populating the stable-haskell ghcup channel. - # multi-* trigger the Cross: MULTI job for the same reason (separate - # tag namespace, separate channel YAML). + # Tag pushes matching multi-* trigger the Cross: MULTI job to + # additionally upload its multi-target bindist tarball to the + # matching GitHub Release, populating the stable-haskell + # ghcup-multi-target-0.1.0.yaml channel. The single multi-target + # bindist replaces the previously-separate wasm32-wasi-* and + # JS standalone channels — both targets ship via multi-* now. tags: - - 'wasm32-wasi-*' - 'multi-*' workflow_dispatch: @@ -560,597 +560,21 @@ jobs: fi # --------------------------------------------------------------------------- - # Cross: JS backend (aarch64-darwin, uses static dist) - # --------------------------------------------------------------------------- - cross-js: - name: "Cross: JS / aarch64-darwin" - # Cross-builds only need the dist artifact from build. They run in parallel - # with tests — each VM is independent (separate nix daemon, separate disk). - needs: [build] - if: ${{ !cancelled() && contains(fromJSON('["success", "failure"]'), needs.build.result) }} - runs-on: [self-hosted, nix] - continue-on-error: true - - env: - EMSDK_VERSION: "3.1.74" - - steps: - - name: Clean workspace - run: | - echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" - echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - echo "/usr/local/bin" >> "$GITHUB_PATH" - - echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV" - - echo "=== Disk usage before cleanup ===" - df -h / || true - df -h "$GITHUB_WORKSPACE" || true - rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true - rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true - sudo rm -f /usr/local/bin/devx 2>/dev/null || true - export PATH="/nix/var/nix/profiles/default/bin:$PATH" - nix-collect-garbage -d 2>/dev/null || true - nix-store --gc 2>/dev/null || true - echo "=== Disk usage after cleanup ===" - df -h / || true - - - uses: actions/checkout@v4 - with: - submodules: "recursive" - fetch-depth: 1 - - # Minimize source tree BEFORE devx import to give devx more headroom. - - name: Minimize source tree - run: | - GIT_COMMIT_ID=$(git rev-parse HEAD) - echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV" - echo "Captured GIT_COMMIT_ID=$GIT_COMMIT_ID" - rm -rf .git - find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true - rm -rf testsuite docs || true - echo "=== Disk after minimize ===" - df -h / || true - - - name: Ensure devx prerequisites - run: | - export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" - command -v zstd || nix-env -iA nixpkgs.zstd - sudo mkdir -p /usr/local/bin - - - uses: input-output-hk/actions/devx@latest - with: - platform: aarch64-darwin - compiler-nix-name: 'ghc98' - minimal: true - ghc: true - - - name: Download dist - uses: actions/download-artifact@v4 - with: - name: aarch64-darwin-dynamic0-dist - path: _build/dist - - - name: Set up stage2 from dist - run: | - chmod +x _build/dist/bin/* - mkdir -p _build/stage2/bin _build/stage2/lib - for exe in _build/dist/bin/*; do - ln -sf "$(pwd)/$exe" "_build/stage2/bin/$(basename $exe)" - done - if [[ -f _build/dist/lib/settings ]]; then - cp -rfp _build/dist/lib/settings _build/stage2/lib/ - fi - - - name: Update hackage - shell: devx {0} - run: | - set -eo pipefail - export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" - mkdir -p "$CABAL_DIR" - _build/dist/bin/cabal update - - - name: Free disk for cross build - run: | - rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages" "${CABAL_DIR:-~/.cabal-devx}/logs" "${CABAL_DIR:-~/.cabal-devx}/store" || true - rm -rf ~/.cabal-devx/packages ~/.cabal-devx/logs ~/.cabal-devx/store || true - rm -rf ~/.cabal/store || true - echo "=== Disk before emscripten install ===" - df -h / || true - df -h /Volumes/WorkSpace || true - - - name: Install emscripten - shell: devx {0} - run: | - set -eux - export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" - mkdir -p "$TMPDIR" - git clone --depth 1 --branch ${{ env.EMSDK_VERSION }} https://github.com/emscripten-core/emsdk.git - cd emsdk - ./emsdk install ${{ env.EMSDK_VERSION }} - ./emsdk activate ${{ env.EMSDK_VERSION }} - - - name: Build JS cross libraries - shell: devx {0} - run: | - set -eux - export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" - export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" - mkdir -p "$TMPDIR" - source emsdk/emsdk_env.sh - make DIST_BUILD=1 \ - CABAL=$PWD/_build/dist/bin/cabal \ - GHC_TOOLCHAIN_BIN=$PWD/_build/dist/bin/ghc-toolchain-bin \ - DERIVE_CONSTANTS_BIN=$PWD/_build/dist/bin/deriveConstants \ - GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \ - HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ - stage3-javascript-unknown-ghcjs - - - name: Smoke test - shell: devx {0} - run: | - set -eo pipefail - source emsdk/emsdk_env.sh - echo 'main = putStrLn "Hello from JS backend"' > /tmp/hello.hs - # JS backend creates a .jsexe directory; the entry point is all.js - _build/dist/bin/javascript-unknown-ghcjs-ghc /tmp/hello.hs -o /tmp/hello - node /tmp/hello.jsexe/all.js - - - name: Upload JS cross artifacts - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: aarch64-darwin-cross-js - retention-days: 1 - path: _build/dist/lib/targets/javascript-unknown-ghcjs - - # --------------------------------------------------------------------------- - # Cross: WASM backend — matrixed across all build hosts so each one - # produces a host-specific bindist tarball that can be shipped through - # the stable-haskell ghcup channel. - # --------------------------------------------------------------------------- - cross-wasm: - name: "Cross: WASM / ${{ matrix.plat }}" - # Cross-builds only need the dist artifact from build. They run in parallel - # with tests — each VM/runner is independent (separate nix daemon, etc.). - needs: [build] - if: ${{ !cancelled() && contains(fromJSON('["success", "failure"]'), needs.build.result) }} - runs-on: ${{ fromJSON(matrix.runner) }} - continue-on-error: true - - strategy: - fail-fast: false - matrix: - include: - - { plat: aarch64-darwin, devx-plat: aarch64-darwin, runner: '["self-hosted", "nix"]' } - - { plat: x86_64-linux, devx-plat: x86_64-linux, runner: '"ubuntu-latest"' } - - { plat: aarch64-linux, devx-plat: aarch64-linux, runner: '"ubuntu-24.04-arm"' } - - steps: - - name: Clean workspace - run: | - echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" - echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - echo "/usr/local/bin" >> "$GITHUB_PATH" - - echo "CABAL_DIR=$GITHUB_WORKSPACE/_build/cabal-dir" >> "$GITHUB_ENV" - - echo "=== Disk usage before cleanup ===" - df -h / || true - df -h "$GITHUB_WORKSPACE" || true - rm -rf "$GITHUB_WORKSPACE"/* "$GITHUB_WORKSPACE"/.??* || true - rm -rf ~/.cabal/store ~/.cabal-devx/store ~/.cabal-devx/packages || true - rm -rf ~/.ghc-wasm || true - sudo rm -f /usr/local/bin/devx 2>/dev/null || true - export PATH="/nix/var/nix/profiles/default/bin:$PATH" - nix-collect-garbage -d 2>/dev/null || true - nix-store --gc 2>/dev/null || true - echo "=== Disk usage after cleanup ===" - df -h / || true - - - uses: actions/checkout@v4 - with: - submodules: "recursive" - fetch-depth: 1 - - # Minimize source tree BEFORE devx import — same strategy as cross-js. - - name: Minimize source tree - run: | - GIT_COMMIT_ID=$(git rev-parse HEAD) - echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV" - echo "Captured GIT_COMMIT_ID=$GIT_COMMIT_ID" - rm -rf .git - find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true - rm -rf testsuite docs || true - echo "=== Disk after minimize ===" - df -h / || true - - - name: Ensure devx prerequisites - run: | - export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" - command -v zstd || nix-env -iA nixpkgs.zstd - sudo mkdir -p /usr/local/bin - - - uses: input-output-hk/actions/devx@latest - with: - platform: ${{ matrix.devx-plat }} - compiler-nix-name: 'ghc98' - minimal: true - ghc: true - - - name: Download dist - uses: actions/download-artifact@v4 - with: - # dynamic1 stage2 (with .dyn_hi for native base/filepath/process/etc.) - # is needed for two reasons: - # 1. stage3 build-side: when happy-lib / alex etc. compile with - # shared:True (via DYNAMIC=1 settings), cabal asks this build - # compiler for native Prelude.dyn_hi. - # 2. wasm bindist runtime: bin/wasm32-unknown-wasi-ghc is a - # Makefile symlink to bin/ghc which `tar -czhf` dereferences; - # the dereferenced binary is dyn-linked against libHS*.so - # files from THIS stage2. The wasm tar rule (Makefile) also - # copies lib/$(HOST_PLATFORM)/ from THIS stage2 alongside, - # so the binary's @rpath resolves at end-user install time. - name: ${{ matrix.plat }}-dynamic1-dist - path: _build/dist - - - name: Set up stage2 from dist - run: | - chmod +x _build/dist/bin/* - mkdir -p _build/stage2/bin _build/stage2/lib - for exe in _build/dist/bin/*; do - ln -sf "$(pwd)/$exe" "_build/stage2/bin/$(basename $exe)" - done - if [[ -f _build/dist/lib/settings ]]; then - cp -rfp _build/dist/lib/settings _build/stage2/lib/ - fi - - - name: Update hackage - shell: devx {0} - run: | - set -eo pipefail - export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" - mkdir -p "$CABAL_DIR" - _build/dist/bin/cabal update - - - name: Free disk for cross build - run: | - rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages" "${CABAL_DIR:-~/.cabal-devx}/logs" "${CABAL_DIR:-~/.cabal-devx}/store" || true - rm -rf ~/.cabal-devx/packages ~/.cabal-devx/logs ~/.cabal-devx/store || true - rm -rf ~/.cabal/store || true - echo "=== Disk before wasi-sdk install ===" - df -h / || true - df -h /Volumes/WorkSpace || true - - - name: Install wasi-sdk - shell: devx {0} - run: | - set -eux - export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" - mkdir -p "$TMPDIR" - # ghc-wasm-meta's bootstrap.sh needs jq + unzip; the smoke test below - # also needs node + wasmtime + wasm-objdump. The devx shell scrubs the - # system PATH so we install or expose them per host kind. - # - # Pick the installer based on uname -s — devx scrubs /usr/bin on both - # macOS and Linux, so `command -v apt-get` would falsely return no on - # both. Also CRUCIAL: on darwin we must NOT add /usr/bin to PATH, or - # BSD `find` (no `-mindepth`) takes precedence over GNU find used in - # Makefile:476 / 485 and the dist-copy step breaks. - case "$(uname -s)" in - Linux) - export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH" - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends \ - jq unzip zstd wabt - # post-link.mjs uses import.meta.filename (added Node 20.11+), - # so we need Node >= 20.11. Ubuntu noble's apt nodejs is - # 18.19.1, and runner-preinstalled Node 20.x at - # /opt/hostedtoolcache isn't visible inside devx for the smoke - # test. Install Node 22 from NodeSource unconditionally so we - # land /usr/bin/node — devx scrubs random tool caches but - # /usr/bin survives via the smoke step's PATH append. - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y --no-install-recommends nodejs - if ! command -v wasmtime >/dev/null 2>&1; then - curl -sSL https://wasmtime.dev/install.sh | bash - export PATH="$HOME/.wasmtime/bin:$PATH" - echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" - fi - ;; - Darwin) - export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH" - for tool in jq unzip zstd node wasmtime; do - if ! command -v "$tool" >/dev/null 2>&1; then - pkg="$tool" - [ "$tool" = "node" ] && pkg="nodejs" - echo "Installing $pkg via nix-env..." - /nix/var/nix/profiles/default/bin/nix-env -iA "nixpkgs.$pkg" - fi - done - if ! command -v wasm-objdump >/dev/null 2>&1; then - echo "Installing wabt (wasm-objdump) via nix-env..." - /nix/var/nix/profiles/default/bin/nix-env -iA nixpkgs.wabt - fi - ;; - esac - curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh | \ - FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh - - - name: Build WASM cross libraries - shell: devx {0} - run: | - set -eux - export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" - export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" - mkdir -p "$TMPDIR" - # Create wasm32-unknown-wasi-* symlinks for LLVM tools in wasi-sdk so they - # don't clash with devx's llvm-ar/llvm-nm/llvm-ranlib (which have - # different capabilities). Also symlink clang/clang++ from the - # wasm32-wasi- name bootstrap actually creates to the canonical autoconf - # triple wasm32-unknown-wasi- that ghc-toolchain-bin searches for — - # without these our build fails at "wasm32-unknown-wasi-clang not found - # in search path". - WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin" - for tool in ar nm ranlib strip; do - ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" - done - for tool in clang clang++; do - ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" - done - # wasi-sdk at END of PATH so native tools (cc, clang) resolve to the - # host compiler. WASM tools (wasm32-unknown-wasi-clang, etc.) have unique - # prefixed names that don't clash. - export PATH=$PATH:$WASI_BIN - # DYNAMIC=1 makes ./configure pass --enable-dynamic, which expands - # cabal.project.stage3.settings to: - # package * { shared:True; executable-dynamic:True } - # constraints: rts +dynamic - # …so the stage3 wasm build produces both .hi + .dyn_hi files AND - # ships .so libraries. Without it the bindist is static-only, and - # end-user TH-heavy apps (miso, jsaddle, aeson, …) hit - # error: [GHC-47808] Failed to load dynamic interface file for X - # because cabal sets `shared: True` for wasm32 user packages but - # the system base/ghc-internal/etc. ship no .dyn_hi to link against. - make DYNAMIC=1 DIST_BUILD=1 \ - CABAL=$PWD/_build/dist/bin/cabal \ - GHC_TOOLCHAIN_BIN=$PWD/_build/dist/bin/ghc-toolchain-bin \ - DERIVE_CONSTANTS_BIN=$PWD/_build/dist/bin/deriveConstants \ - GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \ - HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ - stage3-wasm32-unknown-wasi - - - name: Smoke test - if: success() - shell: devx {0} - run: | - set -eo pipefail - # devx scrubs the user-installed tool locations. Add them back after - # devx's $PATH so devx wins where there's overlap, but the per-OS - # installer outputs are still findable: - # Linux: apt installs node / wasm-objdump to /usr/bin; - # curl wasmtime installer drops into ~/.wasmtime/bin - # Darwin: nix-env -iA puts everything (wasmtime, wabt, node, …) - # into ~/.nix-profile/bin - # /usr/bin is not added on Darwin because it would shadow nix - # coreutils with BSD versions (no -mindepth on find, etc.). - case "$(uname -s)" in - Linux) export PATH="$PATH:$HOME/.wasmtime/bin:$HOME/.ghc-wasm/wasi-sdk/bin:/usr/bin" ;; - Darwin) export PATH="$PATH:$HOME/.nix-profile/bin:$HOME/.ghc-wasm/wasi-sdk/bin" ;; - esac - echo 'main = putStrLn "Hello from WASM backend"' > /tmp/hello.hs - _build/dist/bin/wasm32-unknown-wasi-ghc /tmp/hello.hs -o /tmp/hello.wasm - - # Verify the .wasm binary was produced - ls -lh /tmp/hello.wasm - file /tmp/hello.wasm - - # Diagnostic: inspect WASM imports to determine execution strategy. - # If ghc_wasm_jsffi imports are present, we need post-link.mjs + Node.js. - # If not, wasmtime can run it directly as a pure WASI module. - echo "=== WASM import sections ===" - wasm-objdump -x /tmp/hello.wasm | head -60 || true - echo "=== Checking for ghc_wasm_jsffi imports ===" - - if wasm-objdump -x /tmp/hello.wasm 2>/dev/null | grep -q 'ghc_wasm_jsffi'; then - echo "JSFFI imports detected → using post-link.mjs + Node.js" - - # GHC WASM executables with ghc_wasm_jsffi custom sections require - # JavaScript FFI glue at instantiation time. The post-link.mjs tool - # (installed in libdir, maintained upstream at utils/jsffi/post-link.mjs) - # parses these sections and generates a JavaScript ESM module providing - # the ghc_wasm_jsffi imports. - # - # Workflow (from docs/users_guide/wasm.rst §"JSFFI"): - # 1. Compile: wasm32-unknown-wasi-ghc hello.hs -o hello.wasm - # 2. Post-link: $(ghc --print-libdir)/post-link.mjs -i hello.wasm -o hello.mjs - # 3. Run: node runner.mjs - WASM_LIBDIR=_build/dist/lib/targets/wasm32-unknown-wasi/lib - - # Generate JavaScript FFI glue from WASM custom sections - node "$WASM_LIBDIR/post-link.mjs" -i /tmp/hello.wasm -o /tmp/hello.mjs - - # Instantiate WASM module with JSFFI + WASI imports, then run main. - # This mirrors the knot-tying pattern from docs/users_guide/wasm.rst: - # __exports starts empty, WASM is instantiated with JSFFI imports - # (which capture __exports by reference), then instance exports are - # assigned back into __exports before calling wasi.start(). - node --input-type=module -e ' - import { readFile } from "node:fs/promises"; - import { WASI } from "node:wasi"; - const mod = await WebAssembly.compile(await readFile("/tmp/hello.wasm")); - const jsffi = (await import("/tmp/hello.mjs")).default; - const wasi = new WASI({ version: "preview1", args: ["hello.wasm"] }); - let __exports = {}; - const instance = await WebAssembly.instantiate(mod, { - ghc_wasm_jsffi: jsffi(__exports), - wasi_snapshot_preview1: wasi.wasiImport, - }); - Object.assign(__exports, instance.exports); - wasi.start(instance); - ' - else - echo "No JSFFI imports → running with wasmtime (pure WASI module)" - - # Per docs/users_guide/wasm.rst: "the same toolchain still generates - # self-contained wasm32-unknown-wasi modules by default" — these can be run - # directly with any WASI-compatible runtime. - wasmtime run /tmp/hello.wasm - fi - - # Package the full ghcup-shippable bindist tarball — same recipe the - # Makefile uses locally (tar czhf -h dereferences the wasm-prefixed - # symlinks into real files, includes bin/ + lib/ + configure + Makefile - # + relocate.sh). Renamed with the host triple to disambiguate. - # - # NOTE: the tarball Makefile target depends on `stage3-$(plat)` which is - # .PHONY, so it re-runs ghc-toolchain-bin. That tool needs - # wasm32-unknown-wasi-clang on PATH — the symlinks were created by the - # previous step but PATH doesn't persist across CI steps, so we re-add - # the wasi-sdk bin dir here. - - name: Package bindist tarball - shell: devx {0} - if: ${{ !cancelled() }} - run: | - set -eux - export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" - export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" - # Keep devx's $PATH first so `make` resolves to devx's GNU Make (4.4+) - # not Ubuntu's /usr/bin/make (4.3 — no $(let), silently returns empty - # which corrupts LIB_NAME_GLOB and breaks the package.conf copy phase). - export PATH="$PATH:$HOME/.ghc-wasm/wasi-sdk/bin" - rm -f _build/dist/ghc-wasm32-unknown-wasi.tar.gz - # The same five DIST_BUILD vars the previous "Build WASM cross - # libraries" step uses — stage3 phony deps invoke configure which - # otherwise can't find deriveConstants/genapply/happy templates. - # DYNAMIC=1 mirrors the Build step so configure re-runs with the - # same --enable-dynamic flag (stage3-$plat is PHONY → re-runs). - make DYNAMIC=1 DIST_BUILD=1 \ - CABAL=$PWD/_build/dist/bin/cabal \ - GHC_TOOLCHAIN_BIN=$PWD/_build/dist/bin/ghc-toolchain-bin \ - DERIVE_CONSTANTS_BIN=$PWD/_build/dist/bin/deriveConstants \ - GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \ - HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ - _build/dist/ghc-wasm32-unknown-wasi.tar.gz - # rename with the host triple so we ship one tarball per host - cp _build/dist/ghc-wasm32-unknown-wasi.tar.gz \ - _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - ls -lh _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - echo "SHA256:" - shasum -a 256 _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - - # The wasm cross-compiler is built inside `devx` (a nix-shell), so all - # ELF binaries' interpreter (`PT_INTERP`) is pinned to a `/nix/store/.../ - # ld-linux-*.so.*` path. On any non-nix Linux system that path doesn't - # exist, and the kernel fails to load the binary with ENOENT — manifesting - # as `cannot execute: required file not found`. The same broad class of - # problem `fixup-nix-deps` solves on Darwin (Mach-O dyld refs). - # - # Patchelf the interpreter back to the canonical system ld-linux path and - # drop the RPATH so dynamic libs resolve through ldconfig (libgmp10, - # libffi8, libc, libm — all stock on any modern Linux distro). - # - # Skipped on Darwin: Mach-O doesn't pin an interpreter path the way ELF - # does, so darwin bindists are portable as-is. - - name: Normalize ELF interpreters for portability (Linux only) - if: ${{ !cancelled() && contains(matrix.plat, 'linux') }} - shell: devx {0} - run: | - set -eux - # patchelf is in apt on ubuntu — keep PATH outside devx for sudo. - export PATH="$PATH:/usr/bin:/usr/sbin" - if ! command -v patchelf >/dev/null 2>&1; then - sudo apt-get install -y --no-install-recommends patchelf - fi - case "${{ matrix.plat }}" in - x86_64-linux) INTERP=/lib64/ld-linux-x86-64.so.2; HOST_DIR=x86_64-unknown-linux ;; - aarch64-linux) INTERP=/lib/ld-linux-aarch64.so.1; HOST_DIR=aarch64-unknown-linux ;; - *) echo "::error::unexpected matrix.plat=${{ matrix.plat }}"; exit 1 ;; - esac - - TGZ=_build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - STAGE="$(mktemp -d)" - tar -C "$STAGE" -xzf "$TGZ" - - echo "═══ Patchelfing binaries to interpreter $INTERP ═══" - # Scan everything executable; patchelf only on dynamically-linked ELFs - # whose interpreter still points into /nix/store. Also set an - # @ORIGIN-relative rpath so the dyn-linked binary can locate the - # libHS*.so files we ship in lib/$HOST_DIR/ (mirrors the - # @executable_path/../lib/$(HOST_PLATFORM) rpath stage2 sets on - # Darwin via install_name_tool). - patched=0 - while IFS= read -r bin; do - if file -L "$bin" 2>/dev/null \ - | grep -q "ELF.*dynamically linked.*interpreter /nix/store"; then - echo " patching: ${bin#$STAGE/}" - patchelf --set-interpreter "$INTERP" "$bin" - patchelf --remove-rpath "$bin" - patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$HOST_DIR" "$bin" - patched=$((patched + 1)) - fi - done < <(find "$STAGE" -type f -executable) - echo "═══ Patched $patched binaries ═══" - test "$patched" -gt 0 || { echo "::error::no ELF binaries patched — something is wrong"; exit 1; } - - # Also fix rpath on the shipped host .so files (lib/$HOST_DIR/*.so): - # they reference each other (e.g. libHSghc.so needs libHSrts.so) - # and use $ORIGIN-relative rpath at stage2 build time. patchelf - # may have left a nix-store rpath in place; replace with the - # local-$ORIGIN form so cross-library lookups work from the - # bindist install location. - echo "═══ Setting rpath on lib/$HOST_DIR/*.so ═══" - so_patched=0 - for so in "$STAGE"/lib/"$HOST_DIR"/*.so; do - [ -f "$so" ] || continue - patchelf --force-rpath --set-rpath "\$ORIGIN" "$so" 2>/dev/null || true - so_patched=$((so_patched + 1)) - done - echo "═══ Set rpath on $so_patched shared libs ═══" - - # Repack — tar czhf was used to dereference symlinks originally; the - # staging dir is already real files (tar xzf preserves that), so plain - # tar czf is fine here. Keep the same filename for downstream uploads. - rm "$TGZ" - tar -C "$STAGE" -czf "$TGZ" . - ls -lh "$TGZ" - echo "SHA256 after patchelf:" - shasum -a 256 "$TGZ" - - - name: Upload WASM cross artifacts - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: ${{ matrix.plat }}-cross-wasm - retention-days: 30 - path: _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - - # On a tag push matching wasm32-wasi-*, also upload the bindist - # tarball directly to that GitHub Release so the ghcup channel can - # point at it. Other triggers (PR pushes, branch pushes) only produce - # workflow artifacts which is fine for review purposes. - - name: Upload bindist to release (on tag push) - if: ${{ startsWith(github.ref, 'refs/tags/wasm32-wasi-') }} - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - files: _build/dist/ghc-wasm32-unknown-wasi-${{ matrix.plat }}.tar.gz - fail_on_unmatched_files: true - - # --------------------------------------------------------------------------- - # Cross: MULTI — multi-target bindist combining native + wasm + JS. + # Cross: MULTI — the only Cross job. Builds the multi-target bindist + # combining native + wasm32-unknown-wasi + javascript-unknown-ghcjs in + # one tarball (argv[0] dispatched at runtime). Replaces the previously- + # separate Cross: WASM + Cross: JS jobs — those produced standalone + # bindists that we no longer ship; the multi-target bindist is the + # single end-user-facing artifact and covers both targets. # - # Reuses the Cross: WASM machinery (dynamic1 stage2 download, devx shell, - # wasi-sdk + Node install, patchelf for ELF interpreter + $ORIGIN rpath) - # and adds emscripten install for the JS target. The combined Makefile - # rule `_build/dist/ghc-multi-target.tar.gz` depends on both - # stage3-wasm32-unknown-wasi AND stage3-javascript-unknown-ghcjs, so - # this job builds BOTH cross trees + the multi-target tarball in one - # CI cycle. + # Per-host: dynamic1 stage2 download, devx shell, wasi-sdk + Node + # install, emscripten install, then `make stage3-wasm32-unknown-wasi + # stage3-javascript-unknown-ghcjs` + the combined `ghc-multi-target. + # tar.gz` Makefile rule. patchelf adjusts ELF interpreter + $ORIGIN + # rpath on the linux runners. # - # `continue-on-error: true` matches Cross: WASM — failures are - # informative but don't block the main pipeline. + # `continue-on-error: true` — failures are informative but don't + # block the main pipeline. # --------------------------------------------------------------------------- cross-multi: name: "Cross: MULTI / ${{ matrix.plat }}" @@ -1160,10 +584,8 @@ jobs: continue-on-error: true env: - # Pinned same as Cross: JS — emsdk's git tag, used in the - # `Install emscripten` step's `git clone --branch ${{ env.EMSDK_VERSION }}`. - # Workflow-level env doesn't exist on this workflow; each job - # needing EMSDK_VERSION declares it itself. + # emsdk's git tag — used by the `Install emscripten` step's + # `git clone --branch ${{ env.EMSDK_VERSION }}`. EMSDK_VERSION: "3.1.74" strategy: @@ -1225,7 +647,7 @@ jobs: - name: Download dist uses: actions/download-artifact@v4 with: - # dynamic1 stage2 (same rationale as Cross: WASM): + # dynamic1 stage2: # 1. happy-lib / alex etc. need native Prelude.dyn_hi at build-side # 2. the dyn-linked native binary becomes bin/ghc + bin/-ghc # in the multi-target bindist and needs lib/$(HOST_PLATFORM) @@ -1259,7 +681,7 @@ jobs: df -h / || true # Install wasi-sdk (wasm-target C toolchain) + Node 22 + wasmtime, same - # logic as Cross: WASM since this job builds the wasm half of the + # logic since this job builds the wasm half of the # multi-target bindist. - name: Install wasi-sdk + Node 22 shell: devx {0} @@ -1290,7 +712,7 @@ jobs: FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh # Install emscripten (JS-target C toolchain). EMSDK_VERSION is set in - # the workflow env (same as Cross: JS). + # the workflow env. - name: Install emscripten shell: devx {0} run: | @@ -1311,7 +733,7 @@ jobs: # Source emscripten env (provides emcc on PATH) source emsdk/emsdk_env.sh # Symlink wasi-sdk's wasm32-wasi-* tools as wasm32-unknown-wasi-* - # (same as Cross: WASM — autoconf canonical triple bridging). + #. WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin" for tool in ar nm ranlib strip; do ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" @@ -1320,7 +742,7 @@ jobs: ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" done export PATH=$PATH:$WASI_BIN - # DYNAMIC=1 for the same reason as Cross: WASM — both wasm and JS + # DYNAMIC=1 for both wasm and JS # targets need .dyn_hi for end-user TH builds (miso, aeson, ...). make DYNAMIC=1 DIST_BUILD=1 \ CABAL=$PWD/_build/dist/bin/cabal \ @@ -1329,7 +751,7 @@ jobs: GENAPPLY_BIN=$PWD/_build/dist/bin/genapply \ HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ _build/dist/ghc-multi-target.tar.gz - # Rename to add platform suffix (matches Cross: WASM scheme). + # Rename to add platform suffix. cp _build/dist/ghc-multi-target.tar.gz \ _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz ls -lh _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz @@ -1373,7 +795,7 @@ jobs: echo "═══ Patched $patched binaries ═══" test "$patched" -gt 0 || { echo "::error::no ELF binaries patched"; exit 1; } - # Fix rpath on the shipped host .so files (same as Cross: WASM). + # Fix rpath on the shipped host .so files. echo "═══ Setting rpath on lib/$HOST_DIR/*.so ═══" so_patched=0 for so in "$STAGE"/lib/"$HOST_DIR"/*.so; do @@ -1448,7 +870,7 @@ jobs: path: _build/dist/ghc-multi-target-${{ matrix.plat }}.tar.gz # On a tag push matching multi-*, upload to that GitHub Release. - # (Separate tag namespace from wasm32-wasi-* so the two channels + # (Separate tag namespace from the (no-longer-shipped) wasm32-wasi-* so the channels # stay independent.) - name: Upload bindist to release (on tag push) if: ${{ startsWith(github.ref, 'refs/tags/multi-') }} diff --git a/Makefile b/Makefile index e24f99eb626a..e069f75b46ac 100644 --- a/Makefile +++ b/Makefile @@ -617,6 +617,12 @@ $(STAGE1_STAMP): $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage1 c ifeq ($(DYNAMIC),1) $(SED) -i -e 's/"RTS ways","/"RTS ways","dyn debug_dyn thr_dyn thr_debug_dyn /' $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/settings endif + @# Inject the four per-target dials into the native settings file + @# too — same keys as the stage3 cross targets (see the lengthy + @# comment above $(TARGET_DIR)/lib/settings injection). The native + @# target's dynamic state tracks our DYNAMIC=0/1 build flag; prof + @# is always NO because stage2 isn't built -prof. + $(SED) -i -e 's/\]$$/,("target is dynamic","$(if $(filter 1,$(DYNAMIC)),YES,NO)"),("target ships dynamic libraries","$(if $(filter 1,$(DYNAMIC)),YES,NO)"),("target is profiled","NO"),("target ships profiling libraries","NO")]/' $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/settings $(call LOG,Creating packagedb in $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/package.conf.d) @rm -rf $(STORE_DIR)/host/$(HOST_PLATFORM)/lib/package.conf.d @@ -919,6 +925,17 @@ STAGE3_javascript-unknown-ghcjs_NM = emnm STAGE3_javascript-unknown-ghcjs_RANLIB = emranlib STAGE3_javascript-unknown-ghcjs_STRIP = emstrip STAGE3_javascript-unknown-ghcjs_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) --disable-tables-next-to-code +# JS target overrides: NO across the board. +# * dyn: iserv runs vanilla (no dlopen in the JS runtime), lib tree +# ships no .dyn_hi (Path C doesn't apply to JS — see PR #187). +# * prof: stage2 isn't built -prof, no .p_hi shipped. +# All four dials NO → `ghc --info` for the JS target reports +# `GHC Dynamic: NO`, `GHC Profiled: NO`, `Support dynamic-too: NO`. +# End-user cabal-install correctly skips library-{dynamic,profiling}. +STAGE3_javascript-unknown-ghcjs_TARGET_IS_DYNAMIC = NO +STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_DYN_LIBS = NO +STAGE3_javascript-unknown-ghcjs_TARGET_IS_PROFILED = NO +STAGE3_javascript-unknown-ghcjs_TARGET_SHIPS_PROF_LIBS = NO STAGE3_wasm32-unknown-wasi_CC = wasm32-unknown-wasi-clang STAGE3_wasm32-unknown-wasi_CC_OPTS = -fno-strict-aliasing -Wno-error=int-conversion -Oz -msimd128 -mnontrapping-fptoint -msign-ext -mbulk-memory -mmutable-globals -mmultivalue -mreference-types @@ -1007,6 +1024,42 @@ ifeq ($(DYNAMIC),1) $(SED) -i -e 's/"RTS ways","/"RTS ways","dyn /' $$(TARGET_DIR)/lib/settings endif + @# Inject the per-target dials that drive `ghc --info`'s + @# `GHC Dynamic` and `GHC Profiled` values (cabal-install reads + @# these to decide whether to enable library-dynamic / + @# library-profiling by default): + @# + @# 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: + @# GHC Dynamic = (target is dynamic) && (target ships dynamic libraries) + @# GHC Profiled = (target is profiled) && (target ships profiling libraries) + @# + @# Per-target settings file completely controls these — the + @# shared stage2 GHC binary's RTS-baked-in dynamic/prof-ness is + @# no longer consulted. Two dials per way (is / ships) so a + @# target can be capable but not currently ship artifacts (or + @# vice versa) — keeps the axes orthogonal for slimming + @# experiments and matches what cabal really wants to know. + @# + @# Defaults for our bindists: dynamic dials YES (most targets + @# ship dyn libs), prof dials NO (stage2 isn't built -prof so + @# no target currently ships prof libs). + @# Override via STAGE3__TARGET_{IS_DYNAMIC,SHIPS_DYN_LIBS, + @# IS_PROFILED,SHIPS_PROF_LIBS}. + @# + @# Note: `$$$$` (four dollars) collapses through two layers of + @# Make expansion (define-template + recipe-time) to a literal `$` + @# at shell time, which is what sed needs as the end-of-line anchor. + @# `$$` would collapse to `$` after template expansion, then Make + @# would interpret the lone `$/` in the recipe as a variable lookup + @# and drop the anchor entirely (verified: PR #187 first attempt). + $(SED) -i -e 's/\]$$$$/,("target is dynamic","$(if $(STAGE3_$(1)_TARGET_IS_DYNAMIC),$(STAGE3_$(1)_TARGET_IS_DYNAMIC),YES)"),("target ships dynamic libraries","$(if $(STAGE3_$(1)_TARGET_SHIPS_DYN_LIBS),$(STAGE3_$(1)_TARGET_SHIPS_DYN_LIBS),YES)"),("target is profiled","$(if $(STAGE3_$(1)_TARGET_IS_PROFILED),$(STAGE3_$(1)_TARGET_IS_PROFILED),NO)"),("target ships profiling libraries","$(if $(STAGE3_$(1)_TARGET_SHIPS_PROF_LIBS),$(STAGE3_$(1)_TARGET_SHIPS_PROF_LIBS),NO)")]/' $$(TARGET_DIR)/lib/settings + $$(DIST_DIR)/bin/$(1)-ghc --info @rm -rf $$(TARGET_DIR)/lib/package.conf.d diff --git a/cabal.project.stage3.settings.in b/cabal.project.stage3.settings.in index 0be702118ae4..03bc97751120 100644 --- a/cabal.project.stage3.settings.in +++ b/cabal.project.stage3.settings.in @@ -1,8 +1,8 @@ -- cabal.project.stage3.settings - generated by configure from .in template -- Do not edit this file directly; edit cabal.project.stage3.settings.in instead. -- --- Multi-target stage3: applies dynamic library settings ONLY to wasm32 --- via cabal's `if arch(wasm32)` conditional, which cabal evaluates against +-- Multi-target stage3: applies dynamic library settings per-target +-- via cabal's `if arch(...)` conditional, which cabal evaluates against -- the --with-compiler's target arch per-invocation. -- -- * stage3-wasm32-unknown-wasi (--with-compiler=wasm32-...-ghc): @@ -11,10 +11,21 @@ -- (miso, jsaddle, aeson, …) -- -- * stage3-javascript-unknown-ghcjs (--with-compiler=javascript-...-ghc): --- arch=javascript → conditional FALSE → settings do NOT apply --- → no shared:True flowing into emcc/wasm-ld (which can't produce --- .so for the JS backend; the alternative breaks with --- `wasm-ld: error: unknown argument: -h`) +-- arch=javascript → conditional TRUE (ghc-options branch) +-- → ghc-options: -dynamic-too applied per-package +-- → produces .dyn_hi files without invoking cabal's library-dynamic +-- .so link step (which fails for JS with +-- `wasm-ld: error: unknown argument: -h`). +-- Path C symmetric: the shared stage2 GHC binary reports +-- GHC Dynamic=YES (it is) which makes cabal-install enable +-- library-dynamic by default for the JS target too. Without +-- .dyn_hi files cabal then fails reading e.g. Prelude.dyn_hi +-- when compiling miso. We don't want shared:True here (which +-- would also invoke wasm-ld for an .so we don't need), so +-- ghc-options is the surgical alternative: ask GHC to emit +-- .dyn_o + .dyn_hi alongside .o + .hi during compile, and let +-- cabal skip the library-dynamic link step. .so byproducts +-- are inert for JS (no dlopen). -- -- * Native build-side packages (compiled with --with-build-compiler=ghc, -- i.e. happy-lib, alex, deriveConstants, Setup.hs scripts): @@ -33,5 +44,23 @@ if arch(wasm32) shared: True executable-dynamic: True +-- 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. + constraints: rts +dynamic diff --git a/compiler/GHC/Driver/Session.hs b/compiler/GHC/Driver/Session.hs index 02afd8c91ce4..74e96790de0e 100644 --- a/compiler/GHC/Driver/Session.hs +++ b/compiler/GHC/Driver/Session.hs @@ -132,6 +132,10 @@ module GHC.Driver.Session ( sGhcWithInterpreter, sLibFFI, sTargetRTSLinkerOnlySupportsSharedLibs, + sTargetIsDynamic, + sTargetShipsDynLibs, + sTargetIsProfiled, + sTargetShipsProfLibs, GhcNameVersion(..), FileSettings(..), PlatformMisc(..), @@ -3548,8 +3552,16 @@ compilerInfo dflags ("Have native code generator", showBool $ platformNcgSupported platform), ("target has RTS linker", showBool $ platformHasRTSLinker platform), ("Target default backend", show $ platformDefaultBackend platform), - -- Whether or not we support @-dynamic-too@ - ("Support dynamic-too", showBool $ not isWindows), + -- Whether or not we support @-dynamic-too@ for this target. + -- Historically `not isWindows` (Windows tooling couldn't do + -- it). Now also gated on the per-target `sTargetIsDynamic` + -- dial — if the target isn't dynamic-capable, -dynamic-too + -- is meaningless. Keep the Windows guard as defence in depth + -- for pre-this-patch bindists on Windows that lack the key + -- and so default sTargetIsDynamic=True (the AND would + -- otherwise regress them). + ("Support dynamic-too", showBool $ not isWindows + && sTargetIsDynamic (settings dflags)), -- Whether or not we support the @-j@ flag with @--make@. ("Support parallel --make", "YES"), -- Whether or not we support "Foo from foo-0.1-XXX:Foo" syntax in @@ -3569,10 +3581,27 @@ compilerInfo dflags ("Uses package keys", "YES"), -- Whether or not we support the @-this-unit-id@ flag ("Uses unit IDs", "YES"), - -- Whether or not GHC was compiled using -dynamic - ("GHC Dynamic", showBool hostIsDynamic), - -- Whether or not GHC was compiled using -prof - ("GHC Profiled", showBool hostIsProfiled), + -- Reported as YES iff *both* per-target settings dials say so: + -- `target is dynamic` — the GHC for this target + -- can produce dynamic output + -- `target ships dynamic libraries` — the lib tree actually has + -- .dyn_hi / .so artifacts + -- cabal-install reads this to decide whether to enable + -- @library-dynamic@ by default. The target's per-target settings + -- file completely controls this value — no host-RTS dependency, + -- so on a multi-target bindist with one shared stage2 GHC binary + -- different targets can correctly disagree. Both keys default to + -- True if absent (matches pre-this-change behaviour). + ("GHC Dynamic", showBool (sTargetIsDynamic (settings dflags) + && sTargetShipsDynLibs (settings dflags))), + -- Profiling-way analogue of `GHC Dynamic`. Per-target dials + -- via `target is profiled` + `target ships profiling libraries` + -- settings keys. Drops the historical `hostIsProfiled` RTS- + -- baked-in for the same reason the dyn pair did: on a multi- + -- target bindist the shared stage2 GHC binary's prof-ness is + -- fixed but the lib trees can disagree per target. + ("GHC Profiled", showBool (sTargetIsProfiled (settings dflags) + && sTargetShipsProfLibs (settings dflags))), ("Debug on", showBool debugIsOn), ("LibDir", topDir dflags), -- This is always an absolute path, unlike "Relative Global Package DB" which is diff --git a/compiler/GHC/Platform.hs b/compiler/GHC/Platform.hs index 5375c243c6ef..972f3fb23fc1 100644 --- a/compiler/GHC/Platform.hs +++ b/compiler/GHC/Platform.hs @@ -291,6 +291,33 @@ data PlatformMisc = PlatformMisc , platformMisc_libFFI :: Bool , platformMisc_llvmTarget :: String , platformMisc_targetRTSLinkerOnlySupportsSharedLibs :: Bool + -- | Is the GHC for this target capable of producing dynamic + -- output (i.e. can it honour @-dynamic@ / @-dynamic-too@)? + -- Per-target settings key @"target is dynamic"@ in + -- @lib/targets/\/lib/settings@. On a multi-target bindist + -- the shared stage2 GHC binary's RTS-baked-in dynamic-ness is + -- not a per-target proxy — different targets in one binary may + -- need to disagree (e.g. a JS target whose iserv runs vanilla + -- and whose lib tree has no dyn artifacts). Combined with + -- 'platformMisc_targetShipsDynLibs' to drive @ghc --info@'s + -- @GHC Dynamic@ value, which cabal-install reads. + , platformMisc_targetIsDynamic :: Bool + -- | Does the target's installed library tree ship @.dyn_hi@ / + -- @.so@ files? Per-target settings key + -- @"target ships dynamic libraries"@. Set independently of + -- 'platformMisc_targetIsDynamic' so a target can be dynamic- + -- capable but not currently ship dyn artifacts (or vice versa). + , platformMisc_targetShipsDynLibs :: Bool + -- | Profiling-way analogue of 'platformMisc_targetIsDynamic'. + -- Per-target settings key @"target is profiled"@. Drives + -- @ghc --info@'s @GHC Profiled@ — cabal-install reads that to + -- decide whether to enable @library-profiling@. + , platformMisc_targetIsProfiled :: Bool + -- | Profiling-way analogue of 'platformMisc_targetShipsDynLibs'. + -- Per-target settings key @"target ships profiling libraries"@. + -- Combined with 'platformMisc_targetIsProfiled' for the + -- @GHC Profiled@ report. + , platformMisc_targetShipsProfLibs :: Bool } platformSOName :: Platform -> FilePath -> FilePath diff --git a/compiler/GHC/Settings.hs b/compiler/GHC/Settings.hs index b8ec884a94e4..db5653794314 100644 --- a/compiler/GHC/Settings.hs +++ b/compiler/GHC/Settings.hs @@ -67,6 +67,10 @@ module GHC.Settings , sGhcWithInterpreter , sLibFFI , sTargetRTSLinkerOnlySupportsSharedLibs + , sTargetIsDynamic + , sTargetShipsDynLibs + , sTargetIsProfiled + , sTargetShipsProfLibs ) where import GHC.Prelude @@ -314,3 +318,26 @@ sLibFFI = platformMisc_libFFI . sPlatformMisc sTargetRTSLinkerOnlySupportsSharedLibs :: Settings -> Bool sTargetRTSLinkerOnlySupportsSharedLibs = platformMisc_targetRTSLinkerOnlySupportsSharedLibs . sPlatformMisc + +-- | Is the GHC for this target capable of producing dynamic output? +-- Read from the per-target settings file key @"target is dynamic"@. +-- Combined with 'sTargetShipsDynLibs' to drive @ghc --info@'s +-- @GHC Dynamic@ value — the target's settings file completely +-- controls that, no host-RTS dependency. +sTargetIsDynamic :: Settings -> Bool +sTargetIsDynamic = platformMisc_targetIsDynamic . sPlatformMisc + +-- | Does this target's installed library tree ship .dyn_hi / .so files? +-- Per-target settings key @"target ships dynamic libraries"@. +sTargetShipsDynLibs :: Settings -> Bool +sTargetShipsDynLibs = platformMisc_targetShipsDynLibs . sPlatformMisc + +-- | Profiling-way analogue of 'sTargetIsDynamic'. Per-target settings +-- key @"target is profiled"@. +sTargetIsProfiled :: Settings -> Bool +sTargetIsProfiled = platformMisc_targetIsProfiled . sPlatformMisc + +-- | Profiling-way analogue of 'sTargetShipsDynLibs'. Per-target settings +-- key @"target ships profiling libraries"@. +sTargetShipsProfLibs :: Settings -> Bool +sTargetShipsProfLibs = platformMisc_targetShipsProfLibs . sPlatformMisc diff --git a/compiler/GHC/Settings/IO.hs b/compiler/GHC/Settings/IO.hs index 63964ff02ff8..2ede21587c6c 100644 --- a/compiler/GHC/Settings/IO.hs +++ b/compiler/GHC/Settings/IO.hs @@ -185,6 +185,34 @@ initSettings top_dir = do ghcWithInterpreter <- getBooleanSetting "Use interpreter" useLibFFI <- getBooleanSetting "Use LibFFI" + -- Per-target dial #1: is the GHC for THIS target capable of + -- producing dynamic output (i.e. honouring -dynamic / + -- -dynamic-too)? On a multi-target bindist with one shared stage2 + -- GHC binary, this can't be derived from the binary's compile- + -- time `hostIsDynamic`. Default True for backward compatibility + -- with older bindist settings files that predate the key. + targetIsDynamic <- either (const $ pure True) pure $ + getRawBooleanSetting settingsFile mySettings "target is dynamic" + + -- Per-target dial #2: does this target's installed lib tree + -- actually ship .dyn_hi / .so files? Independent of + -- `target is dynamic` so a dynamic-capable target can still + -- truthfully say it doesn't ship artifacts (e.g. a slimmed + -- bindist). cabal-install combines both via GHC Dynamic to + -- decide whether to enable library-dynamic by default. + targetShipsDynLibs <- either (const $ pure True) pure $ + getRawBooleanSetting settingsFile mySettings "target ships dynamic libraries" + + -- Profiling-way analogues of the two dyn dials above. Drive + -- `GHC Profiled` the same way (sTargetIsProfiled && sTargetShipsProfLibs). + -- Defaults to True for backward compatibility with bindists that + -- predate the keys — matches the historical hostIsProfiled + -- behaviour when GHC was prof-built. + targetIsProfiled <- either (const $ pure True) pure $ + getRawBooleanSetting settingsFile mySettings "target is profiled" + targetShipsProfLibs <- either (const $ pure True) pure $ + getRawBooleanSetting settingsFile mySettings "target ships profiling libraries" + baseUnitId <- getSetting_raw "base unit-id" return $ Settings @@ -267,6 +295,10 @@ initSettings top_dir = do , platformMisc_libFFI = useLibFFI , platformMisc_llvmTarget = llvmTarget , platformMisc_targetRTSLinkerOnlySupportsSharedLibs = targetRTSLinkerOnlySupportsSharedLibs + , platformMisc_targetIsDynamic = targetIsDynamic + , platformMisc_targetShipsDynLibs = targetShipsDynLibs + , platformMisc_targetIsProfiled = targetIsProfiled + , platformMisc_targetShipsProfLibs = targetShipsProfLibs } , sRawSettings = settingsList From 01c8004afe5122a82da9809c74ee4e08102c1fde Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 11:26:50 +0900 Subject: [PATCH 08/14] address #184 review comments on stage3 settings + Cabal pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cabal.project.stage0 | 12 +++++++ cabal.project.stage1 | 2 ++ cabal.project.stage2 | 2 ++ cabal.project.stage3 | 5 +++ cabal.project.stage3.settings.in | 62 ++++++++++---------------------- 5 files changed, 40 insertions(+), 43 deletions(-) diff --git a/cabal.project.stage0 b/cabal.project.stage0 index e5af03d9c5e1..c0cf5784ccd5 100644 --- a/cabal.project.stage0 +++ b/cabal.project.stage0 @@ -1,3 +1,15 @@ +-- Cabal pin: stable-haskell/Cabal branch `feat/rpath-relativize-absolute` +-- (= stable-haskell/Cabal PR #368). Patches Link.hs to unconditionally +-- relativize absolute rpaths via `shortRelativePath` against the +-- artifact's bindir/libdir, fixing the darwin LC_RPATH leak (task #51) +-- without flipping cabal's `relocatable: True` flag — the latter also +-- emits `library-dirs: ${pkgroot}/...` which our post-stage2 bindist +-- path rewriting can't cope with. See lode/rpath-leak-investigation.md. +-- +-- stage0/1/2 share this pin (host-side Cabal). stage3 deliberately stays +-- on the older proven SHA `44817477...` — the cross-build doesn't hit +-- the macOS rpath issue and we don't want to test the rpath patch on +-- the wasm path. source-repository-package type: git location: https://github.com/stable-haskell/Cabal.git diff --git a/cabal.project.stage1 b/cabal.project.stage1 index 74cd7f353308..89781c567be4 100644 --- a/cabal.project.stage1 +++ b/cabal.project.stage1 @@ -43,6 +43,8 @@ packages: -- hsc2hs: see source-repository-package below source-repository-package + -- See cabal.project.stage0 for what this Cabal SHA pins (rpath patch + -- for darwin LC_RPATH leak; stage0/1/2 share the same host-side Cabal). type: git location: https://github.com/stable-haskell/Cabal.git tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d diff --git a/cabal.project.stage2 b/cabal.project.stage2 index 8bcf5d474a60..22eec73772f0 100644 --- a/cabal.project.stage2 +++ b/cabal.project.stage2 @@ -86,6 +86,8 @@ packages: -- wip/angerman/compile-less (cross-compilation + local store + recompilation avoidance) source-repository-package + -- See cabal.project.stage0 for what this Cabal SHA pins (rpath patch + -- for darwin LC_RPATH leak; stage0/1/2 share the same host-side Cabal). type: git location: https://github.com/stable-haskell/Cabal.git tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d diff --git a/cabal.project.stage3 b/cabal.project.stage3 index 0ba84d878618..68ddc26f2356 100644 --- a/cabal.project.stage3 +++ b/cabal.project.stage3 @@ -71,6 +71,11 @@ packages: https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz source-repository-package + -- stage3 (cross-build) deliberately stays on the older proven SHA + -- `44817477...` instead of the rpath-patched `6a5ce816...` used by + -- stage0/1/2. The cross-build doesn't hit the macOS LC_RPATH leak, + -- and we don't want to take the rpath patch on the wasm path. See + -- cabal.project.stage0 for the rpath patch context. type: git location: https://github.com/stable-haskell/Cabal.git tag: 44817477ff6d22de4bfa4307e061df58f319d3b6 diff --git a/cabal.project.stage3.settings.in b/cabal.project.stage3.settings.in index 03bc97751120..05df9f20587d 100644 --- a/cabal.project.stage3.settings.in +++ b/cabal.project.stage3.settings.in @@ -1,8 +1,8 @@ -- cabal.project.stage3.settings - generated by configure from .in template -- Do not edit this file directly; edit cabal.project.stage3.settings.in instead. -- --- Multi-target stage3: applies dynamic library settings per-target --- via cabal's `if arch(...)` conditional, which cabal evaluates against +-- Multi-target stage3: applies dynamic library settings ONLY to wasm32 +-- via cabal's `if arch(wasm32)` conditional, which cabal evaluates against -- the --with-compiler's target arch per-invocation. -- -- * stage3-wasm32-unknown-wasi (--with-compiler=wasm32-...-ghc): @@ -11,21 +11,14 @@ -- (miso, jsaddle, aeson, …) -- -- * stage3-javascript-unknown-ghcjs (--with-compiler=javascript-...-ghc): --- arch=javascript → conditional TRUE (ghc-options branch) --- → ghc-options: -dynamic-too applied per-package --- → produces .dyn_hi files without invoking cabal's library-dynamic --- .so link step (which fails for JS with --- `wasm-ld: error: unknown argument: -h`). --- Path C symmetric: the shared stage2 GHC binary reports --- GHC Dynamic=YES (it is) which makes cabal-install enable --- library-dynamic by default for the JS target too. Without --- .dyn_hi files cabal then fails reading e.g. Prelude.dyn_hi --- when compiling miso. We don't want shared:True here (which --- would also invoke wasm-ld for an .so we don't need), so --- ghc-options is the surgical alternative: ask GHC to emit --- .dyn_o + .dyn_hi alongside .o + .hi during compile, and let --- cabal skip the library-dynamic link step. .so byproducts --- are inert for JS (no dlopen). +-- arch=javascript → conditional FALSE → settings do NOT apply +-- → no shared:True flowing into emcc/wasm-ld (which can't produce +-- .so for the JS backend; the alternative breaks with +-- `wasm-ld: error: unknown argument: -h`) +-- JS-side .dyn_hi shipping is a separate concern, driven instead +-- by the per-target settings dials introduced for #67 (target is +-- dynamic / target ships dynamic libraries, set to NO for +-- javascript-unknown-ghcjs in the Makefile). -- -- * Native build-side packages (compiled with --with-build-compiler=ghc, -- i.e. happy-lib, alex, deriveConstants, Setup.hs scripts): @@ -33,34 +26,17 @@ -- → no shared, no -dynamic-too codepath -- → builds against vanilla native base (no need for native .dyn_hi) -- --- Hardcoded — no per-package autoconf substitution (we deliberately --- avoid the literal variable name in this comment so autoconf does --- not expand it). DYNAMIC=1 / DYNAMIC=0 has no effect on stage3 --- (the wasm target ALWAYS needs shared:True regardless of how --- stage2 was built; the conditional handles JS/native exclusion). +-- Hardcoded — no per-package autoconf substitution. DYNAMIC=1/0 has +-- no effect on stage3: the wasm target ALWAYS needs shared:True +-- regardless of how stage2 was built; the conditional handles JS +-- and native build-side exclusion. The `rts +dynamic` constraint +-- lives inside the if-block too, so it's wasm-only by construction +-- and matches stage2's APPEND_CONSTRAINT(rts +dynamic) semantics +-- exactly when --enable-dynamic flows through. if arch(wasm32) package * shared: True executable-dynamic: True - --- 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. - -constraints: - rts +dynamic + constraints: + rts +dynamic From 3f31d013cf1c4f0d2e8cd3a4fe5f2795431cea05 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 11:31:36 +0900 Subject: [PATCH 09/14] unify stage3 Cabal pin with stage0/1/2 (6a5ce816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cabal.project.stage0 | 7 +++---- cabal.project.stage3 | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cabal.project.stage0 b/cabal.project.stage0 index c0cf5784ccd5..5d24b7156ad6 100644 --- a/cabal.project.stage0 +++ b/cabal.project.stage0 @@ -6,10 +6,9 @@ -- emits `library-dirs: ${pkgroot}/...` which our post-stage2 bindist -- path rewriting can't cope with. See lode/rpath-leak-investigation.md. -- --- stage0/1/2 share this pin (host-side Cabal). stage3 deliberately stays --- on the older proven SHA `44817477...` — the cross-build doesn't hit --- the macOS rpath issue and we don't want to test the rpath patch on --- the wasm path. +-- All stages (0/1/2/3) share this pin. 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. source-repository-package type: git location: https://github.com/stable-haskell/Cabal.git diff --git a/cabal.project.stage3 b/cabal.project.stage3 index 68ddc26f2356..659aec04c661 100644 --- a/cabal.project.stage3 +++ b/cabal.project.stage3 @@ -71,14 +71,14 @@ packages: https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz source-repository-package - -- stage3 (cross-build) deliberately stays on the older proven SHA - -- `44817477...` instead of the rpath-patched `6a5ce816...` used by - -- stage0/1/2. The cross-build doesn't hit the macOS LC_RPATH leak, - -- and we don't want to take the rpath patch on the wasm path. See - -- cabal.project.stage0 for the rpath patch context. + -- See cabal.project.stage0 for what this Cabal SHA pins (rpath patch + -- for darwin LC_RPATH leak; all stages share the same host-side Cabal). + -- The rpath patch only touches host-linker codepaths (wasm-ld and the + -- JS backend don't use rpaths) so it's a no-op for stage3's cross + -- outputs, but unifying the pin avoids two-Cabal maintenance overhead. type: git location: https://github.com/stable-haskell/Cabal.git - tag: 44817477ff6d22de4bfa4307e061df58f319d3b6 + tag: 6a5ce8161ca76356a9ea43f2e9e09483e6f5849d subdir: Cabal Cabal-syntax From 94d9d4a2c2852750a79c9ed058b569214bb8ac93 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 12:28:16 +0900 Subject: [PATCH 10/14] stage3.settings.in: restore @ALL_PACKAGES@ / @CONSTRAINTS@ template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cabal.project.stage3.settings.in | 18 ++++++++---------- configure.ac | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/cabal.project.stage3.settings.in b/cabal.project.stage3.settings.in index 05df9f20587d..19629b7bd8ea 100644 --- a/cabal.project.stage3.settings.in +++ b/cabal.project.stage3.settings.in @@ -26,17 +26,15 @@ -- → no shared, no -dynamic-too codepath -- → builds against vanilla native base (no need for native .dyn_hi) -- --- Hardcoded — no per-package autoconf substitution. DYNAMIC=1/0 has --- no effect on stage3: the wasm target ALWAYS needs shared:True --- regardless of how stage2 was built; the conditional handles JS --- and native build-side exclusion. The `rts +dynamic` constraint --- lives inside the if-block too, so it's wasm-only by construction --- and matches stage2's APPEND_CONSTRAINT(rts +dynamic) semantics --- exactly when --enable-dynamic flows through. +-- The package / constraint bodies below come from the same autoconf +-- accumulation variables stage2.settings.in uses (APPEND_PKG_FIELD / +-- APPEND_CONSTRAINT in configure.ac), via the 4-space-indented +-- @ALL_PACKAGES_STAGE3@ / @CONSTRAINTS_STAGE3@ variants — so passing +-- --enable-dynamic to configure flips stage2 and stage3 (wasm) together, +-- without duplicating the package-fields list. if arch(wasm32) package * - shared: True - executable-dynamic: True +@ALL_PACKAGES_STAGE3@@STAGE3_EXTRA_PKG@ constraints: - rts +dynamic +@CONSTRAINTS_STAGE3@ diff --git a/configure.ac b/configure.ac index 753d52595095..98c74f786c1d 100644 --- a/configure.ac +++ b/configure.ac @@ -69,7 +69,10 @@ AS_IF([test "x$enable_dynamic" = "xyes"], [ STAGE3_EXTRA_PKG="" AC_SUBST([STAGE3_EXTRA_PKG]) -# Indent with two spaces for substitution blocks (uniform handling) +# Indent with two spaces for the stage2 substitution blocks: in +# cabal.project.stage2.settings.in the @ALL_PACKAGES@ / @CONSTRAINTS@ +# tokens sit one level deep (under `package *` / `constraints:` at +# column 0). ALL_PACKAGES=`printf '%b' "$ALL_PACKAGES"` CONSTRAINTS=`printf '%b' "$CONSTRAINTS"` ALL_PACKAGES_INDENTED=`printf '%s' "$ALL_PACKAGES" | sed 's/^/ /'` @@ -80,6 +83,16 @@ AS_IF([test "x$CONSTRAINTS" = "x"], [CONSTRAINTS=" -- (none)"], [CONSTRAINTS="$ AC_SUBST([ALL_PACKAGES]) AC_SUBST([CONSTRAINTS]) +# Stage3 substitution blocks live INSIDE `if arch(wasm32)` (since +# shared:True / rts +dynamic are wasm-only there), so they need an +# extra level of indent (4 spaces total) to nest correctly under +# `package *` / `constraints:` at column 2. Use the same source data +# as stage2 — just re-indent. +ALL_PACKAGES_STAGE3=`printf '%s' "$ALL_PACKAGES" | sed 's/^/ /'` +CONSTRAINTS_STAGE3=`printf '%s' "$CONSTRAINTS" | sed 's/^/ /'` +AC_SUBST([ALL_PACKAGES_STAGE3]) +AC_SUBST([CONSTRAINTS_STAGE3]) + # --- Define Programs --- # We don't need to check for CC, MAKE_SET, and others for now, we only want substitution. # AC_PROG_CC From 13d5775095f94e99e6eec9cd42977d82c9b8c04d Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 14:43:42 +0900 Subject: [PATCH 11/14] stage3.settings.in: don't name @VAR@ tokens literally in the comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 3f4aa3bdc 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. --- cabal.project.stage3.settings.in | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cabal.project.stage3.settings.in b/cabal.project.stage3.settings.in index 19629b7bd8ea..3b55161a79c0 100644 --- a/cabal.project.stage3.settings.in +++ b/cabal.project.stage3.settings.in @@ -28,10 +28,12 @@ -- -- The package / constraint bodies below come from the same autoconf -- accumulation variables stage2.settings.in uses (APPEND_PKG_FIELD / --- APPEND_CONSTRAINT in configure.ac), via the 4-space-indented --- @ALL_PACKAGES_STAGE3@ / @CONSTRAINTS_STAGE3@ variants — so passing --- --enable-dynamic to configure flips stage2 and stage3 (wasm) together, --- without duplicating the package-fields list. +-- APPEND_CONSTRAINT in configure.ac), via 4-space-indented variants +-- (see configure.ac) — so passing --enable-dynamic to configure flips +-- stage2 and stage3 (wasm) together, without duplicating the +-- package-fields list. Do not name the substitution tokens literally +-- in this comment: autoconf would substitute them here too and inject +-- multi-line content mid-comment, producing invalid cabal syntax. if arch(wasm32) package * From d17a78c3e6fee6fce7aa68ac303b0698972c571e Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 20:53:11 +0900 Subject: [PATCH 12/14] ci: fold miso-counter TH-heavy test into e2e-MULTI, retire e2e-WASM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/channel-e2e-wasm.yml | 426 ------------------------- 1 file changed, 426 deletions(-) delete mode 100644 .github/workflows/channel-e2e-wasm.yml diff --git a/.github/workflows/channel-e2e-wasm.yml b/.github/workflows/channel-e2e-wasm.yml deleted file mode 100644 index 5a589de15375..000000000000 --- a/.github/workflows/channel-e2e-wasm.yml +++ /dev/null @@ -1,426 +0,0 @@ -# Channel end-to-end CI — wasm-only single-target bindist channel. -# -# Validates the SHIPPED wasm cross-compiler + cabal + ghcup channel YAML -# (`ghcup-wasm.yaml`) by exercising the exact flow an end-user does: -# 1. install ghcup fresh -# 2. add the stable-haskell ghcup channel -# 3. ghcup install ghc / ghcup install cabal -# 4. build the published example templates (hello + miso-counter) -# -# This closes a gap left by `Cross: WASM` in nix-ci.yml — those jobs -# validate the compiler in-tree (`_build/dist/bin/wasm32-unknown-wasi-ghc`) -# from the active checkout, not the published ghcup-installed bundle that -# real users actually get. Concretely, this catches: -# * channel YAML schema breakage -# * dlUri / dlHash mismatches (e.g. asset re-uploaded with different bytes) -# * relocate.sh / post-install hook regressions on a fresh install prefix -# * dual-compiler regressions in the shipped `cabal` -# * TH-heavy build regressions at scale (the miso template — 50+ deps -# incl. aeson, lens, jsaddle, jsaddle-wasm, miso) -# -# Runs on (all github-hosted; this repo is public, so standard runners — -# macOS included — are free. The per-minute multipliers only apply to -# billed minutes on private repos or to larger runners): -# * github-hosted ubuntu-latest (x86_64-linux) -# * github-hosted ubuntu-24.04-arm (aarch64-linux) -# * github-hosted macos-15 (aarch64-darwin) -# -# The darwin leg deliberately runs on a stock github-hosted macOS image -# (Xcode + Command Line Tools preinstalled, no nix): the whole point is -# to mirror what a real end-user has. Our self-hosted aarch64-darwin Tart -# VMs are the opposite — nix + a pre-imported devx closure, and no CLT — -# so they're the wrong environment for an end-user-fidelity test. They -# stay reserved for the nix-based in-tree builds in nix-ci.yml. -# -# Companion workflow: `Channel e2e (MULTI)` in -# `.github/workflows/channel-e2e-multi.yml` covers the separate -# `ghcup-multi-target-0.1.0.yaml` channel. -name: Channel e2e (WASM) - -on: - push: - tags: [wasm32-wasi-*] # new GHC release → re-test - pull_request: - # validate workflow changes themselves end-to-end before merge - paths: ['.github/workflows/channel-e2e-wasm.yml'] - workflow_dispatch: - inputs: - wasm_version: - description: 'GHC wasm version to test (e.g. wasm32-wasi-9.14.0.stable.12)' - default: '' - # Weekly silent-regression canary on the default branch. Catches drift: - # release asset re-uploaded, channel YAML mis-deployed, NodeSource - # setup_22.x breaking, ghcup-runner-image quirks, wasi-sdk bootstrap.sh - # layout changes, etc. Cron only fires on the workflow's home default - # branch (stable-ghc-9.14 once this lands there). - schedule: - - cron: '0 6 * * 1' # Monday 06:00 UTC - -# Only run one at a time per ref — avoids racing on cabal-store side effects -# when the cron and a manual dispatch happen close together. -concurrency: - group: channel-e2e-wasm-${{ github.ref }} - cancel-in-progress: false - -jobs: - install-and-build: - name: e2e WASM / ${{ matrix.plat }} - # matrix.runner is a JSON-string ARRAY of labels: '["label1", ...]', - # so every leg selects its github-hosted image through one knob. - runs-on: ${{ fromJSON(matrix.runner) }} - # 60min: aarch64-linux took 30min to build through aeson/lens/jsaddle - # for the miso template; miso itself is sizeable, and ubuntu-24.04-arm - # is meaningfully slower than ubuntu-latest. 30min hit timeout right - # before miso started compiling. x86_64-linux fits comfortably in 30 - # min so this only adds slack for the slow runner. - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { plat: x86_64-linux, runner: '["ubuntu-latest"]' } - - { plat: aarch64-linux, runner: '["ubuntu-24.04-arm"]' } - - { plat: aarch64-darwin, runner: '["macos-15"]' } - - env: - # Default is the current LatestPrerelease; workflow_dispatch can override. - # Push events (tag wasm32-wasi-*) leave inputs empty → fall back to default. - WASM_VER: ${{ inputs.wasm_version != '' && inputs.wasm_version || 'wasm32-wasi-9.14.0.stable.12' }} - CABAL_VER: 3.17.0.0.stable.0 - - steps: - # --------------------------------------------------------------------- - # 1. ghcup itself — fresh install. We DON'T use the pre-installed ghcup - # on github-hosted runners; this validates the official installer - # works as documented in the README and landing page. - # - # Two ghcup-runner-image quirks to work around: - # * /usr/local/.ghcup pre-baked, root-owned → set - # GHCUP_INSTALL_BASE_PREFIX="$HOME" so install lands in ~/.ghcup. - # * $HOME/.ghcup ALSO pre-baked on some images, owned root-ish → - # wipe it first so the installer's chmod +x has clean ground. - # - # MINIMAL=1 skips the auto-install of latest GHC/cabal; the channel - # YAML installs the stable-haskell variants we actually want. - # --------------------------------------------------------------------- - - name: Install ghcup fresh - run: | - set -euo pipefail - # Wipe pre-baked ghcup locations. github-hosted ubuntu runners - # ship /usr/local/.ghcup (root-owned); the macOS runners don't, - # so the sudo path runs only on Linux. The $HOME/.ghcup wipe is - # unconditional (cheap, no-op if absent). - rm -rf "$HOME/.ghcup" - if [ "$(uname -s)" = "Linux" ] && [ -d /usr/local/.ghcup ]; then - sudo rm -rf /usr/local/.ghcup - fi - curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ - | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ - BOOTSTRAP_HASKELL_MINIMAL=1 \ - GHCUP_INSTALL_BASE_PREFIX="$HOME" \ - sh - echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" - # Belt-and-suspenders: persist this for every subsequent step so - # ghcup's own re-derivation of its base prefix doesn't drift. - echo "GHCUP_INSTALL_BASE_PREFIX=$HOME" >> "$GITHUB_ENV" - - # --------------------------------------------------------------------- - # 2. System tools: gcc (cabal needs it for the dual-compiler build's - # native side — Setup.hs compiles + FFI cbits) and Node 22. - # - # Ubuntu noble's apt nodejs is 18.19.1, but post-link.mjs uses - # `import.meta.filename` (added Node 20.11). On Node 18 the - # post-link step silently exits without writing the .mjs glue, - # breaking subsequent JSFFI runs. NodeSource setup_22.x is the - # canonical fix. - # --------------------------------------------------------------------- - - name: Install gcc + Node 22 (Linux via apt + NodeSource) - if: runner.os == 'Linux' - run: | - set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends build-essential - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y --no-install-recommends nodejs - gcc --version | head -1 - # ubuntu-24.04-arm pre-bakes Node 20 in /opt/hostedtoolcache and - # adds it to PATH ahead of /usr/bin. That means even after we - # apt-install Node 22 to /usr/bin/node, `node` resolves to the - # toolcache v20 — which then runs dyld.mjs under a Node release - # that lacks features Node 22 introduced (stream.toWeb flow - # control over experimental .toWeb()), causing miso TH builds - # to deadlock against wasm-iserv. - # See aarch64-linux miso hang investigation (run 26700668292, - # logged v20.20.0 on aarch64 vs v22.22.3 on x86_64 with the - # same workflow). - # Force /usr/bin first so the NodeSource Node 22 binary wins. - echo "/usr/bin" >> "$GITHUB_PATH" - export PATH="/usr/bin:$PATH" - which node - node --version - # Assert Node 22+ — fail loud if the toolcache still shadows. - ver=$(node --version | tr -d v | cut -d. -f1) - test "$ver" -ge 22 || { echo "::error::expected Node 22+, got $(node --version)"; exit 1; } - - # Darwin: install Node 22 from the official nodejs.org darwin-arm64 - # tarball directly — hermetic, matches what an end-user who just - # installed Node would have, and independent of whatever Node the - # runner image happens to preinstall. - # - # No clang/CLT setup needed: github-hosted macOS images ship Xcode + - # Command Line Tools preinstalled, so /usr/bin/clang and the SDK are - # already there (the whole reason this leg moved off the self-hosted - # Tart VMs, which carry nix but no CLT). - - name: Install Node 22 (Darwin) - if: runner.os == 'macOS' - run: | - set -euo pipefail - NODE_VER=v22.22.0 - NODE_DIR="$HOME/node-$NODE_VER" - rm -rf "$NODE_DIR" - curl -fsSL "https://nodejs.org/dist/$NODE_VER/node-$NODE_VER-darwin-arm64.tar.xz" \ - | tar -xJ -C "$HOME" - mv "$HOME/node-$NODE_VER-darwin-arm64" "$NODE_DIR" - echo "$NODE_DIR/bin" >> "$GITHUB_PATH" - "$NODE_DIR/bin/node" --version - # Sanity-check the preinstalled Apple toolchain is wired up. - xcode-select -p - /usr/bin/clang --version | head -1 - - # --------------------------------------------------------------------- - # 3a. Install a native GHC for the dual-compiler build. The hello - # and miso templates' cabal.project sets - # with-build-compiler: ghc - # with-compiler: wasm32-unknown-wasi-ghc - # so we need both a native `ghc` (for Setup.hs + Template Haskell - # host evaluation) and the wasm cross-compiler. An end-user - # following our install docs would do `ghcup install ghc recommended` - # before working on a project; replicate that here. - # --------------------------------------------------------------------- - - name: Install native GHC (recommended) for Setup.hs + TH host - run: | - set -euo pipefail - ghcup install ghc recommended - ghcup set ghc recommended - ghc --version - - # --------------------------------------------------------------------- - # 3b. Add the stable-haskell channel + install the wasm cross-compiler. - # The channel URL is the LIVE one — this test fails if gh-pages - # is mis-deployed or the release assets aren't yet uploaded. - # --------------------------------------------------------------------- - - name: Add stable-haskell channel + install wasm GHC - run: | - set -euo pipefail - ghcup --version - ghcup config add-release-channel \ - https://stable-haskell.github.io/ghc/ghcup-wasm.yaml - ghcup install ghc "$WASM_VER" - # Put the cross-compiler on PATH for subsequent steps. - echo "$HOME/.ghcup/ghc/$WASM_VER/bin" >> "$GITHUB_PATH" - - # --------------------------------------------------------------------- - # 3c. Install wasi-sdk. The wasm GHC's `settings` file declares - # `C compiler command = wasm32-unknown-wasi-clang`. That binary - # ships in wasi-sdk, NOT in our channel (intentionally — wasi-sdk - # pinning is ghc-wasm-meta's domain). End users need it too. - # - # ghc-wasm-meta's bootstrap.sh extracts wasi-sdk under - # $HOME/.ghc-wasm/wasi-sdk/. We also create wasm32-unknown-wasi-* - # symlinks because the wasi-sdk bindist ships the binaries with - # `wasm32-wasi-` prefix while autoconf's canonical triple (which - # ghc-toolchain-bin baked into the wasm GHC settings) is - # `wasm32-unknown-wasi-`. - # --------------------------------------------------------------------- - - name: Install wasi-sdk via ghc-wasm-meta bootstrap - run: | - set -euo pipefail - export TMPDIR="$GITHUB_WORKSPACE/_tmp" - mkdir -p "$TMPDIR" - curl -fsSL https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/raw/master/bootstrap.sh | \ - FLAVOUR=9.12 PREFIX=$HOME/.ghc-wasm sh - WASI_BIN="$HOME/.ghc-wasm/wasi-sdk/bin" - test -d "$WASI_BIN" || { echo "::error::$WASI_BIN missing — bootstrap.sh layout changed?"; exit 1; } - # Bridge the prefix mismatch — wasi-sdk ships wasm32-wasi-clang, - # the wasm GHC settings expects wasm32-unknown-wasi-clang. - for tool in clang clang++; do - ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" - done - # llvm-{ar,nm,ranlib,strip} are wrapped under the canonical triple - # too, in case the wasm GHC settings file references any of them. - for tool in ar nm ranlib strip; do - ln -sf "$WASI_BIN/llvm-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" 2>/dev/null || true - done - echo "$WASI_BIN" >> "$GITHUB_PATH" - ls "$WASI_BIN" | grep -E "wasm32-unknown-wasi-(clang|ar|nm|ranlib|strip)" | head -10 - - # --------------------------------------------------------------------- - # 3c. Install cabal from the channel. cabal-$CABAL_VER MUST install - # successfully on every supported platform; any failure (channel - # YAML missing the entry, dlHash mismatch, platform classification - # drift, network issue) is a real bug — fail loud rather than - # silently skip downstream tests. - # - # Earlier iterations of this step had a soft fallback that set - # cabal_installed=false and exited 0 when ghcup reported "Unable - # to find a download for Tool", from the days when cabal wasn't - # yet on every Linux variant. Removed: stale + masks real bugs. - # --------------------------------------------------------------------- - - name: Install cabal - run: | - set -euo pipefail - ghcup install cabal "$CABAL_VER" - ghcup set cabal "$CABAL_VER" - cabal --version - - # --------------------------------------------------------------------- - # 4. Sanity: verify the cabal binary and a standalone single-compiler - # build pass — confirms the stable-haskell cabal-install binary itself - # is healthy, the channel YAML is correct, and dependency lookup - # (gcc / pkg-config / ghc) all work in the basic case. - # --------------------------------------------------------------------- - - name: Sanity — single-compiler cabal build - run: | - set -euo pipefail - cabal --version - mkdir -p /tmp/probe && cd /tmp/probe - cat > probe.cabal <<'EOF' - cabal-version: 3.0 - name: probe - version: 0.1 - executable probe - main-is: Main.hs - build-depends: base - default-language: Haskell2010 - EOF - echo 'main = putStrLn "ok"' > Main.hs - cat > cabal.project <<'EOF' - packages: . - with-build-compiler: ghc - with-build-hc-pkg: ghc-pkg - EOF - cabal update - cabal build - cabal run -v0 probe | tee /tmp/probe-out - grep -q '^ok$' /tmp/probe-out - - # --------------------------------------------------------------------- - # 4b. Darwin diagnostic — pin down why cabal sees an empty - # `wasm32-unknown-wasi-ghc --version` on github-hosted macos-15. - # The bindist installs + reports 9.14 fine on aarch64-darwin macOS - # 26 (verified via ghcup's configure+make install path), so this is - # macos-15-specific — suspect dyld/quarantine blocking the dyn-linked - # host-dylib load via @rpath. Also surfaces the bindist's leaked - # /Volumes/WorkSpace build rpath. Informational only (never fails); - # remove once the cause is fixed. - # --------------------------------------------------------------------- - - name: Diagnose wasm-ghc version detection (Darwin) - if: runner.os == 'macOS' - run: | - set +e - echo "macOS: $(sw_vers -productVersion) ($(sw_vers -buildVersion))" - echo "resolved on PATH: $(command -v wasm32-unknown-wasi-ghc || true)" - PREFIX="$HOME/.ghcup/ghc/$WASM_VER" - BIN="$PREFIX/bin/wasm32-unknown-wasi-ghc" - echo "=== file ==="; file "$BIN" - echo "=== rpaths (watch for a leaked /Volumes/WorkSpace build path) ===" - otool -l "$BIN" 2>/dev/null | grep -A2 LC_RPATH | grep path - echo "=== host dylib dir present? ===" - ls "$PREFIX/lib/aarch64-apple-darwin" 2>&1 | head -5 - echo "=== quarantine xattrs on binary + a host dylib? ===" - xattr "$BIN" 2>&1 - xattr "$PREFIX/lib/aarch64-apple-darwin/"*.dylib 2>/dev/null | head -3 - echo "=== direct --numeric-version (stdout / stderr / exit) ===" - out="$("$BIN" --numeric-version 2>/tmp/wghc.err)"; rc=$? - echo "stdout=[$out] exit=$rc"; echo "stderr:"; cat /tmp/wghc.err - echo "=== dyld trace of the same invocation ===" - DYLD_PRINT_LIBRARIES=1 DYLD_PRINT_RPATHS=1 "$BIN" --numeric-version 2>&1 | head -60 - true - - # --------------------------------------------------------------------- - # 5. Smoke test — hello template. ~30 s build, runs via node:wasi - # and prints "Hello from the WASM reactor!" — covers reactor - # bring-up + ghc_wasm_jsffi_init + hs_start sequence. - # --------------------------------------------------------------------- - - name: hello template — build + run-node - run: | - set -euo pipefail - curl -fL -o hello.tar.gz \ - https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-hello.tar.gz - tar xf hello.tar.gz - cd stable-haskell-wasm-hello - # The published template's Makefile bakes WASM_VERSION=stable.0 (it - # was packaged before stable.1/.2 shipped). Override on the - # command line so post-link.mjs resolves under our $WASM_VER prefix. - make WASM_VERSION="$WASM_VER" build - # Use tee so we see the output even when run-node exits non-zero; - # `out=$(...)` + set -e would swallow it. - make WASM_VERSION="$WASM_VER" run-node 2>&1 | tee /tmp/run-node.out - grep -q 'Hello from the WASM reactor!' /tmp/run-node.out \ - || { echo "::error::expected 'Hello from the WASM reactor!' in run-node output"; exit 1; } - - # --------------------------------------------------------------------- - # 6. Real-world test — miso-counter template. 50+ TH-heavy deps. - # First build ~5-10 min on a clean cabal store. Verifies the full - # dyld + JSFFI link chain works at scale. Skip the run-web step - # (needs a browser); just check the .wasm artifact exists and is - # a valid WebAssembly module — sufficient to catch link-level - # regressions. - # --------------------------------------------------------------------- - - name: miso-counter template — build + verify wasm artifact - run: | - set -euo pipefail - curl -fL -o miso.tar.gz \ - https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-miso-counter.tar.gz - tar xf miso.tar.gz - cd stable-haskell-wasm-miso-counter - - # ------------------------------------------------------------------ - # Diagnostic monitor — captures process + memory state every 60s - # during the miso build, so when (if) the build hangs we know - # WHICH process is stuck and HOW (sleeping vs spinning, memory - # footprint, child relationships). - # - # Triggered by the aarch64-linux miso hang in stable.12's first - # e2e attempt (run 26700668292): jsaddle-wasm completed, then 47 - # minutes of silence before timeout. Need module-level + process- - # level visibility to diagnose. - # ------------------------------------------------------------------ - ( - while true; do - echo "::group::diag $(date +%H:%M:%S)" - echo "--- ps (wasm/ghc/cabal/node) ---" - if [ "$(uname -s)" = "Darwin" ]; then - ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ - | head -1 - ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ - | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true - echo "--- memory ---" - vm_stat | head -10 - else - ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ - | head -1 - ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ - | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true - echo "--- memory ---" - free -h 2>/dev/null - fi - echo "::endgroup::" - sleep 60 - done - ) & - MONITOR_PID=$! - # Make sure the monitor goes down with the step, regardless of - # success/failure/cancellation. The cancellation case relies on - # GitHub sending SIGTERM to our shell, which propagates via - # default trap behavior — explicit trap is belt-and-suspenders. - trap 'kill $MONITOR_PID 2>/dev/null || true' EXIT INT TERM - - # Same WASM_VERSION override as the hello step. - make WASM_VERSION="$WASM_VER" build - wasm_file=$(find dist-newstyle -name 'myapp.wasm' -type f | head -1) - test -n "$wasm_file" || { echo "::error::myapp.wasm not produced"; exit 1; } - ls -lh "$wasm_file" - file "$wasm_file" | tee /tmp/file_out - grep -q 'WebAssembly' /tmp/file_out \ - || { echo "::error::artifact is not a wasm binary"; exit 1; } From e49e5d4a13e921ca118240d6e34e57b222279e0e Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 7 Jun 2026 20:55:46 +0900 Subject: [PATCH 13/14] ci(e2e-multi): add miso-counter TH-heavy step via the wasm frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the e2e-WASM retirement in the previous commit (d17a78c3e6f) — 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. --- .github/workflows/channel-e2e-multi.yml | 96 +++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/.github/workflows/channel-e2e-multi.yml b/.github/workflows/channel-e2e-multi.yml index 64749a9323ba..0fd63fc4ed41 100644 --- a/.github/workflows/channel-e2e-multi.yml +++ b/.github/workflows/channel-e2e-multi.yml @@ -8,12 +8,15 @@ # 4. ghcup set ghc multi-9.14.0.stable.X # 5. compile + run a hello-world for each of native / wasm / JS — proves # argv[0] dispatch is correctly wired by the bindist install +# 6. build the miso-counter template via the wasm frontend — 50+ TH-heavy +# deps (aeson / lens / jsaddle / jsaddle-wasm / miso); catches +# TH-at-scale regressions in dyld + JSFFI + cabal-dual-compiler that +# hello-worlds wouldn't notice # -# Distinct from `Channel e2e (WASM)` which tests the legacy single-target -# wasm channel (ghcup-wasm.yaml). The multi-target channel -# (ghcup-multi-target-0.1.0.yaml) ships a different bindist layout with -# all three target frontends in one tarball, and merits its own test -# surface so failures attribute clearly. +# This workflow is the sole end-to-end test surface — the legacy +# `Channel e2e (WASM)` workflow (and the `ghcup-wasm.yaml` single-target +# channel it covered) is retired: the multi-target bindist supersedes it +# and we'd rather not maintain two near-identical CI surfaces. name: Channel e2e (MULTI) on: @@ -45,7 +48,11 @@ jobs: install-and-multi: name: e2e MULTI / ${{ matrix.plat }} runs-on: ${{ fromJSON(matrix.runner) }} - timeout-minutes: 45 + # 60min: the miso-counter step adds 5-10 min on x86_64-linux + macOS, + # and aarch64-linux is meaningfully slower (ubuntu-24.04-arm runner + # historically took ~30 min through the full aeson / lens / jsaddle / + # miso TH chain). Hello-worlds alone fit in 15 min; miso eats the rest. + timeout-minutes: 60 continue-on-error: ${{ matrix.allow-failure == true }} strategy: fail-fast: false @@ -62,7 +69,10 @@ jobs: steps: # --------------------------------------------------------------------- - # 1. Fresh ghcup (mirrors the Channel e2e (WASM) workflow). + # 1. Fresh ghcup install. Wipe pre-baked locations and run the + # official installer the same way an end-user would — proves the + # install-time documentation we publish actually works on the + # standard runner images. # --------------------------------------------------------------------- - name: Install ghcup fresh run: | @@ -256,11 +266,83 @@ jobs: make WASM_VERSION="$MULTI_VER" run-node 2>&1 | tee /tmp/multi-run.out grep -q 'Hello from the WASM reactor!' /tmp/multi-run.out + # --------------------------------------------------------------------- + # 7b. miso-counter — full TH-heavy real-app build via the wasm + # frontend. 50+ transitive deps including aeson, lens, jsaddle, + # jsaddle-wasm, miso. This is the load-bearing end-to-end test: + # hello-worlds wouldn't notice TH-at-scale regressions in dyld / + # JSFFI / the cabal-dual-compiler split. Skip `run-web` (needs a + # browser); verifying the .wasm artifact exists + parses as a + # valid WebAssembly module is sufficient to catch link-level + # regressions. + # + # A background diagnostic monitor logs process + memory state + # every 60 s during the build; this was added after the + # aarch64-linux miso hang (stable.12 first e2e attempt, run + # 26700668292: jsaddle-wasm completed then 47 min of silence) so + # a future hang surfaces WHICH process is stuck and HOW (sleeping + # vs spinning). + # --------------------------------------------------------------------- + - name: multi — miso-counter via multi-target wasm (strict) + if: ${{ !cancelled() && steps.multi_install.outputs.installed == 'true' }} + run: | + set -euo pipefail + mkdir -p /tmp/multi-miso && cd /tmp/multi-miso + curl -fL -o miso.tar.gz \ + https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-miso-counter.tar.gz + tar xf miso.tar.gz + cd stable-haskell-wasm-miso-counter + + # Diagnostic monitor — see comment above. Goes down with the + # step via EXIT trap; SIGTERM from GitHub on cancellation also + # propagates via default trap behaviour. + ( + while true; do + echo "::group::diag $(date +%H:%M:%S)" + echo "--- ps (wasm/ghc/cabal/node) ---" + if [ "$(uname -s)" = "Darwin" ]; then + ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ + | head -1 + ps -A -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ + | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true + echo "--- memory ---" + vm_stat | head -10 + else + ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ + | head -1 + ps -e -o pid,ppid,%cpu,%mem,rss,etime,stat,comm,command 2>/dev/null \ + | grep -E "(ghc|cabal|node|wasm32|iserv)" | grep -v grep || true + echo "--- memory ---" + free -h 2>/dev/null + fi + echo "::endgroup::" + sleep 60 + done + ) & + MONITOR_PID=$! + trap 'kill $MONITOR_PID 2>/dev/null || true' EXIT INT TERM + + # Override the template's pinned default; MULTI_VER ships the + # wasm32-unknown-wasi-ghc binary under $HOME/.ghcup/ghc/$MULTI_VER/bin/. + make WASM_VERSION="$MULTI_VER" build + wasm_file=$(find dist-newstyle -name 'myapp.wasm' -type f | head -1) + test -n "$wasm_file" || { echo "::error::myapp.wasm not produced"; exit 1; } + ls -lh "$wasm_file" + file "$wasm_file" | tee /tmp/file_out + grep -q 'WebAssembly' /tmp/file_out \ + || { echo "::error::artifact is not a wasm binary"; exit 1; } + # --------------------------------------------------------------------- # 8. JS hello — proves `javascript-unknown-ghcjs-ghc` frontend # produces Node-runnable .js. emscripten is installed here on demand # rather than as a global setup step because only this one test # needs it. + # + # NOTE: there is no JS-target miso-counter test (yet). The wasm + # miso template's Makefile is wasm-specific and the JS frontend + # would need a parallel template (or a target-agnostic + # cabal.project we don't yet ship). TH-heavy JS-target coverage + # is a follow-up once a template exists. # --------------------------------------------------------------------- - name: multi — JS hello via multi-target (strict) if: ${{ !cancelled() && steps.multi_install.outputs.installed == 'true' }} From 5db32956dda52b6a7f8f5f8c82afcf42b5edf0b5 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Mon, 8 Jun 2026 06:48:02 +0900 Subject: [PATCH 14/14] ci(e2e-multi): cabal update before the miso-counter build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/channel-e2e-multi.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/channel-e2e-multi.yml b/.github/workflows/channel-e2e-multi.yml index 0fd63fc4ed41..c4826d12598e 100644 --- a/.github/workflows/channel-e2e-multi.yml +++ b/.github/workflows/channel-e2e-multi.yml @@ -287,6 +287,16 @@ jobs: if: ${{ !cancelled() && steps.multi_install.outputs.installed == 'true' }} run: | set -euo pipefail + # Make sure the package index is populated. Without this the + # solver fails resolving jsaddle-wasm (and any other + # not-already-source-repo-pinned dep) with + # Could not resolve dependencies: + # [__1] unknown package: host:jsaddle-wasm (dependency of host:myapp) + # — which is what bit the first e2e-MULTI run of this step (see + # run 27091838526). The hello steps above don't touch Hackage + # so they don't reveal the missing index. + cabal update + mkdir -p /tmp/multi-miso && cd /tmp/multi-miso curl -fL -o miso.tar.gz \ https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-miso-counter.tar.gz