diff --git a/.github/workflows/cabal-release.yml b/.github/workflows/cabal-release.yml new file mode 100644 index 000000000000..6a1e9037c5a3 --- /dev/null +++ b/.github/workflows/cabal-release.yml @@ -0,0 +1,60 @@ +# Build stable-haskell cabal-install Linux bindists (channel gap). +# +# The wasm ghcup channel ships GHC for aarch64-darwin + x86_64-linux + +# aarch64-linux, but cabal-3.17.0.0.stable.0 only shipped for darwin — +# Linux users hit "Unable to find a download for Tool 'cabal'" during +# `ghcup install cabal 3.17.0.0.stable.0`. +# +# This workflow rebuilds cabal-install from the upstream branch +# stable-haskell/cabal:stable-haskell/feature/wasm-cross-ghcup-stack +# (a 2-commit stack of #368 rpath relativize + #361 target-prefix-aware +# tool guess) for x86_64-linux + aarch64-linux, and uploads the binaries +# as workflow artifacts. +# +# This is a thin wrapper around the reusable workflow (the same one +# nix-ci.yml calls as a DAG node). Triggers: +# * workflow_dispatch — manual, lets us preview the build before tagging +# * push of tag `cabal-3.17.0.0.stable.*` — auto-uploads bindists to +# the matching release +# * pull_request touching this workflow — sanity-check changes here +name: Build cabal Linux bindists + +on: + push: + tags: ['cabal-3.17.0.0.stable.*'] + pull_request: + paths: + - '.github/workflows/cabal-release.yml' + - '.github/workflows/reusable-cabal-release.yml' + workflow_dispatch: + inputs: + cabal_sha: + description: 'stable-haskell/cabal SHA to build' + default: '8b8433b736d45ec53a103baf4e4aabb8010ca2ed' + release_tag: + description: 'Release tag to upload to (leave blank to skip upload)' + default: '' + +permissions: + contents: write # for asset upload to releases on tag push + +concurrency: + group: cabal-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + uses: ./.github/workflows/reusable-cabal-release.yml + with: + # workflow_dispatch passes inputs.*; tag/PR triggers fall back to + # the reusable workflow's own defaults. + cabal_sha: ${{ inputs.cabal_sha || '8b8433b736d45ec53a103baf4e4aabb8010ca2ed' }} + # On a tag push, route the asset upload to that tag's release. On + # workflow_dispatch, only if the operator filled release_tag. + release_tag: >- + ${{ startsWith(github.ref, 'refs/tags/cabal-3.17.0.0.stable.') + && github.ref_name + || (github.event_name == 'workflow_dispatch' && inputs.release_tag || '') }} + permissions: + contents: write + secrets: inherit diff --git a/.github/workflows/channel-e2e-multi.yml b/.github/workflows/channel-e2e-multi.yml new file mode 100644 index 000000000000..9d3c7c72e069 --- /dev/null +++ b/.github/workflows/channel-e2e-multi.yml @@ -0,0 +1,50 @@ +# Channel end-to-end CI — multi-target bindist channel (live). +# +# Validates the SHIPPED multi-target GHC + cabal + ghcup channel YAML +# by exercising the exact flow an end-user does: ghcup install fresh, +# add the multi-target stable-haskell channel, install GHC + cabal, +# compile + run a hello-world for each of native / wasm / JS, then +# build a TH-heavy real-app (miso-counter) via the wasm frontend. +# +# This is a thin wrapper around reusable-channel-e2e.yml in install_mode +# "channel" (live channel YAML, not this run's artifacts). The same +# reusable workflow is also called from nix-ci.yml in install_mode +# "artifact" against PR-built bindists. +# +# Triggers: +# * push of `multi-*` tag — re-validate after a new release ships +# * PR that touches this workflow OR the reusable +# * workflow_dispatch +# * weekly silent-regression canary +# +# The legacy `Channel e2e (WASM)` workflow is retired; the multi-target +# bindist supersedes it. +name: Channel e2e (MULTI) + +on: + push: + tags: [multi-*] + pull_request: + paths: + - '.github/workflows/channel-e2e-multi.yml' + - '.github/workflows/reusable-channel-e2e.yml' + workflow_dispatch: + inputs: + multi_version: + description: 'Multi-target GHC version to test (e.g. multi-9.14.0.stable.1)' + default: '' + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC — silent-regression canary + +concurrency: + group: channel-e2e-multi-${{ github.ref }} + cancel-in-progress: false + +jobs: + e2e: + uses: ./.github/workflows/reusable-channel-e2e.yml + with: + install_mode: channel + multi_version: ${{ inputs.multi_version != '' && inputs.multi_version || 'multi-9.14.0.stable.1' }} + cabal_version: '3.17.0.0.stable.0' + secrets: inherit diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml new file mode 100644 index 000000000000..a4b597560501 --- /dev/null +++ b/.github/workflows/nix-ci.yml @@ -0,0 +1,915 @@ +name: Nix CI + +on: + pull_request: + types: + - opened + - synchronize + push: + branches: [stable-ghc-9.14, stable-master] + # 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: + - 'multi-*' + + workflow_dispatch: + +permissions: + contents: write # for upload of bindist tarballs to releases on tag push + +env: + CABAL_CACHE_PATH: _build/cabal/bin + +# Auto-cancel superseded runs on the same branch (except stable branches) +concurrency: + group: nix-ci-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/heads/stable-') }} + +jobs: + # --------------------------------------------------------------------------- + # Build job: compile GHC and produce the dist artifact + # --------------------------------------------------------------------------- + build: + name: "Build / ${{ matrix.plat }} / dynamic=${{ matrix.dynamic }}" + runs-on: ${{ fromJSON(matrix.runner) }} + + strategy: + fail-fast: false + matrix: + include: + - { plat: x86_64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-latest"' } + - { plat: x86_64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-latest"' } + - { plat: aarch64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-24.04-arm"' } + - { plat: aarch64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-24.04-arm"' } + - { plat: aarch64-darwin, ghc: '98', dynamic: 0, runner: '["self-hosted", "nix"]' } + - { plat: aarch64-darwin, ghc: '98', dynamic: 1, runner: '["self-hosted", "nix"]' } + + steps: + # Ephemeral Tart VMs: 80GB disk split between system (41GB APFS, ~7GB free + # after macOS) and WorkSpace (28GB, symlinked from ~/actions-runner/_work). + # The devx closure (~7GB) fills the system disk, leaving no room for cabal + # hackage index or GHC temp files. Redirect CABAL_DIR and TMPDIR to WorkSpace. + - name: Clean workspace + run: | + # Add nix paths and /usr/local/bin to GITHUB_PATH so that: + # - 'git' from nix is found before the Xcode CLT shim at /usr/bin/git + # - 'devx' symlink in /usr/local/bin is found after devx import + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/usr/local/bin" >> "$GITHUB_PATH" + + # Redirect CABAL_DIR to WorkSpace volume. The devx shell sets + # CABAL_DIR=~/.cabal-devx (on system disk), but after devx import + # the system disk has <1GB free. Override via GITHUB_ENV so all + # subsequent devx steps use WorkSpace for cabal packages/store. + 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 + # Remove old devx symlink so previous devx nix closure becomes + # unreferenced and can be garbage collected. + sudo rm -f /usr/local/bin/devx 2>/dev/null || true + # nix commands may fail if nix is not on PATH (fresh VMs) + 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@v5 + with: + submodules: "recursive" + fetch-depth: 1 + + # devx action needs: zstd (closure import), /usr/local/bin (symlink). + # These are baked into the base image but we verify defensively. + - 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.plat }} + compiler-nix-name: 'ghc${{ matrix.ghc }}' + minimal: true + ghc: true + + - name: Cache stage0 cabal binary + id: cabal-cache + uses: actions/cache@v5 + with: + path: ${{ env.CABAL_CACHE_PATH }} + key: cabal-${{ vars.CACHE_VERSION || 'v1' }}-${{ matrix.plat }}-${{ hashFiles('cabal.project.stage0') }} + + - name: Check disk space + run: | + echo "=== Disk space available for build ===" + echo "--- WorkSpace (build output) ---" + df -h . + echo "--- System disk (nix store, devx, temp files) ---" + df -h / + + - name: Update hackage + shell: devx {0} + run: | + set -eo pipefail + # Override devx's CABAL_DIR (~/.cabal-devx on system disk) to use + # WorkSpace volume. System disk has <1GB free after devx import. + export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" + mkdir -p "$CABAL_DIR" + cabal update + + - name: Start metrics collection + shell: bash + run: ./mk/collect-metrics.sh start _build/metrics 0.5 + + # Split build into stages to manage disk space. + # After stage1 completes, aggressively clean everything except + # the stage1 store (compiler + installed packages) which stage2 + # needs. Also remove .git since ghc-boot's Setup.hs accepts + # GIT_COMMIT_ID env var instead of running 'git rev-parse HEAD'. + - name: Build stage0 + stage1 + shell: devx {0} + timeout-minutes: 30 + run: | + set -eo pipefail + export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + make QUIET=1 DYNAMIC=${{ matrix.dynamic }} CABAL_ARGS="-j2" ${{ steps.cabal-cache.outputs.cache-hit == 'true' && 'USE_SYSTEM_CABAL=1' || '' }} stage1 + echo "=== Disk after stage1 ===" + df -h . + + - name: Clean intermediates to free disk for stage2 + run: | + echo "=== Before cleanup ===" + du -sh _build/stage0/ _build/stage1/ _build/packages/ .git/ 2>/dev/null || true + df -h . + + # Capture git commit ID before removing .git — ghc-boot's Setup.hs + # checks GIT_COMMIT_ID env var before falling back to 'git rev-parse'. + GIT_COMMIT_ID=$(git rev-parse HEAD) + echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" >> "$GITHUB_ENV" + echo "Captured GIT_COMMIT_ID=$GIT_COMMIT_ID" + + # Remove .git directory (200-500MB on shallow clone with submodules). + # No longer needed: ghc-boot will use GIT_COMMIT_ID env var. + rm -rf .git + find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true + + # Stage0: cabal binary is in _build/cabal/bin/ (separate from build dir). + # Remove all stage0 build artifacts — only the cabal binary matters. + rm -rf _build/stage0/ 2>/dev/null || true + + # Stage1: keep ONLY the store/ (installed compiler + libs in store/host/*/bin/ and lib/). + # Remove everything else: build artifacts, cache, logs, etc. + find _build/stage1/ -maxdepth 1 -mindepth 1 -not -name store -exec rm -rf {} + + + # Remove downloaded source tarballs (already unpacked by cabal) + rm -rf _build/packages/ 2>/dev/null || true + + # Remove build temp files (redirected TMPDIR from stage1) + rm -rf _build/tmp/ 2>/dev/null || true + + # Remove cabal hackage index (stage2 uses its own local cache via Makefile's + # hackage target, so the devx cabal-dir index is no longer needed) + rm -rf "${CABAL_DIR:-~/.cabal-devx}/packages/" 2>/dev/null || true + + echo "=== After cleanup ===" + du -sh _build/stage1/ _build/cabal/ 2>/dev/null || true + df -h . + + - name: Build stage2 + shell: devx {0} + timeout-minutes: 60 + run: | + set -eo pipefail + export CABAL_DIR="$GITHUB_WORKSPACE/_build/cabal-dir" + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + make QUIET=1 DYNAMIC=${{ matrix.dynamic }} CABAL_ARGS="-j2" USE_SYSTEM_CABAL=1 stage2 + echo "=== Disk after stage2 ===" + df -h . + + - name: Stop metrics collection + if: ${{ !cancelled() }} + shell: bash + run: ./mk/collect-metrics.sh stop _build/metrics + + - name: Display build timings + if: ${{ !cancelled() }} + shell: devx {0} + run: make timing-summary + + # After build+dist, remove all intermediate files to free disk for + # artifact upload. Only keep dist/, timing/, metrics/. + # Note: tests.tar.gz is NOT needed — test job uses its own checkout. + - name: Clean build intermediates + if: ${{ !cancelled() }} + run: | + echo "=== Before final cleanup ===" + du -sh _build/stage0/ _build/stage1/ _build/stage2/ 2>/dev/null || true + df -h . + rm -rf _build/stage0 _build/stage1 _build/stage2 _build/packages 2>/dev/null || true + echo "=== After final cleanup ===" + df -h . + + # Upload dist artifact FIRST so downstream jobs (test, cross) can start + # as soon as the build job finishes, without waiting for metrics/plots. + - name: Upload dist + uses: actions/upload-artifact@v5 + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-dist + retention-days: 1 + path: _build/dist + + - name: Upload build logs and timing + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-build-logs + retention-days: 1 + path: | + _build/logs/ + _build/timing/ + + - name: Upload metrics + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-build-metrics + retention-days: 1 + path: _build/metrics/ + + - name: Generate metrics plots + if: ${{ !cancelled() }} + continue-on-error: true + timeout-minutes: 5 + shell: bash + run: | + # Generate build SVG plot. + # Use bash (not devx) because nix is not available inside the devx shell. + PYTHON_MPL=$(nix build --impure --no-link --print-out-paths \ + --expr 'let pkgs = (builtins.getFlake "nixpkgs").legacyPackages.${builtins.currentSystem}; in pkgs.python3.withPackages (ps: [ps.matplotlib])') + "$PYTHON_MPL/bin/python3" ./mk/plot-metrics.py _build/metrics _build/timing _build/metrics/metrics + + - name: Upload metrics plots to R2 + if: ${{ !cancelled() }} + continue-on-error: true + timeout-minutes: 5 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_METRICS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_METRICS_SECRET_ACCESS_KEY }} + R2_ENDPOINT: ${{ secrets.R2_METRICS_ENDPOINT }} + R2_PUBLIC_URL: ${{ vars.R2_METRICS_PUBLIC_URL }} + R2_BUCKET: ${{ vars.R2_METRICS_BUCKET_NAME }} + shell: bash + run: | + RUN_ID="${{ github.run_id }}" + PLAT="${{ matrix.plat }}" + DYN="${{ matrix.dynamic }}" + + export AWS_ENDPOINT_URL="${R2_ENDPOINT}" + export AWS_DEFAULT_REGION=auto + + # Use bash (not devx) because nix is not available inside the devx shell. + AWSCLI=$(nix build --no-link --print-out-paths nixpkgs#awscli2) + AWS="$AWSCLI/bin/aws" + + # Upload build plot if it exists + if [[ -f "_build/metrics/metrics-build.svg" ]]; then + BUILD_PATH="runs/${RUN_ID}/${PLAT}-dynamic${DYN}-build.svg" + "$AWS" s3 cp _build/metrics/metrics-build.svg "s3://${R2_BUCKET}/${BUILD_PATH}" --content-type 'image/svg+xml' || echo "Failed to upload build plot (non-fatal)" + echo "BUILD_PLOT_URL=${R2_PUBLIC_URL}/${BUILD_PATH}" >> $GITHUB_ENV + fi + + - name: Write build metrics summary + if: ${{ !cancelled() }} + continue-on-error: true + shell: devx {0} + run: | + echo "## Build Metrics Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Embed build plot if available + if [[ -n "${BUILD_PLOT_URL:-}" ]]; then + echo "### Build Phases (CPU & Memory)" >> $GITHUB_STEP_SUMMARY + echo "\"Build" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "### Phase Timings" >> $GITHUB_STEP_SUMMARY + echo "| Phase | Duration | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-------|----------|--------|" >> $GITHUB_STEP_SUMMARY + + make timing-summary 2>/dev/null || true + if [[ -f "_build/timing/summary.txt" ]]; then + while read -r phase dur status; do + mins=$((dur / 60)) + secs=$((dur % 60)) + status_str="OK" + [[ "$status" == "1" ]] && status_str="FAIL" + if [[ "$phase" == *.* ]]; then + echo "|   $phase | ${mins}m ${secs}s | $status_str |" >> $GITHUB_STEP_SUMMARY + else + echo "| **$phase** | **${mins}m ${secs}s** | $status_str |" >> $GITHUB_STEP_SUMMARY + fi + done < "_build/timing/summary.txt" + fi + echo "" >> $GITHUB_STEP_SUMMARY + if [[ -f "_build/metrics/metrics.csv" ]]; then + max_mem=$(awk -F',' 'NR>1 {if($3>max) max=$3} END {printf "%.1f", max/1024}' _build/metrics/metrics.csv) + echo "**Peak Memory:** ${max_mem} GB" >> $GITHUB_STEP_SUMMARY + fi + + # --------------------------------------------------------------------------- + # Test job: download the dist and run the testsuite standalone + # --------------------------------------------------------------------------- + test: + name: "Test / ${{ matrix.plat }} / dynamic=${{ matrix.dynamic }}" + needs: build + runs-on: ${{ fromJSON(matrix.runner) }} + + strategy: + fail-fast: false + matrix: + include: + - { plat: x86_64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-latest"' } + - { plat: x86_64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-latest"' } + - { plat: aarch64-linux, ghc: '98', dynamic: 0, runner: '"ubuntu-24.04-arm"' } + - { plat: aarch64-linux, ghc: '98', dynamic: 1, runner: '"ubuntu-24.04-arm"' } + - { plat: aarch64-darwin, ghc: '98', dynamic: 0, runner: '["self-hosted", "nix"]' } + - { plat: aarch64-darwin, ghc: '98', dynamic: 1, runner: '["self-hosted", "nix"]' } + + 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@v5 + with: + submodules: "recursive" + fetch-depth: 1 + + - 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.plat }} + compiler-nix-name: 'ghc${{ matrix.ghc }}' + minimal: true + ghc: true + + - name: Download dist + uses: actions/download-artifact@v5 + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-dist + path: _build/dist + + - name: Restore permissions + run: | + chmod +x _build/dist/bin/* + # Recache package DB — paths change when dist is unpacked on a + # different machine, making the binary cache stale. Without this, + # every test gets "WARNING: cache is out of date" in stderr. + _build/dist/bin/ghc-pkg recache --package-db _build/dist/lib/package.conf.d + + - name: Start metrics collection + shell: bash + run: ./mk/collect-metrics.sh start _build/metrics 0.5 + + - name: Free disk for tests + run: | + rm -rf .git + find . -name .git -type d -exec rm -rf {} + 2>/dev/null || true + rm -rf ~/.cabal-devx/packages/ 2>/dev/null || true + df -h . + + - name: Build testsuite timeout tool + shell: devx {0} + run: | + set -eo pipefail + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + make -C testsuite/timeout + + - name: Record test start time + shell: bash + run: | + mkdir -p _build/timing + date +%s > _build/timing/test.start + + - name: Run testsuite + id: run-testsuite + shell: devx {0} + timeout-minutes: 120 + run: | + set -eo pipefail + export TMPDIR="$GITHUB_WORKSPACE/_build/tmp" + mkdir -p "$TMPDIR" + DIST_BIN="$GITHUB_WORKSPACE/_build/dist/bin" + # Pass the same variables the top-level 'make test' target uses. + # SKIP_PERF_TESTS=YES prevents performance tests from running + # (they timeout on CI). CANONICAL_TEST_HC_OPTS enables lint passes. + TEST_HC="$DIST_BIN/ghc" \ + GHC_PKG="$DIST_BIN/ghc-pkg" \ + HP2PS_ABS="$DIST_BIN/hp2ps" \ + HPC="$DIST_BIN/hpc" \ + RUNGHC="$DIST_BIN/runghc" \ + TEST_CC="${CC:-cc}" \ + TEST_HC_OPTS="-dcore-lint -dstg-lint -dcmm-lint -no-user-package-db -fno-dump-with-ways -fprint-error-index-links=never -rtsopts -fno-warn-missed-specialisations -fshow-warning-groups -fdiagnostics-color=never -fno-diagnostics-show-caret -Werror=compat -dno-debug-output" \ + SKIP_PERF_TESTS=YES \ + THREADS=4 \ + METRICS_FILE="$GITHUB_WORKSPACE/_build/test-perf.csv" \ + SUMMARY_FILE="$GITHUB_WORKSPACE/_build/test-summary.txt" \ + JUNIT_FILE="$GITHUB_WORKSPACE/_build/test-junit.xml" \ + make -C testsuite/tests test + + - name: Record test end time + if: ${{ !cancelled() }} + shell: bash + run: | + mkdir -p _build/timing + date +%s > _build/timing/test.end + if [[ "${{ steps.run-testsuite.outcome }}" == "success" ]]; then + echo "0" > _build/timing/test.status + else + echo "1" > _build/timing/test.status + fi + + - name: Stop metrics collection + if: ${{ !cancelled() }} + shell: bash + run: ./mk/collect-metrics.sh stop _build/metrics + + # Upload test artifacts FIRST, before slow metrics generation. + - name: Upload test results + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-testsuite-results + retention-days: 1 + path: | + _build/test-perf.csv + _build/test-summary.txt + _build/test-junit.xml + + - name: Upload test metrics + uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: ${{ matrix.plat }}-dynamic${{ matrix.dynamic }}-test-metrics + retention-days: 1 + path: _build/metrics/ + + - name: Generate metrics plots + if: ${{ !cancelled() }} + continue-on-error: true + timeout-minutes: 5 + shell: bash + run: | + # Generate test SVG plot. + PYTHON_MPL=$(nix build --impure --no-link --print-out-paths \ + --expr 'let pkgs = (builtins.getFlake "nixpkgs").legacyPackages.${builtins.currentSystem}; in pkgs.python3.withPackages (ps: [ps.matplotlib])') + "$PYTHON_MPL/bin/python3" ./mk/plot-metrics.py _build/metrics _build/timing _build/metrics/metrics + + - name: Upload metrics plots to R2 + if: ${{ !cancelled() }} + continue-on-error: true + timeout-minutes: 5 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_METRICS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_METRICS_SECRET_ACCESS_KEY }} + R2_ENDPOINT: ${{ secrets.R2_METRICS_ENDPOINT }} + R2_PUBLIC_URL: ${{ vars.R2_METRICS_PUBLIC_URL }} + R2_BUCKET: ${{ vars.R2_METRICS_BUCKET_NAME }} + shell: bash + run: | + RUN_ID="${{ github.run_id }}" + PLAT="${{ matrix.plat }}" + DYN="${{ matrix.dynamic }}" + + export AWS_ENDPOINT_URL="${R2_ENDPOINT}" + export AWS_DEFAULT_REGION=auto + + AWSCLI=$(nix build --no-link --print-out-paths nixpkgs#awscli2) + AWS="$AWSCLI/bin/aws" + + # Upload test plot if it exists + if [[ -f "_build/metrics/metrics-test.svg" ]]; then + TEST_PATH="runs/${RUN_ID}/${PLAT}-dynamic${DYN}-test.svg" + "$AWS" s3 cp _build/metrics/metrics-test.svg "s3://${R2_BUCKET}/${TEST_PATH}" --content-type 'image/svg+xml' || echo "Failed to upload test plot (non-fatal)" + echo "TEST_PLOT_URL=${R2_PUBLIC_URL}/${TEST_PATH}" >> $GITHUB_ENV + fi + + - name: Write test metrics summary + if: ${{ !cancelled() }} + continue-on-error: true + shell: devx {0} + run: | + echo "## Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Include test results from the summary file + if [[ -f "_build/test-summary.txt" ]]; then + echo '```' >> $GITHUB_STEP_SUMMARY + cat _build/test-summary.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Embed test plot if available + if [[ -n "${TEST_PLOT_URL:-}" ]]; then + echo "### Test Phase (CPU & Memory)" >> $GITHUB_STEP_SUMMARY + echo "\"Test" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [[ -f "_build/metrics/metrics.csv" ]]; then + max_mem=$(awk -F',' 'NR>1 {if($3>max) max=$3} END {printf "%.1f", max/1024}' _build/metrics/metrics.csv) + echo "**Peak Memory:** ${max_mem} GB" >> $GITHUB_STEP_SUMMARY + fi + + # --------------------------------------------------------------------------- + # 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. + # + # 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` — 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: + # emsdk's git tag — used by the `Install emscripten` step's + # `git clone --branch ${{ env.EMSDK_VERSION }}`. + 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@v5 + 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@v5 + with: + # 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) + # 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 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. + - 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-* + #. + 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 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 \ + GENPRIMOPCODE_BIN=$PWD/_build/dist/bin/genprimopcode \ + HAPPY_TEMPLATE_DIR=$PWD/_build/dist/share/happy-lib/data \ + _build/dist/ghc-multi-target.tar.gz + # 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 + 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. + 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@v5 + 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 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-') }} + 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 + + # --------------------------------------------------------------------------- + # Cabal Linux bindist — runs in parallel with the GHC build. The fresh + # cabal artifact is then consumed by `e2e-multi` so a PR-introduced cabal + # regression is caught BEFORE we ship. + # --------------------------------------------------------------------------- + cabal-release: + uses: ./.github/workflows/reusable-cabal-release.yml + permissions: + contents: write + secrets: inherit + + # --------------------------------------------------------------------------- + # Channel end-to-end against THIS run's artifacts (artifact install mode): + # downloads ghc-multi-target-${plat}.tar.gz from cross-multi + cabal-${ver}- + # ${plat}.tar.gz from cabal-release, installs them locally and runs the + # same hello / miso / JS-hello test surface as the live-channel + # canary (channel-e2e-multi.yml). Closes the DAG: a PR-introduced + # regression in GHC packaging, cabal patches, or wasm sysroot wiring + # surfaces here BEFORE merge instead of at release time. + # --------------------------------------------------------------------------- + e2e-multi: + needs: [cross-multi, cabal-release] + if: ${{ !cancelled() + && needs.cross-multi.result == 'success' + && needs.cabal-release.result == 'success' }} + uses: ./.github/workflows/reusable-channel-e2e.yml + with: + install_mode: artifact + # PATH suffix only; in artifact mode no `ghcup install` is done. + multi_version: 'multi-pr-ci' + cabal_version: '3.17.0.0.stable.0' + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6bf7718610f..c96a7fdfa7a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,10 +28,10 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: artifacts-* merge-multiple: true diff --git a/.github/workflows/reusable-cabal-release.yml b/.github/workflows/reusable-cabal-release.yml new file mode 100644 index 000000000000..5944ef9dbc99 --- /dev/null +++ b/.github/workflows/reusable-cabal-release.yml @@ -0,0 +1,177 @@ +# Reusable: build stable-haskell cabal-install Linux bindists. +# +# Called from: +# * cabal-release.yml — standalone trigger surface (tag push, PR-on-self, +# workflow_dispatch). Drives release-asset upload on a `cabal-*` tag. +# * nix-ci.yml — DAG node, runs in parallel with the GHC build. The +# fresh cabal bindist is then consumed by the channel-e2e job so a +# PR-introduced cabal regression is caught before merge. +# +# The build itself is a small thing — Ubuntu apt deps + ghcup bootstrap +# GHC + checkout stable-haskell/cabal at the pinned SHA + `cabal build` +# + tarball — but doing it on both x86_64-linux and aarch64-linux runners +# (with the same SHA used by cabal.project.stage{0,1,2,3}) keeps the +# channel's cabal bytes in lock-step with the GHC bytes shipped from +# the same PR. + +name: reusable-cabal-release + +on: + workflow_call: + inputs: + cabal_sha: + description: 'stable-haskell/cabal SHA to build' + type: string + default: '8b8433b736d45ec53a103baf4e4aabb8010ca2ed' + cabal_ver: + description: 'cabal-install version baked into artifact name' + type: string + default: '3.17.0.0.stable.0' + bootstrap_ghc: + description: 'GHC used to compile cabal-install' + type: string + default: '9.8.4' + release_tag: + description: 'GitHub Release tag to upload to (empty = no release upload)' + type: string + default: '' + outputs: + cabal_ver: + description: 'cabal-install version that was built' + value: ${{ inputs.cabal_ver }} + +permissions: + contents: write # for asset upload to releases on tag push + +jobs: + build: + name: build / ${{ matrix.plat }} + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - { plat: x86_64-linux, runs-on: ubuntu-latest } + - { plat: aarch64-linux, runs-on: ubuntu-24.04-arm } + + env: + CABAL_SHA: ${{ inputs.cabal_sha }} + BOOTSTRAP_GHC: ${{ inputs.bootstrap_ghc }} + CABAL_VER: ${{ inputs.cabal_ver }} + + steps: + # --------------------------------------------------------------------- + # 1. Workspace + apt deps (Ubuntu base — recent glibc, broad-enough + # compat for modern Debian/Ubuntu/RHEL9+/Fedora). + # --------------------------------------------------------------------- + - name: System dependencies + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential git curl xz-utils libgmp10 libgmp-dev zlib1g-dev \ + libffi-dev libncurses-dev pkg-config + + # --------------------------------------------------------------------- + # 2. Bootstrap GHC + a recent cabal-install via ghcup. + # --------------------------------------------------------------------- + - name: Install ghcup + bootstrap GHC + run: | + set -euo pipefail + sudo rm -rf "$HOME/.ghcup" + 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 + export PATH="$HOME/.ghcup/bin:$PATH" + ghcup install ghc "$BOOTSTRAP_GHC" + ghcup set ghc "$BOOTSTRAP_GHC" + ghcup install cabal 3.14.2.0 # any recent upstream cabal is fine + ghcup set cabal 3.14.2.0 + echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" + echo "$HOME/.ghcup/ghc/$BOOTSTRAP_GHC/bin" >> "$GITHUB_PATH" + ghc --version + cabal --version + + # --------------------------------------------------------------------- + # 3. Checkout stable-haskell/cabal at the recorded SHA — both the + # rpath relativize and target-prefix-aware tool guess patches + # are in the SHA (stable-haskell/feature/wasm-cross-ghcup-stack + # branch), so no patch application is required here. + # --------------------------------------------------------------------- + - name: Checkout stable-haskell/cabal @ CABAL_SHA + uses: actions/checkout@v5 + with: + repository: stable-haskell/cabal + ref: ${{ env.CABAL_SHA }} + path: cabal-src + # We need full history because the SHA might not be on the + # default branch tip; ref above pins a commit not a branch. + fetch-depth: 0 + + # --------------------------------------------------------------------- + # 4. Build cabal-install using stable-haskell/cabal's release + # project (split-sections for size; dynamic-link gmp because + # static-link requires PIC gmp which Ubuntu's libgmp-dev isn't + # always built with). + # --------------------------------------------------------------------- + - name: cabal build + working-directory: cabal-src + run: | + set -euo pipefail + cabal update + PROJ=cabal.project + if [ -f cabal.release.project ]; then PROJ=cabal.release.project; fi + echo "Using project: $PROJ" + cabal build cabal-install:exe:cabal \ + --project-file "$PROJ" \ + --enable-split-sections \ + -O2 + + # --------------------------------------------------------------------- + # 5. Stage the binary; libgmp stays dynamically linked (every modern + # Linux ships it). + # --------------------------------------------------------------------- + - name: Stage + package + id: stage + working-directory: cabal-src + run: | + set -euo pipefail + stage="$HOME/cabal-stage" + mkdir -p "$stage" + bin=$(find dist-newstyle -type f -name cabal -executable | head -1) + test -n "$bin" || { echo "::error::built cabal not found"; exit 1; } + cp "$bin" "$stage/cabal" + strip "$stage/cabal" + ls -lh "$stage/cabal" + file "$stage/cabal" + "$stage/cabal" --version + ldd "$stage/cabal" | sed 's/^/ /' + tarball="cabal-${CABAL_VER}-${{ matrix.plat }}.tar.gz" + tar -C "$stage" -czf "$GITHUB_WORKSPACE/$tarball" cabal + echo + echo "═══ artifact: $tarball ═══" + ls -lh "$GITHUB_WORKSPACE/$tarball" + shasum -a 256 "$GITHUB_WORKSPACE/$tarball" + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------- + # 6. Upload as workflow artifact (always) — release-asset upload + # only triggered if the caller passed a release_tag input. + # --------------------------------------------------------------------- + - name: Upload artifact + uses: actions/upload-artifact@v5 + with: + name: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }} + path: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }}.tar.gz + retention-days: 30 + + - name: Upload to release (if release_tag provided) + if: ${{ inputs.release_tag != '' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.release_tag }} + files: cabal-${{ env.CABAL_VER }}-${{ matrix.plat }}.tar.gz + fail_on_unmatched_files: true diff --git a/.github/workflows/reusable-channel-e2e.yml b/.github/workflows/reusable-channel-e2e.yml new file mode 100644 index 000000000000..4ba24d609ba2 --- /dev/null +++ b/.github/workflows/reusable-channel-e2e.yml @@ -0,0 +1,440 @@ +# Reusable: channel end-to-end CI for the multi-target bindist. +# +# Two install modes, selected by `install_mode`: +# +# * channel — install GHC + cabal via the live stable-haskell ghcup +# channels (post-release smoke test). Triggered from +# channel-e2e-multi.yml on tag push / schedule. +# +# * artifact — download the multi-target GHC tarball + cabal bindist +# from the SAME workflow run (uploaded by Cross: MULTI +# and reusable-cabal-release as part of nix-ci) and +# install locally. Triggered from nix-ci.yml; catches +# regressions introduced by the PR BEFORE we ship. +# +# Common test surface for both modes: +# 1. native hello — exercises argv[0]=ghc path +# 2. wasm hello — exercises wasm32-unknown-wasi-ghc path +# 3. miso-counter — TH-heavy real-app build via wasm frontend (50+ +# deps incl. aeson / jsaddle / miso) +# 4. JS hello — exercises javascript-unknown-ghcjs-ghc path + +name: reusable-channel-e2e + +on: + workflow_call: + inputs: + install_mode: + description: '"channel" (live ghcup channel) or "artifact" (this run''s artifacts)' + type: string + default: 'channel' + multi_version: + description: 'Multi-target GHC version label (artifact-mode: used only for PATH suffixing)' + type: string + default: 'multi-9.14.0.stable.1' + cabal_version: + description: 'cabal-install version label' + type: string + default: '3.17.0.0.stable.0' + platforms_json: + description: 'JSON array of {plat,runner} for the matrix' + type: string + default: | + [ + { "plat": "x86_64-linux", "runner": "ubuntu-latest" }, + { "plat": "aarch64-linux", "runner": "ubuntu-24.04-arm" }, + { "plat": "aarch64-darwin", "runner": "macos-15" } + ] + +jobs: + install-and-multi: + name: e2e MULTI / ${{ matrix.plat }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.platforms_json) }} + + env: + MULTI_VER: ${{ inputs.multi_version }} + CABAL_VER: ${{ inputs.cabal_version }} + INSTALL_MODE: ${{ inputs.install_mode }} + + steps: + # --------------------------------------------------------------------- + # 1. Fresh ghcup install (needed for cabal install via channel; in + # artifact mode the GHC + cabal come from artifacts but ghcup is + # still convenient for shell PATH bootstrap). + # --------------------------------------------------------------------- + - name: Install ghcup fresh + run: | + set -euo pipefail + 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" + echo "GHCUP_INSTALL_BASE_PREFIX=$HOME" >> "$GITHUB_ENV" + + # --------------------------------------------------------------------- + # 2. Node 22 (needed by wasm + JS target TH eval). + # --------------------------------------------------------------------- + - 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 + # Force /usr/bin first so NodeSource Node 22 wins over toolcache Node 20 + # (ubuntu-24.04-arm pre-bakes Node 20 in /opt/hostedtoolcache). + echo "/usr/bin" >> "$GITHUB_PATH" + export PATH="/usr/bin:$PATH" + ver=$(node --version | tr -d v | cut -d. -f1) + test "$ver" -ge 22 || { echo "::error::expected Node 22+, got $(node --version)"; exit 1; } + + - 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 + + # --------------------------------------------------------------------- + # 3. wasi-sdk — needed for the wasm hello / miso tests to find + # wasm32-unknown-wasi-clang at link time. + # --------------------------------------------------------------------- + - 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"; exit 1; } + for tool in clang clang++; do + ln -sf "$WASI_BIN/wasm32-wasi-$tool" "$WASI_BIN/wasm32-unknown-wasi-$tool" + done + 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" + # 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. Re-prepend /usr/bin so + # Apple's clang wins for native compiles. + if [ "$(uname -s)" = "Darwin" ]; then + echo "/usr/bin" >> "$GITHUB_PATH" + fi + + # --------------------------------------------------------------------- + # 4a (channel mode). Add the wasm channel + install cabal via ghcup. + # --------------------------------------------------------------------- + - name: Add stable-haskell wasm channel (for cabal) — channel mode + if: ${{ inputs.install_mode == 'channel' }} + run: | + set -euo pipefail + ghcup config add-release-channel \ + https://stable-haskell.github.io/ghc/ghcup-wasm.yaml + + - name: Install cabal via ghcup — channel mode + if: ${{ inputs.install_mode == 'channel' }} + run: | + set -euo pipefail + ghcup install cabal "$CABAL_VER" + ghcup set cabal "$CABAL_VER" + cabal --version + + # --------------------------------------------------------------------- + # 4b (artifact mode). Two install paths for cabal: + # * Linux — download cabal artifact built by reusable-cabal-release + # in this run, extract to ~/.local/bin + # * Darwin — no cabal artifact (we build Linux only); fall back to + # the ghcup wasm channel + # + # Download step uses continue-on-error so a failed cabal-release + # for one platform doesn't cascade into an e2e job failure — we + # detect the missing artifact via steps.dl_cabal.outcome and fall + # back to the channel install path instead. + # --------------------------------------------------------------------- + - name: Download cabal bindist artifact — artifact mode (Linux) + id: dl_cabal + if: ${{ inputs.install_mode == 'artifact' && runner.os == 'Linux' }} + continue-on-error: true + uses: actions/download-artifact@v5 + with: + name: cabal-${{ inputs.cabal_version }}-${{ matrix.plat }} + path: ${{ runner.temp }}/cabal-artifact + + - name: Install cabal from artifact — artifact mode (Linux, present) + if: >- + ${{ inputs.install_mode == 'artifact' + && runner.os == 'Linux' + && steps.dl_cabal.outcome == 'success' }} + run: | + set -euo pipefail + mkdir -p "$HOME/.local/bin" + tar -xzf "${{ runner.temp }}/cabal-artifact/cabal-${CABAL_VER}-${{ matrix.plat }}.tar.gz" \ + -C "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/cabal" --version + + - name: Install cabal via wasm channel — artifact-mode fallback (Linux, missing) + if: >- + ${{ inputs.install_mode == 'artifact' + && runner.os == 'Linux' + && steps.dl_cabal.outcome != 'success' }} + run: | + set -euo pipefail + echo "::warning::cabal-${CABAL_VER}-${{ matrix.plat }} artifact missing (cabal-release failed?) — falling back to wasm channel install" + ghcup config add-release-channel \ + https://stable-haskell.github.io/ghc/ghcup-wasm.yaml + ghcup install cabal "$CABAL_VER" + ghcup set cabal "$CABAL_VER" + cabal --version + + - name: Install cabal via wasm channel — artifact mode (Darwin) + if: ${{ inputs.install_mode == 'artifact' && runner.os == 'macOS' }} + run: | + set -euo pipefail + ghcup config add-release-channel \ + https://stable-haskell.github.io/ghc/ghcup-wasm.yaml + ghcup install cabal "$CABAL_VER" + ghcup set cabal "$CABAL_VER" + cabal --version + + # --------------------------------------------------------------------- + # 5a (channel mode). Install the multi-target GHC from the live + # channel. If the channel YAML or the asset is unreachable, + # record installed=false and skip the strict tests below. + # --------------------------------------------------------------------- + - name: Install multi-target GHC — channel mode + id: install_channel + if: ${{ inputs.install_mode == 'channel' }} + run: | + set -euo pipefail + if ! ghcup config add-release-channel \ + https://stable-haskell.github.io/ghc/ghcup-multi-target-0.1.0.yaml 2>&1 | tee /tmp/multi-chan.log; then + echo "::warning::could not add multi-target channel — skipping multi-target tests" + echo "installed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! ghcup install ghc "$MULTI_VER" 2>&1 | tee /tmp/multi-install.log; then + echo "::warning::$MULTI_VER not installable from channel — skipping multi-target tests" + echo "installed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + ghcup set ghc "$MULTI_VER" + echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" + echo "installed=true" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------- + # 5b (artifact mode). Download the multi-target tarball produced by + # Cross: MULTI in the same workflow run, extract it, and run + # the bundled ./configure + make install — the exact flow ghcup + # uses end-user-side. Then prepend its bin/ to PATH. + # + # Cross: MULTI is matrix'd with `continue-on-error: true` in + # nix-ci.yml so a single platform failure (e.g. transient github.com + # 504 during curl) doesn't block the other platforms. That means + # `needs.cross-multi.result` at the caller level resolves "success" + # even when one matrix entry didn't upload an artifact — so the + # gate has to happen here, per platform. Download is + # continue-on-error; install_artifact only runs if the download + # succeeded; downstream test steps gate on install_artifact's + # `installed=true` output, so a missing artifact cleanly skips + # the platform with a warning instead of cascading into failure. + # --------------------------------------------------------------------- + - name: Download multi-target GHC artifact — artifact mode + id: dl_multi + if: ${{ inputs.install_mode == 'artifact' }} + continue-on-error: true + uses: actions/download-artifact@v5 + with: + name: ${{ matrix.plat }}-cross-multi + path: ${{ runner.temp }}/multi-artifact + + - name: Skip notice — multi-target artifact missing + if: >- + ${{ inputs.install_mode == 'artifact' + && steps.dl_multi.outcome != 'success' }} + run: | + echo "::warning::${{ matrix.plat }}-cross-multi artifact missing (cross-multi probably failed on this platform) — skipping multi-target tests for ${{ matrix.plat }}" + + - name: Install multi-target GHC from artifact + id: install_artifact + if: >- + ${{ inputs.install_mode == 'artifact' + && steps.dl_multi.outcome == 'success' }} + run: | + set -euo pipefail + PREFIX="$HOME/multi-target-install" + WORK="${{ runner.temp }}/multi-extract" + mkdir -p "$WORK" "$PREFIX" + tar -xzf "${{ runner.temp }}/multi-artifact/ghc-multi-target-${{ matrix.plat }}.tar.gz" \ + -C "$WORK" + cd "$WORK" + # The tarball extracts files into the current dir (bin/, lib/, + # configure, Makefile, relocate.sh). + ls -la | head -n 10 + test -x ./configure || { echo "::error::no configure in tarball"; exit 1; } + ./configure --prefix="$PREFIX" + make install + echo "$PREFIX/bin" >> "$GITHUB_PATH" + export PATH="$PREFIX/bin:$PATH" + # Verify all three frontends resolve. + "$PREFIX/bin/ghc" --numeric-version + "$PREFIX/bin/wasm32-unknown-wasi-ghc" --numeric-version + "$PREFIX/bin/javascript-unknown-ghcjs-ghc" --numeric-version + echo "installed=true" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------- + # 5c (both modes). Smoke-test argv[0] dispatch: each frontend MUST + # report its own Target platform. + # --------------------------------------------------------------------- + - name: Verify argv[0] dispatch reports correct Target platform + if: >- + ${{ (inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true') + || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true') }} + run: | + set -euo pipefail + ghc --info \ + | grep -F -e '("Target platform","x86_64-' \ + -e '("Target platform","aarch64-' \ + >/dev/null \ + || { echo "::error::native ghc reports unexpected Target platform"; ghc --info | grep "Target platform"; exit 1; } + wasm32-unknown-wasi-ghc --info | grep -qF '("Target platform","wasm32-unknown-wasi")' \ + || { echo "::error::wasm frontend reports wrong Target platform"; wasm32-unknown-wasi-ghc --info | grep "Target platform"; exit 1; } + javascript-unknown-ghcjs-ghc --info | grep -qF '("Target platform","javascript-unknown-ghcjs")' \ + || { echo "::error::JS frontend reports wrong Target platform"; javascript-unknown-ghcjs-ghc --info | grep "Target platform"; exit 1; } + + # --------------------------------------------------------------------- + # 6. Native hello — proves `ghc` frontend compiles and runs native. + # --------------------------------------------------------------------- + - name: multi — native hello (strict) + if: >- + ${{ !cancelled() && + ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true') + || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }} + run: | + set -euo pipefail + mkdir -p /tmp/native-hello && cd /tmp/native-hello + echo 'main = putStrLn "Hello from native ghc"' > hello.hs + ghc hello.hs -o hello + ./hello | tee /tmp/native-out + grep -q 'Hello from native ghc' /tmp/native-out + + # --------------------------------------------------------------------- + # 7. Wasm hello — proves wasm32-unknown-wasi-ghc frontend produces + # a Node:wasi-runnable .wasm via the published hello template. + # --------------------------------------------------------------------- + - name: multi — wasm hello via multi-target (strict) + if: >- + ${{ !cancelled() && + ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true') + || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }} + run: | + set -euo pipefail + mkdir -p /tmp/multi-hello && cd /tmp/multi-hello + curl -fL -o multi-hello.tar.gz \ + https://stable-haskell.github.io/ghc/examples/stable-haskell-wasm-hello.tar.gz + tar xf multi-hello.tar.gz + cd stable-haskell-wasm-hello + make WASM_VERSION="$MULTI_VER" build + 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. Load-bearing end-to-end test for TH at + # scale, dyld, JSFFI, cabal-dual-compiler. + # + # Background diagnostic monitor logs ps + memory every 60s so a + # future hang surfaces WHICH process is stuck and HOW. + # --------------------------------------------------------------------- + - name: multi — miso-counter via multi-target wasm (strict) + if: >- + ${{ !cancelled() && + ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true') + || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }} + run: | + set -euo pipefail + 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 + tar xf miso.tar.gz + cd stable-haskell-wasm-miso-counter + + ( + 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 + + 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 installed on-demand + # here since only this step needs it. + # --------------------------------------------------------------------- + - name: multi — JS hello via multi-target (strict) + if: >- + ${{ !cancelled() && + ((inputs.install_mode == 'channel' && steps.install_channel.outputs.installed == 'true') + || (inputs.install_mode == 'artifact' && steps.install_artifact.outputs.installed == 'true')) }} + run: | + set -euo pipefail + if ! command -v emcc >/dev/null 2>&1; then + git clone --depth 1 --branch 3.1.74 https://github.com/emscripten-core/emsdk.git /tmp/emsdk + cd /tmp/emsdk && ./emsdk install 3.1.74 && ./emsdk activate 3.1.74 + source ./emsdk_env.sh + fi + mkdir -p /tmp/js-hello && cd /tmp/js-hello + echo 'main = putStrLn "Hello from JS backend"' > hello.hs + javascript-unknown-ghcjs-ghc hello.hs -o hello + node hello.jsexe/all.js | tee /tmp/js-out + grep -q 'Hello from JS backend' /tmp/js-out diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index fe56af77f3f3..38e1e157874c 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -117,7 +117,7 @@ jobs: - name: Install GHC run: ghcup --no-verbose install ghc --set --install-targets "${{ env.GHC_TARGETS }}" "${{ env.GHC_VERSION }}" - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -222,7 +222,7 @@ jobs: } ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -304,7 +304,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -359,7 +359,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -415,7 +415,7 @@ jobs: run: shell: 'C:/msys64/usr/bin/bash.exe --login -e {0}' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -469,7 +469,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -651,7 +651,7 @@ jobs: - name: git clone fix run: git config --system --add safe.directory $GITHUB_WORKSPACE - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} @@ -703,7 +703,7 @@ jobs: } ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} @@ -736,7 +736,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} @@ -763,7 +763,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -793,7 +793,7 @@ jobs: run: shell: 'C:/msys64/usr/bin/bash.exe --login -e {0}' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} submodules: recursive @@ -825,7 +825,7 @@ jobs: matrix: branch: ${{ fromJSON(inputs.branches) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} diff --git a/Makefile b/Makefile index 3c398ed9f539..77fd787852c4 100644 --- a/Makefile +++ b/Makefile @@ -119,11 +119,6 @@ SED ?= sed LN ?= ln LN_S ?= $(LN) -s LN_SF ?= $(LN) -sf -ifeq ($(UNAME), FreeBSD) -TAR ?= gtar -else -TAR ?= tar -endif ifeq ($(UNAME), Darwin) DLL := *.dylib @@ -151,6 +146,19 @@ GHC_CONFIGURE_ARGS = GHC_TOOLCHAIN_ARGS = --disable-ld-override +# Cross-compilation tool paths: default to stage1 build tree; can be +# overridden for dist-based cross builds (CI) where only the dist +# artifact is available. +GHC_TOOLCHAIN_BIN ?= $(STAGE1_PATH)/bin/ghc-toolchain-bin +DERIVE_CONSTANTS_BIN ?= $(STAGE1_PATH)/bin/deriveConstants +GENAPPLY_BIN ?= $(STAGE1_PATH)/bin/genapply +# genprimopcode is invoked by compiler/Setup.hs by bare name (`readProcess +# "genprimopcode"`), i.e. resolved via PATH rather than an env var. Its +# directory is prepended to PATH for the stage3 cabal build (see below) so the +# freshly-built one wins over the bootstrap GHC's, which predates the primop +# `effect`/`NoEffect` grammar and dies at primops.txt:139:31. +GENPRIMOPCODE_BIN ?= $(STAGE1_PATH)/bin/genprimopcode + # # Build directories and paths # @@ -179,14 +187,10 @@ STAGE0_STAMP := $(BUILD_DIR)/.stamp-stage0 STAGE1_STAMP := $(BUILD_DIR)/.stamp-stage1 STAGE2_STAMP := $(BUILD_DIR)/.stamp-stage2 -# Stamp fallback rules: if a stamp doesn't exist, invoke the corresponding -# stage via recursive make. The stage recipe touches the stamp on success. -# Because there are no prerequisites, Make won't re-run these when the stamp -# file already exists — which is the whole point: `test: $(STAGE2_STAMP)` will -# skip the build if stage2 already completed. -$(STAGE0_STAMP): ; @$(MAKE) stable-cabal -$(STAGE1_STAMP): ; @$(MAKE) stage1 -$(STAGE2_STAMP): ; @$(MAKE) stage2 +# Each stamp is the real Make target for its stage's recipe; PHONY aliases +# (stage1, stage2) are provided for convenience. This lets Make resolve the +# full dependency graph in a single invocation (e.g. `make _build/dist/ghc.tar.gz`) +# while still skipping already-completed stages. # HOST_PLATFROM is always from the bootstrap compiler HOST_PLATFORM := $(shell $(GHC0) --print-host-platform) @@ -478,7 +482,12 @@ define DIST_COPY_LIBS_SO endef define DIST_COPY_LIBS_SO_CROSS - @find $(CURDIR)/$(STORE_DIR)/host/$(TARGET_PLATFORM)/lib/ -mindepth 1 -type f -name "$(DLL)" -execdir cp '{}' $(CURDIR)/$(DIST_DIR)/lib/targets/$(TARGET_PLATFORM)/lib/$(TARGET_PLATFORM)/'{}' \; + @# $(DLL) is set based on BUILD host (.dylib on Darwin, .so on Linux), but + @# cross targets have their own shared-library extension. wasm32-* and + @# Linux-style targets produce .so; Mac/Darwin targets would produce .dylib. + @# Search for both extensions so cross-stage SO/DYLIB files land in dist + @# regardless of build-host vs target-platform mismatch. + @find $(CURDIR)/$(STORE_DIR)/host/$(TARGET_PLATFORM)/lib/ -mindepth 1 -type f \( -name "*.so" -o -name "*.dylib" \) -execdir cp '{}' $(CURDIR)/$(DIST_DIR)/lib/targets/$(TARGET_PLATFORM)/lib/$(TARGET_PLATFORM)/'{}' \; endef @@ -592,8 +601,12 @@ STAGE1_CABAL_BUILD = \ --with-build-compiler=$(GHC0) \ --ghc-options "-ghcversion-file=$(call NORMALIZE_FP,$(CURDIR)/rts/include/ghcversion.h)" -stage1: STAGE=stage1 -stage1: stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage1 cabal.project.common libraries/ghc-boot-th-next | hackage +ifndef DIST_BUILD +# The stamp file is the real target so that Make can resolve it as a +# prerequisite (e.g. stage2 -> $(GHC1) -> $(STAGE1_STAMP)). The PHONY +# alias below lets users run `make stage1` as before. +$(STAGE1_STAMP): STAGE=stage1 +$(STAGE1_STAMP): $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage1 cabal.project.common | stable-cabal libraries/ghc-boot-th-next hackage $(call PHASE_START,stage1) $(call LOG,Starting build of $(STAGE)) @@ -607,6 +620,12 @@ stage1: stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stag 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 @@ -614,9 +633,13 @@ endif $(call LOG,Finished building $(STAGE)) $(call PHASE_END_OK,stage1) - @touch $(STAGE1_STAMP) + @touch $@ + +.PHONY: stage1 +stage1: $(STAGE1_STAMP) -$(addprefix $(STAGE1_PATH)/bin/,$(STAGE1_EXECUTABLES)) : stage1 +$(addprefix $(STAGE1_PATH)/bin/,$(STAGE1_EXECUTABLES)) : $(STAGE1_STAMP) +endif # DIST_BUILD (stage1) # ____ _ ____ # / ___|| |_ __ _ __ _ ___ |___ \ @@ -685,8 +708,6 @@ STAGE2_LIBRARIES = \ stm \ system-cxx-std-lib \ template-haskell \ - template-haskell-lift \ - template-haskell-quasiquoter \ text \ time \ transformers \ @@ -714,9 +735,10 @@ STAGE2_CABAL_BUILD = \ $(foreach dir,$(STAGE2_EXTRA_LIB_DIRS),--extra-lib-dirs=$(dir)) \ $(foreach dir,$(STAGE2_EXTRA_INCLUDE_DIRS),--extra-include-dirs=$(dir)) -stage2: STAGE=stage2 -stage2: TARGET_PLATFORM:=$(HOST_PLATFORM) -stage2: $(GHC1) stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage2.common cabal.project.stage2.static cabal.project.stage2.dynamic cabal.project.common libraries/ghc-boot-th-next | stage1 +ifndef DIST_BUILD +$(STAGE2_STAMP): STAGE=stage2 +$(STAGE2_STAMP): TARGET_PLATFORM:=$(HOST_PLATFORM) +$(STAGE2_STAMP): $(GHC1) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.project.stage2.common cabal.project.stage2.static cabal.project.stage2.dynamic cabal.project.common | stable-cabal libraries/ghc-boot-th-next $(STAGE1_STAMP) $(call PHASE_START,stage2) $(call LOG,Starting build of $(STAGE)) @@ -755,21 +777,25 @@ stage2: $(GHC1) stable-cabal $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) cabal.proj $(call LOG,Creating $(DIST_DIR)/lib/template-hsc.h) @cp $(STAGE2_PATH)/lib/hsc2hs-*-hsc2hs/share/template-hsc.h $(DIST_DIR)/lib/template-hsc.h -ifeq ($(DYNAMIC),1) - # set rpath. Only needed for dynamic builds: statically-linked executables - # link only system libraries (libc/libm) and load nothing from ../lib, so - # they need no rpath. (patchelf also cannot rewrite the large static ghc, - # haddock and ghc-iserv binaries: it fails with "virtual address space - # underrun".) + # set rpath @for binary in $(DIST_DIR)/bin/* ; do \ $(call SET_RPATH,../lib/$(HOST_PLATFORM),$${binary}) ; \ done +ifeq ($(DYNAMIC),1) ifneq ($(UNAME), Darwin) - $(PATCHELF) --force-rpath --set-rpath "\$$ORIGIN" $(CURDIR)/$(DIST_DIR)/lib/$(TARGET_PLATFORM)/$(DLL) + @for so in $(CURDIR)/$(DIST_DIR)/lib/$(TARGET_PLATFORM)/$(DLL) ; do \ + [ -f "$$so" ] && $(PATCHELF) --force-rpath --set-rpath "\$$ORIGIN" "$$so" ; \ + done ; true endif $(call LOG,Create -dyn iserv executable symlink so ghc can find ghc-iserv-dyn) @$(LN_SF) ghc-iserv$(EXE_EXT) "$(DIST_DIR)/bin/ghc-iserv-dyn$(EXE_EXT)" endif + # On Darwin, strip the build-host /Volumes/WorkSpace LC_RPATH leak + # and rewrite nix-store LC_LOAD_DYLIB install names to /usr/lib + # BEFORE the ghc-pkg recache below — otherwise recache itself can + # abort-trap on macOS 15 dyld (which rejects unresolvable absolute + # rpaths as fatal). The script is a no-op on non-Darwin hosts. + @./mk/clean-darwin-macho.sh $(DIST_DIR) $(call LOG,Refreshing $(DIST_DIR)/lib/package.conf.d cache) @$(DIST_DIR)/bin/ghc-pkg recache --package-db $(CURDIR)/$(DIST_DIR)/lib/package.conf.d @@ -780,12 +806,36 @@ endif @cp -rfp driver/ghc-usage.txt $(DIST_DIR)/lib/ @cp -rfp driver/ghci-usage.txt $(DIST_DIR)/lib/ + # Copy stage1 tools needed for cross-compilation from dist + $(call LOG,Copying cross-compilation tools to $(DIST_DIR)/bin) + @cp -fp $(STAGE1_PATH)/bin/ghc-toolchain-bin $(DIST_DIR)/bin/ghc-toolchain-bin + @cp -fp $(STAGE1_PATH)/bin/deriveConstants $(DIST_DIR)/bin/deriveConstants + @cp -fp $(STAGE1_PATH)/bin/genapply $(DIST_DIR)/bin/genapply + # genprimopcode: compiler/Setup.hs resolves it from PATH; the stage3 cabal + # build prepends $(dir $(GENPRIMOPCODE_BIN)) so this dist copy wins over the + # bootstrap GHC's (which can't parse the primop effect grammar, dying at + # primops.txt:139:31). Required for DIST_BUILD (CI) cross builds. + @cp -fp $(STAGE1_PATH)/bin/genprimopcode $(DIST_DIR)/bin/genprimopcode + + # Copy stage0 cabal (needed for cross-compilation from dist, has -W flag) + $(call LOG,Copying cabal to $(DIST_DIR)/bin) + @cp -fp $(CABAL) $(DIST_DIR)/bin/cabal + + # Copy happy-lib templates (needed for cross-compilation from dist) + $(call LOG,Copying happy-lib templates to $(DIST_DIR)/share) + @mkdir -p $(DIST_DIR)/share/happy-lib/data + @cp -rfp _build/stage2/src/happy-lib-*/data/* $(DIST_DIR)/share/happy-lib/data/ + $(call LOG,Finished building $(STAGE) in $(DIST_DIR)) $(call PHASE_END_OK,stage2.dist) $(call PHASE_END_OK,stage2) - @touch $(STAGE2_STAMP) + @touch $@ + +.PHONY: stage2 +stage2: $(STAGE2_STAMP) -$(addprefix $(STAGE2_PATH)/bin/,$(STAGE2_EXECUTABLES)) : stage2 +$(addprefix $(STAGE2_PATH)/bin/,$(STAGE2_EXECUTABLES)) : $(STAGE2_STAMP) +endif # DIST_BUILD (stage2) # ____ _ _____ # / ___|| |_ __ _ __ _ ___ |___ / @@ -873,6 +923,9 @@ STAGE3_javascript-unknown-ghcjs_CC = emcc STAGE3_javascript-unknown-ghcjs_CC_OPTS = STAGE3_javascript-unknown-ghcjs_CXX = em++ STAGE3_javascript-unknown-ghcjs_CXX_OPTS = +STAGE3_javascript-unknown-ghcjs_CPP = emcc +STAGE3_javascript-unknown-ghcjs_HS_CPP = emcc +STAGE3_javascript-unknown-ghcjs_CMM_CPP = emcc STAGE3_javascript-unknown-ghcjs_EXTRA_INCLUDE_DIRS = STAGE3_javascript-unknown-ghcjs_EXTRA_LIB_DIRS = STAGE3_javascript-unknown-ghcjs_LD = emcc @@ -880,20 +933,44 @@ 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 - -STAGE3_wasm32-unknown-wasi_CC = wasm32-wasi-clang +# 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 -STAGE3_wasm32-unknown-wasi_CXX = wasm32-wasi-clang++ +STAGE3_wasm32-unknown-wasi_CXX = wasm32-unknown-wasi-clang++ STAGE3_wasm32-unknown-wasi_CXX_OPTS = $(STAGE3_wasm32-unknown-wasi_CC_OPTS) -fno-exceptions -STAGE3_wasm32-unknown-wasi_AR = wasm32-wasi-ar -STAGE3_wasm32-unknown-wasi_RANLIB = wasm32-wasi-ranlib +STAGE3_wasm32-unknown-wasi_CPP = wasm32-unknown-wasi-clang +STAGE3_wasm32-unknown-wasi_HS_CPP = wasm32-unknown-wasi-clang +STAGE3_wasm32-unknown-wasi_CMM_CPP = wasm32-unknown-wasi-clang +STAGE3_wasm32-unknown-wasi_AR = wasm32-unknown-wasi-ar +STAGE3_wasm32-unknown-wasi_RANLIB = wasm32-unknown-wasi-ranlib STAGE3_wasm32-unknown-wasi_EXTRA_INCLUDE_DIRS = STAGE3_wasm32-unknown-wasi_EXTRA_LIB_DIRS = -STAGE3_wasm32-unknown-wasi_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) --merge-objs wasm-ld --merge-objs-opt="-r" --disable-tables-next-to-code --disable-libffi-adjustors +STAGE3_wasm32-unknown-wasi_GHC_TOOLCHAIN_ARGS = $(GHC_TOOLCHAIN_ARGS) \ + --merge-objs wasm-ld --merge-objs-opt="-r" \ + --disable-tables-next-to-code --disable-libffi-adjustors \ + --cc-link-opt="-Wl,--keep-section=ghc_wasm_jsffi" \ + --cc-link-opt="-Wl,--keep-section=target_features" \ + --cc-link-opt="-Wl,--stack-first" \ + --cc-link-opt="-Wl,--strip-debug" TARGET_DIR = $(DIST_DIR)/lib/targets/$(TARGET_PLATFORM) +# Happy template directory: defaults to stage2's unpacked happy-lib source; can +# be overridden for dist-based cross builds where stage2 build tree is unavailable. +HAPPY_TEMPLATE_DIR ?= $(wildcard _build/stage2/src/happy-lib-*/data) + # NOTE: disable-library-for-ghci is repeated here but it should be sufficient # to put it in cabal.project.stage3 @@ -901,8 +978,9 @@ define stage3 STAGE3_$(1)_CABAL_BUILD = \ env \ - DERIVE_CONSTANTS=$$(call NORMALIZE_FP,$$(CURDIR)/$$(STAGE1_PATH)/bin/deriveConstants) \ - GENAPPLY=$$(call NORMALIZE_FP,$$(CURDIR)/$$(STAGE1_PATH)/bin/genapply) \ + PATH="$$(call NORMALIZE_FP,$$(abspath $$(dir $$(GENPRIMOPCODE_BIN)))):$$$$PATH" \ + DERIVE_CONSTANTS=$$(call NORMALIZE_FP,$$(abspath $$(DERIVE_CONSTANTS_BIN))) \ + GENAPPLY=$$(call NORMALIZE_FP,$$(abspath $$(GENAPPLY_BIN))) \ NM=$$(STAGE3_$(1)_NM) \ OBJDUMP=$$(STAGE3_$(1)_OBJDUMP) \ $$(CABAL_BUILD) \ @@ -911,20 +989,29 @@ STAGE3_$(1)_CABAL_BUILD = \ --ghc-options "-ghcversion-file=$$(call NORMALIZE_FP,$$(CURDIR)/rts/include/ghcversion.h)" \ --with-hsc2hs=$$(call NORMALIZE_FP,$$(CURDIR)/$$(DIST_DIR)/bin/$(1)-hsc2hs) \ --hsc2hs-options='-x' \ + --happy-options="--template=$$(abspath $$(HAPPY_TEMPLATE_DIR))" \ --with-gcc $$(STAGE3_$(1)_CC) \ $$(foreach dir,$$(STAGE3_$(1)_EXTRA_LIB_DIRS),--extra-lib-dirs=$$(dir)) \ $$(foreach dir,$$(STAGE3_$(1)_EXTRA_INCLUDE_DIRS),--extra-include-dirs=$$(dir)) +# In DIST_BUILD mode, stage2 binaries come from a pre-built dist artifact; +# skip the $(GHC2) prerequisite (it points into the stage2 store, not dist). +ifdef DIST_BUILD +STAGE3_$(1)_PREREQS = $$(GHC_TOOLCHAIN_BIN) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files +else +STAGE3_$(1)_PREREQS = $(GHC2) $$(GHC_TOOLCHAIN_BIN) $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files +endif + .PHONY: stage3-$(1) stage3-$(1): STAGE=stage3 stage3-$(1): TARGET_PLATFORM=$(1) -stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS) $(CONFIGURED_FILES) libraries/ghc-boot-th-next cabal.project.common cabal.project.stage3 stage3-$(1)-additional-files +stage3-$(1): $$(STAGE3_$(1)_PREREQS) $$(call PHASE_START,stage3-$(1)) $$(call LOG,Linking executables) $$(foreach exe,$$(STAGE3_EXECUTABLES),$(LN_SF) $$(exe) $(DIST_DIR)/bin/$(1)-$$(exe);) @mkdir -p $$(TARGET_DIR)/lib - $$(STAGE1_PATH)/bin/ghc-toolchain-bin \ + $$(GHC_TOOLCHAIN_BIN) \ --output-settings \ --output $$(TARGET_DIR)/lib/settings \ --triple $(1) \ @@ -932,6 +1019,9 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS) $$(foreach opt,$$(STAGE3_$(1)_CC_OPTS),--cc-opt=$$(opt)) \ --cxx $$(STAGE3_$(1)_CXX) \ $$(foreach opt,$$(STAGE3_$(1)_CXX_OPTS),--cxx-opt=$$(opt)) \ + $(if $(STAGE3_$(1)_CPP),--cpp $$(STAGE3_$(1)_CPP),) \ + $(if $(STAGE3_$(1)_HS_CPP),--hs-cpp $$(STAGE3_$(1)_HS_CPP),) \ + $(if $(STAGE3_$(1)_CMM_CPP),--cmm-cpp $$(STAGE3_$(1)_CMM_CPP),) \ $(if $(STAGE3_$(1)_AR),--ar $$(STAGE3_$(1)_AR),) \ $(if $(STAGE3_$(1)_LD),--ld $$(STAGE3_$(1)_LD),) \ $(if $(STAGE3_$(1)_ND),--nm $$(STAGE3_$(1)_NM),) \ @@ -939,6 +1029,46 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS) --disable-ld-override \ $$(STAGE3_$(1)_GHC_TOOLCHAIN_ARGS) +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 @@ -976,10 +1106,28 @@ stage3-$(1): $(GHC2) $$(STAGE1_PATH)/bin/ghc-toolchain-bin $(CONFIGURE_SCRIPTS) $(DIST_DIR)/ghc-$(1).tar.gz: stage3-$(1) @echo "::group::Creating ghc-$(1).tar.gz..." - tar czf $$@ \ + # -h: dereference symlinks. The stage3 cross bin/$(1)-* entries are + # symlinks to the native stage2 binaries (bin/ghc, bin/ghc-pkg, etc.). + # Without -h, the tarball would extract to broken symlinks since the + # bare-name targets aren't included. With -h, each cross-prefixed name + # becomes a real file (a copy of the native binary, which detects its + # invocation name to configure for the target). + # + # wasm only — lib/$(HOST_PLATFORM): when stage2 is built DYNAMIC=1 + # (which is required for stage3's build-side happy-lib/alex to find + # native Prelude.dyn_hi when shared:True applies project-wide), the + # stage2 bin/ghc that gets symlinked-then-dereferenced as + # bin/wasm32-unknown-wasi-ghc is dyn-linked against libHS*.so files + # under lib/$(HOST_PLATFORM)/. The binary's @rpath is set to + # `$$ORIGIN/../lib/$(HOST_PLATFORM)` during stage2 packaging, so + # the libs need to ship in the wasm bindist at that path or the + # binary fails to load with dyld errors on end-user install. + tar czhf $$@ \ --directory=$$(DIST_DIR) \ $(foreach exe,$(STAGE3_EXECUTABLES),bin/$(1)-$(exe)$(EXE_EXT)) \ - lib/targets/$(1) + lib/targets/$(1) \ + $(if $(filter wasm32-unknown-wasi,$(1)),lib/$(HOST_PLATFORM)) \ + $(if $(filter wasm32-unknown-wasi,$(1)),relocate.sh configure Makefile) @echo "::endgroup::" endef @@ -1009,6 +1157,22 @@ stage3-wasm32-unknown-wasi-additional-files: @cp -f utils/jsffi/post-link.mjs $(TARGET_DIR)/lib/post-link.mjs $(call LOG,Copying prelude.mjs) @cp -f utils/jsffi/prelude.mjs $(TARGET_DIR)/lib/prelude.mjs + # Phase 3: relocate.sh at the top of the bindist — re-runs ghc-pkg recache + # for the new install prefix (binary cache is not portable; *.conf files + # already use ${pkgroot}-relative paths from DIST_COPY_LIB_CONF_CROSS). + # Note: ghc-pkg auto-recaches by mtime on first read, so this is just an + # eager refresh. + $(call LOG,Copying relocate.sh to bindist root) + @cp -f mk/wasm-relocate.sh $(DIST_DIR)/relocate.sh + @chmod +x $(DIST_DIR)/relocate.sh + # Phase 4: autoconf-shaped stubs so `ghcup install ghc ` can do its + # standard "./configure --prefix=DIR && make install" flow. The configure + # script just records the prefix; the Makefile install target copies + # bin/ + lib/ + relocate.sh and refreshes the package-db cache. + $(call LOG,Copying configure + Makefile to bindist root) + @cp -f mk/wasm-configure.sh $(DIST_DIR)/configure + @chmod +x $(DIST_DIR)/configure + @cp -f mk/wasm-bindist-Makefile $(DIST_DIR)/Makefile stage3-x86_64-musl-linux-additional-files: STAGE=stage3 stage3-x86_64-musl-linux-additional-files: TARGET_PLATFORM=x86_64-musl-linux @@ -1027,9 +1191,9 @@ stage3: $(foreach platform,$(STAGE3_PLATFORMS),stage3-$(platform)) # |____/|_|_| |_|\__,_|_|___/\__|___/ # -$(DIST_DIR)/ghc.tar.gz: stage2 +$(DIST_DIR)/ghc.tar.gz: $(STAGE2_STAMP) @echo "::group::Creating ghc.tar.gz..." - @$(TAR) czf $@ \ + @tar czf $@ \ --directory=$(DIST_DIR) \ $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \ $(shell if [ "$(DYNAMIC)" = 1 ] ; then echo "bin/ghc-iserv-dyn$(EXE_EXT)" ; fi) \ @@ -1041,20 +1205,20 @@ $(DIST_DIR)/ghc.tar.gz: stage2 lib/$(HOST_PLATFORM) @echo "::endgroup::" -$(DIST_DIR)/cabal.tar.gz: stable-cabal +$(DIST_DIR)/cabal.tar.gz: | stable-cabal @echo "::group::Creating cabal.tar.gz..." @mkdir -p $(DIST_DIR)/bin @cp $(CABAL) $(DIST_DIR)/bin/ - @$(TAR) czf $@ \ + @tar czf $@ \ --directory=$(DIST_DIR) \ bin/cabal @echo "::endgroup::" -$(DIST_DIR)/haskell-toolchain.tar.gz: stable-cabal stage2 stage3-javascript-unknown-ghcjs +$(DIST_DIR)/haskell-toolchain.tar.gz: $(STAGE2_STAMP) | stable-cabal stage3-javascript-unknown-ghcjs @echo "::group::Creating haskell-toolchain.tar.gz..." @mkdir -p $(DIST_DIR)/bin @cp $(CABAL) $(DIST_DIR)/bin/ - @$(TAR) czf $@ \ + @tar czf $@ \ --directory=$(DIST_DIR) \ $(foreach exe,$(STAGE2_EXECUTABLES),bin/$(exe)$(EXE_EXT)) \ lib/ghc-usage.txt \ @@ -1068,9 +1232,49 @@ $(DIST_DIR)/haskell-toolchain.tar.gz: stable-cabal stage2 stage3-javascript-unkn 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 $@ \ + @tar czf $@ \ testsuite @echo "::endgroup::" @@ -1081,11 +1285,13 @@ $(DIST_DIR)/tests.tar.gz: # |_| |_|\__,_|\___|_|\_\__,_|\__, |\___| # |___/ +ifndef DIST_BUILD # .PHONY: hackage hackage: $(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz -$(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz: +$(BUILD_DIR)/packages/hackage.haskell.org/01-index.tar.gz: | stable-cabal $(CABAL) --remote-repo-cache $(call NORMALIZE_FP,$(CURDIR)/$(BUILD_DIR)/packages) update +endif # DIST_BUILD (hackage) # ____ __ _ # / ___|___ _ __ / _(_) __ _ _ _ _ __ ___ diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 000000000000..ac48198baef0 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,180 @@ +# GHC WASM Cross-Compiler Build Instructions + +## Prerequisites + +### Nix Only - No ghcup! + +All dependencies are provided via Nix. You only need: + +```bash +# Verify Nix is installed +nix --version +``` + +That's it! No ghcup, no manual WASM SDK installation. + +## Building WASM Cross-Compiler + +### Option A: Build on linux-0 (Recommended) + +```bash +./build-wasm-on-linux0.sh +``` + +This will: +1. Sync source code to linux-0 +2. Run the Makefile-based build in nix-shell +3. Takes 1-2 hours + +### Option B: Build Locally + +```bash +./build-wasm-make.sh +``` + +Or manually: +```bash +nix-shell +make CABAL=_build/stage0/bin/cabal stage2 +make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi +``` + +## Build Process + +The build happens in two stages: + +### Stage 2: Bootstrap Compiler +```bash +make CABAL=_build/stage0/bin/cabal stage2 +``` +Builds GHC itself using the GHC 9.8.4 bootstrap compiler from Nix. + +**Time:** ~45-60 minutes + +### Stage 3: WASM Cross-Compiler +```bash +make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi +``` +Builds the WASM cross-compiler using stage2. + +**Time:** ~30-45 minutes + +## Build Output + +The WASM cross-compiler will be at: +``` +_build/stage3/bin/wasm32-unknown-wasi-ghc +``` + +## Testing + +```bash +# Create a simple program +echo 'main = putStrLn "Hello from WASM!"' > hello.hs + +# Compile to WASM (in nix-shell) +nix-shell --run '_build/stage3/bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm' + +# Run with wasmtime +wasmtime hello.wasm +``` + +## What Nix Provides + +The `shell.nix` environment includes: + +- **GHC 9.8.4** - Bootstrap compiler +- **Cabal, Happy, Alex** - Build tools +- **LLVM 18 with Clang** - WASM backend support +- **WASM Cross-Compiler** - From `pkgsCross.wasi32` (Clang 19 with wasilibc) +- **Build essentials** - autoconf, automake, python3, etc. + +**100% from nixpkgs** - No ghcup, no manual downloads, no external WASM SDK! + +## Build Times + +- **Stage 2**: ~45-60 minutes (full GHC bootstrap) +- **Stage 3**: ~30-45 minutes (WASM cross-compiler) +- **Total**: ~1.5-2 hours on linux-0 + +## About This Implementation + +This build uses: +- **Makefile-based build system** (NOT Hadrian - Hadrian is wrong!) +- **100% Nix** (NO ghcup) - provides GHC bootstrap, build tools, and LLVM +- **wasi-sdk 24.0** - official WASM/WASI SDK from WebAssembly/wasi-sdk +- **Alternative __PIC__ fix** - removes guards from `rts/wasm/Wasm.S` +- **Native adjustors** - uses native code instead of libffi (libffi is Emscripten-only) + +Our changes implement proper on-demand GlobalRegs compilation as an +alternative to the patch in https://github.com/stable-haskell/ghc/issues/134. + +### WASM Toolchain: wasi-sdk 24.0 + +The shell.nix automatically downloads and sets up wasi-sdk 24.0 which provides: +- `clang` 18.1.8 targeting wasm32-unknown-wasi +- Integrated `wasi-libc` (WASI C library with sysroot) +- LLVM binutils (`llvm-ar`, `llvm-ranlib`, `wasm-ld`) + +**Why not nixpkgs pkgsCross.wasi32?** +- nixpkgs LLD 19.1.7 has a bug rejecting valid WebAssembly binary modules +- See error: "archive member '*.c.obj' is neither ET_REL nor LLVM bitcode" +- wasi-sdk 24.0 has a working linker and is the upstream recommended toolchain + +The shell.nix creates wrapper scripts (not symlinks) for all WASM tools to ensure +LD_LIBRARY_PATH propagates through Cabal subprocesses to find libLLVM.so.18.1-wasi-sdk. + +### libffi Incompatibility with WASI + +**Important:** libffi's WebAssembly support is Emscripten-only, NOT compatible with WASI. + +The RTS is patched to exclude wasm32 from the libffi-clib dependency (see rts.cabal +lines 596 and 794). The build uses native adjustors instead via the +`--disable-libffi-adjustors` flag passed to ghc-toolchain-bin. + +See: https://github.com/libffi/libffi/blob/master/src/wasm/ffi.c#L33 +(requires emscripten/emscripten.h, not available in WASI) + +## Troubleshooting + +### "nix-shell: command not found" + +Install Nix: +```bash +curl -L https://nixos.org/nix/install | sh +``` + +### "make: *** No rule to make target 'stage3-wasm32-unknown-wasi'" + +Make sure you're on the `stable-ghc-9.14` branch with the latest Makefile. + +### Build fails in stage2 + +Make sure you're inside `nix-shell` which provides all necessary tools. + +### LLVM/Clang version issues + +The shell.nix uses LLVM 18. If you need a different version, edit shell.nix. + +## Interactive Development + +For development work: + +```bash +# Enter the nix shell +nix-shell + +# Now you have all tools available +ghc --version +cabal --version +llvm-config --version + +# Build stages manually +make CABAL=_build/stage0/bin/cabal stage2 +make CABAL=_build/stage0/bin/cabal stage3-wasm32-unknown-wasi +``` + +## References + +- Build instructions: https://github.com/stable-haskell/ghc/issues/134 +- Stable Haskell GHC: https://github.com/stable-haskell/ghc diff --git a/build-wasm-make.sh b/build-wasm-make.sh new file mode 100755 index 000000000000..555fc78c8583 --- /dev/null +++ b/build-wasm-make.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build WASM cross-compiler using Makefile (NOT Hadrian!) +# Based on https://github.com/stable-haskell/ghc/issues/134 +# +# This script uses NIX flake for ALL dependencies (NO ghcup!) +# 1. Nix provides GHC bootstrap compiler and tools +# 2. Nix provides LLVM/Clang with WASM support (pinned via ghc-wasm-meta) +# 3. Builds stage2 (bootstrap native compiler) +# 4. Builds stage3-wasm32-unknown-wasi (cross-compiler + WASM libraries) +# +# The Makefile default CABAL=_build/stage0/bin/cabal is used (no override needed): +# stage0 cabal is built automatically from the repo's pinned Cabal source. + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}ℹ${NC} $*"; } +success() { echo -e "${GREEN}✓${NC} $*"; } +error() { echo -e "${RED}✗${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } + +# Check we're in the right place +if [ ! -f "Makefile" ]; then + error "Not in GHC source directory (Makefile not found)" + exit 1 +fi + +info "Building WASM cross-compiler via Makefile" +info "Using Nix flake for all dependencies (NO ghcup)" +info "" + +# Check for nix +if ! command -v nix >/dev/null 2>&1; then + error "nix not found. Please install Nix first." + exit 1 +fi + +# Build stage2: native bootstrap compiler +# Uses Makefile default CABAL=_build/stage0/bin/cabal (stage0 built automatically) +info "Step 1/2: Building stage2 (bootstrap compiler)" +info "This builds GHC itself using the bootstrap compiler..." +info "Expected time: 45-60 minutes" +info "" + +nix develop . -c make stage2 + +echo "" +success "Stage2 build complete!" +echo "" + +# Build stage3-wasm32-unknown-wasi: cross-compiler + WASM libraries +info "Step 2/2: Building stage3-wasm32-unknown-wasi (WASM cross-compiler)" +info "This builds the WASM cross-compiler using stage2..." +info "Expected time: 30-45 minutes" +info "" + +nix develop . -c make stage3-wasm32-unknown-wasi + +echo "" +success "WASM cross-compiler build complete!" +echo "" +echo "Cross-compiler location:" +echo " _build/stage3/bin/wasm32-unknown-wasi-ghc" +echo "" +echo "To test:" +echo " printf 'main = putStrLn \"Hello WASM\"\n' > hello.hs" +echo " nix develop . -c _build/stage3/bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm" diff --git a/build-wasm-on-linux0.sh b/build-wasm-on-linux0.sh new file mode 100755 index 000000000000..073fc48b485a --- /dev/null +++ b/build-wasm-on-linux0.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Helper script to build WASM cross-compiler on linux-0 +# Syncs the repo (source only, no _build/), then runs the Makefile-based build. +# +# Usage: ./build-wasm-on-linux0.sh +# +# Prerequisites on linux-0: +# 1. Nix must be installed with flake support +# +# Note: This script handles the case where the local repo is a git worktree +# by re-initializing a fresh git repo on the remote after syncing (nix develop +# requires the directory to be a proper git repository to evaluate flake.nix). + +set -euo pipefail + +REMOTE_HOST="${REMOTE_HOST:-x86_64-linux-0.lan}" +REMOTE_USER="${REMOTE_USER:-${USER}}" +REMOTE_DIR="${REMOTE_DIR:-/tmp/ghc-wasm-build}" +LOCAL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${BLUE}ℹ${NC} $*"; } +success() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } + +info "Syncing GHC source to ${REMOTE_HOST}:${REMOTE_DIR}..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}" + +# Sync the source (excluding build artifacts and git metadata). +# We exclude .git because when building from a git worktree, .git is a FILE +# pointing to the main repo on the local machine — unusable on the remote. +rsync -avz --delete \ + --exclude='_build/' \ + --exclude='.git' \ + --exclude='.git/' \ + --exclude='cabal-cache/' \ + --exclude='.nix-wasm-bin/' \ + --exclude='*.o' \ + --exclude='*.hi' \ + "${LOCAL_DIR}/" \ + "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" + +# Re-initialize git on the remote. +# nix develop requires the directory to be a valid git repo to evaluate flake.nix +# (it uses builtins.fetchGit / git+file:// URL for the local flake). +# After rsync (which excludes .git), we create a fresh repo with all files staged. +info "Re-initializing git repository on remote (needed for nix develop)..." +ssh "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && git init -q && git add -A && git commit -q -m 'build: local snapshot' 2>/dev/null || true" + +success "Sync complete" + +info "Starting WASM cross-compiler build on linux-0..." +info "Using Makefile-based build (stage2 → stage3-wasm32-unknown-wasi)" +warn "This will take 1-2 hours depending on the machine" +echo "" + +# Run the build interactively so progress is visible +ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && ./build-wasm-make.sh" + +success "Build completed!" +info "The WASM cross-compiler is available at:" +echo " ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/_build/stage3/bin/wasm32-unknown-wasi-ghc" diff --git a/cabal.project.stage1 b/cabal.project.stage1 index eeaf3da3db8d..27ce9669a696 100644 --- a/cabal.project.stage1 +++ b/cabal.project.stage1 @@ -1,6 +1,15 @@ -- Configuration common to all stages import: cabal.project.common +-- Hermetic build: resolve only from the explicitly-listed packages (+ the +-- bootstrap compiler's installed packages), never Hackage. stage2/stage3 +-- already set this, but stage1 did not — so stage1's build-tools (happy/alex +-- for the compiler's Parser.y/Lexer.x) floated to Hackage's newest. That was +-- latent until happy-2.1.7 was published, after which stage1 built +-- happy-lib-2.1.7 and the resulting parser code broke the build +-- (genprimopcode GHC-44432: happyDoParse vs the pinned happy template). +active-repositories: :none + packages: -- NOTE: we need rts-headers, because the _newly_ built compiler depends -- on these potentially _new_ headers, we must not rely on those from @@ -40,6 +49,15 @@ packages: https://hackage.haskell.org/package/semaphore-compat-2.0.1/semaphore-compat-2.0.1.tar.gz https://hackage.haskell.org/package/unix-2.8.8.0/unix-2.8.8.0.tar.gz https://hackage.haskell.org/package/Win32-2.14.2.1/Win32-2.14.2.1.tar.gz + + -- Build-tools for the compiler's Parser.y / Lexer.x, plus hpc (a dep of ghc + -- whose bootstrap-installed version wants the old directory). Pinned so the + -- `active-repositories: :none` above resolves them to the same vetted + -- versions as stage2/stage3 instead of floating to Hackage's newest. + https://hackage.haskell.org/package/alex-3.5.2.0/alex-3.5.2.0.tar.gz + https://hackage.haskell.org/package/happy-2.1.5/happy-2.1.5.tar.gz + https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz + https://hackage.haskell.org/package/hpc-0.7.0.2/hpc-0.7.0.2.tar.gz -- hsc2hs: see source-repository-package below source-repository-package diff --git a/cabal.project.stage3 b/cabal.project.stage3 index b241b89ed93e..33ecdc20638d 100644 --- a/cabal.project.stage3 +++ b/cabal.project.stage3 @@ -1,6 +1,28 @@ -- Configuration common to all stages import: cabal.project.common +-- WASM target libraries are built shared so end-user TH/GHCi works at +-- runtime via dyld.mjs (miso, jsaddle, aeson, …): the wasm libs then ship +-- .dyn_hi + .so. Scoped to `arch(wasm32)` so it applies ONLY when cabal +-- builds against the wasm32 --with-compiler — the native build-compiler +-- packages (happy-lib, alex, Setup.hs) and the JS target are unaffected +-- (JS can't produce .so via emcc/wasm-ld: `wasm-ld: unknown argument: -h`). +-- +-- This was previously generated by ./configure into +-- cabal.project.stage3.settings from the stage2 accumulation machinery +-- (m4/accumulate.m4 + @ALL_PACKAGES@). That machinery was removed in +-- favour of explicit cabal.project.stage2.{common,static,dynamic}; stage3 +-- has only the one wasm-dynamic variant (the Cross: MULTI build always +-- runs DYNAMIC=1), so we inline it here instead of regenerating it. +-- The per-target `settings` dials (target is dynamic / ships dynamic +-- libraries = YES for wasm) make GHC's link pipeline accept this. +if arch(wasm32) + package * + shared: True + executable-dynamic: True + constraints: + rts +dynamic + -- Disable Hackage, we explicitly include the packages we need. active-repositories: :none @@ -48,8 +70,8 @@ packages: https://hackage.haskell.org/package/os-string-2.0.8/os-string-2.0.8.tar.gz https://hackage.haskell.org/package/parsec-3.1.18.0/parsec-3.1.18.0.tar.gz https://hackage.haskell.org/package/pretty-1.1.3.6/pretty-1.1.3.6.tar.gz - https://hackage.haskell.org/package/process-1.6.29.0/process-1.6.29.0.tar.gz - https://hackage.haskell.org/package/semaphore-compat-2.0.1/semaphore-compat-2.0.1.tar.gz + https://hackage.haskell.org/package/process-1.6.26.1/process-1.6.26.1.tar.gz + https://hackage.haskell.org/package/semaphore-compat-1.0.0/semaphore-compat-1.0.0.tar.gz https://hackage.haskell.org/package/stm-2.5.3.1/stm-2.5.3.1.tar.gz https://hackage.haskell.org/package/text-2.1.3/text-2.1.3.tar.gz https://hackage.haskell.org/package/time-1.15/time-1.15.tar.gz @@ -65,6 +87,11 @@ packages: https://hackage.haskell.org/package/happy-lib-2.1.5/happy-lib-2.1.5.tar.gz source-repository-package + -- 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 -- See cabal.project.stage0 for why this SHA (post-split host:*/build:*). @@ -146,16 +173,17 @@ constraints: -- Package level configuration -- +-- NOTE: `shared` / `executable-dynamic` for the wasm target are set in the +-- `if arch(wasm32)` block at the top of this file (not here), so they apply +-- only to wasm and leave the native build-compiler / JS target packages +-- vanilla. package * - shared: False - executable-dynamic: False - -- library-for-ghci will cause a `ld -r` call to create pre-linked objects. -- This helps the internal linker when trying to link (.a) archives with massive -- displacements. In that case the displacement can be in excess of what -- is possible to relocate, and the linker fails. Pre-linking the objects helps with -- this (and linking performance, as we need to process fewer relocations). - -- + -- -- For some cross targets like JavaScript, this will fail as the $LD -r invocation -- might not be properly supported. -- @@ -173,13 +201,15 @@ package * package libffi-clib ghc-options: -no-rts --- WASM/WASI-specific overrides: --- - shared: True is required for GHCi/TH via dyld.mjs --- - +use-system-libffi is already set unconditionally on package rts above, --- but we keep this block for clarity and future conditional changes. -if os(wasi) - package * - shared: True +-- WASM/WASI shared-library overrides live in the `if arch(wasm32)` block +-- near the top of this file (shared: True + executable-dynamic: True + +-- rts +dynamic). An earlier (2026-05-26) attempt failed at the +-- ghc-internal link step ("[GHC-74335] -dynamic is ignored when linking +-- binaries on WASM"); it was unblocked by the per-target `settings` dials +-- (target is dynamic / ships dynamic libraries = YES for wasm) plus +-- disabling the wasm makeDynFlagsConsistent rule — not by a cabal tweak. +-- With that in place end-user TH-heavy apps (miso, jsaddle, aeson, …) +-- build against this bindist. package ghc flags: +build-tool-depends +internal-interpreter diff --git a/compiler/GHC/Driver/Session.hs b/compiler/GHC/Driver/Session.hs index 4b303d6e3787..385a02e04375 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(..), @@ -3555,8 +3559,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"), -- The semaphore protocol version supported by @-jsem@. @@ -3578,10 +3590,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 @@ -3791,18 +3820,26 @@ makeDynFlagsConsistent dflags = let warn = "-dynamic is ignored when using -staticlib" in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn -- For the wasm target, when ghc is invoked with -dynamic, - -- when linking the final .wasm binary we must still ensure - -- the static archives are selected. Otherwise wasm-ld would - -- fail to find and link the .so library dependencies. wasm-ld - -- can link PIC objects into static .wasm binaries fine, so we - -- only adjust the ways in the final linking step, and only - -- when linking .wasm binary (which is supposed to be fully - -- static), not when linking .so shared libraries. - | LinkExecutable _ <- ghcLink dflags - , ArchWasm32 <- arch - , ways dflags `hasWay` WayDyn - = let warn = "-dynamic is ignored when linking binaries on WASM" - in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn + -- (DISABLED Phase 6.5, 2026-05-26) — the original rule below was meant + -- to strip WayDyn only when linking the final .wasm binary, but the + -- default `ghcLink` in `defaultDynFlags` is `LinkExecutable Dynamic`, so + -- the rule fired for EVERY --make invocation that didn't override it. + -- That broke cabal library builds with `shared:True, library-vanilla:False` + -- (the rule stripped WayDyn mid-compile, then GHC looked for vanilla .hi + -- files that don't exist since only .dyn_hi was produced). + -- + -- The wasm linker (wasm-ld) silently handles -dynamic without issue — + -- wasm binaries are inherently single-file artifacts, and PIC objects + -- link fine into them. Removing the strip lets WayDyn flow through to + -- the actual link step, where wasm-ld does the right thing. + -- (If a future regression surfaces, narrow the rule to fire ONLY in the + -- actual link phase, not at flag-consistency time.) + -- + -- | LinkExecutable _ <- ghcLink dflags + -- , ArchWasm32 <- arch + -- , ways dflags `hasWay` WayDyn + -- = let warn = "-dynamic is ignored when linking binaries on WASM" + -- in loop dflags{targetWays_ = removeWay WayDyn (targetWays_ dflags)} warn | LinkInMemory <- ghcLink dflags , not (gopt Opt_ExternalInterpreter dflags) diff --git a/compiler/GHC/Linker/Dynamic.hs b/compiler/GHC/Linker/Dynamic.hs index 9a6346309cb2..f9da15be4fe6 100644 --- a/compiler/GHC/Linker/Dynamic.hs +++ b/compiler/GHC/Linker/Dynamic.hs @@ -16,6 +16,7 @@ import GHC.SysTools.Tasks import GHC.Driver.Config.Linker import GHC.Driver.Session +import GHC.Driver.DynFlags (rtsWayUnitId) import GHC.Unit.Env import GHC.Unit.Types @@ -52,7 +53,27 @@ linkDynLib logger tmpfs dflags0 unit_env o_files dep_packages verbFlags = getVerbFlags dflags o_file = outputFile_ dflags - pkgs_with_rts <- mayThrowUnitErr (preloadUnitsInfo' unit_env dep_packages) + pkgs_with_rts <- do + pkgs0 <- mayThrowUnitErr (preloadUnitsInfo' unit_env dep_packages) + -- On wasm32, every Haskell .so must declare the current way's rts in + -- its `dylink.0/needed_dynlibs` section, otherwise the runtime dyld + -- can load it ahead of rts and the .so's `_initialize` references + -- unresolved rts symbols (e.g. `registerForeignExports`) — see + -- ghc-internal, which is built with `-no-rts` to bypass mkUnitState's + -- sanity check (compiler/GHC/Unit/State.hs:1652) but consequently has + -- cabal omit rts from its --depends list. wasm-ld populates + -- needed_dynlibs from the resolved `-l` flags, so we must inject the + -- current way's rts here whenever it isn't already present, even if + -- `-no-rts` is in effect. Other targets either link rts statically at + -- exe-link time or rely on host symbol resolution; on wasm32 the .so + -- itself carries the dependency. + let rts_uid = rtsWayUnitId dflags + pure $ case arch of + ArchWasm32 + | not (any ((== rts_uid) . unitId) pkgs0) + , Just rts_info <- lookupUnitId (ue_units unit_env) rts_uid + -> rts_info : pkgs0 + _ -> pkgs0 let pkg_lib_paths = collectLibraryDirs (ways dflags) pkgs_with_rts let pkg_lib_path_opts = concatMap get_pkg_lib_path_opts pkg_lib_paths @@ -84,7 +105,10 @@ linkDynLib logger tmpfs dflags0 unit_env o_files dep_packages -- * if -flink-rts is used, we link with the rts. -- -- * on wasm we need to ensure libHSrts*.so is listed in - -- WASM_DYLINK_NEEDED, otherwise dyld can't load it. + -- WASM_DYLINK_NEEDED, otherwise dyld can't load it. We force the + -- current way's rts into `pkgs_with_rts` above so that even .so + -- files whose package was built with `-no-rts` (e.g. ghc-internal) + -- still declare the rts dependency. -- -- let pkgs_without_rts = filter ((/= PackageName (fsLit "rts")) . unitPackageName) pkgs_with_rts 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/Runtime/Interpreter/Wasm.hs b/compiler/GHC/Runtime/Interpreter/Wasm.hs index 645aba927181..13a73042397e 100644 --- a/compiler/GHC/Runtime/Interpreter/Wasm.hs +++ b/compiler/GHC/Runtime/Interpreter/Wasm.hs @@ -10,11 +10,13 @@ import GHC.Runtime.Interpreter.Types #if !defined(mingw32_HOST_OS) import Control.Concurrent.MVar +import Control.Exception (throwIO) import Data.Maybe import GHC.Data.FastString import qualified GHC.Data.ShortText as ST import GHC.Platform import GHC.Unit +import GHC.Utils.Panic (GhcException (InstallationError)) import GHCi.Message import System.Directory import System.Environment.Blank @@ -34,6 +36,21 @@ spawnWasmInterp :: WasmInterpConfig -> IO (ExtInterpInstance ()) -- See Note [The Wasm Dynamic Linker] for details spawnWasmInterp WasmInterpConfig {..} = do + -- Pre-flight: dyld.mjs (the wasm dynamic linker) has a + -- `#!/usr/bin/env -S node` shebang and needs `node` on $PATH. Without + -- it the iserv child exits with code 127, which the generic + -- "External interpreter terminated (127)" message in + -- Process.hs:129 doesn't explain. Emit a clear, actionable error + -- here instead. + mb_node <- findExecutable "node" + case mb_node of + Just _ -> pure () + Nothing -> throwIO . InstallationError $ + "Template Haskell evaluation on the wasm32 backend requires `node` " + ++ "(Node.js) on PATH. The wasm dynamic linker shim (" + ++ wasmInterpDyLD + ++ ") is a Node script. Install Node.js and ensure `node` is " + ++ "reachable, then retry." let Just ghci_unit_id = lookupPackageName wasmInterpUnitState 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 diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000000..47c63c5c0561 --- /dev/null +++ b/flake.lock @@ -0,0 +1,130 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "ghc-wasm-meta": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771125696, + "narHash": "sha256-+90XoM7PUfgD7383ki/aHC0cLKS32XVnYgI672pegcY=", + "ref": "refs/heads/master", + "rev": "4e1f900e9933966634bc2e29dbeb81d09ce36727", + "revCount": 514, + "type": "git", + "url": "https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git" + }, + "original": { + "type": "git", + "url": "https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770770419, + "narHash": "sha256-iKZMkr6Cm9JzWlRYW/VPoL0A9jVKtZYiU4zSrVeetIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6c5e707c6b5339359a9a9e215c5e66d6d802fd7a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0182a361324364ae3f436a63005877674cf45efb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "ghc-wasm-meta": "ghc-wasm-meta", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000000..837979fac389 --- /dev/null +++ b/flake.nix @@ -0,0 +1,150 @@ +{ + description = "GHC WASM Cross-Compiler Build Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + ghc-wasm-meta.url = "git+https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta.git"; + }; + + outputs = { self, nixpkgs, flake-utils, ghc-wasm-meta }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Use wasi-sdk from ghc-wasm-meta with libffi-wasm pre-integrated + wasi-sdk = ghc-wasm-meta.packages.${system}.wasi-sdk; + + in { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Bootstrap GHC compiler + haskell.compiler.ghc984 + haskell.packages.ghc984.cabal-install + haskell.packages.ghc984.happy + haskell.packages.ghc984.alex + + # Build tools + autoconf + automake + python3 + m4 + + # For WASM - llvm and clang with wasm support (native tools) + llvm_18 + clang_18 + + # Additional tools + git + gnumake + which + curl + + # NOTE: wasi-sdk is intentionally NOT in buildInputs to prevent nix + # from setting CC/LD/AR to the WASM cross-compiler globally. + # It's accessed exclusively via $WASI_SDK_DIR set in shellHook. + ]; + + shellHook = '' + echo "GHC WASM Cross-Compiler Build Environment" + echo "==========================================" + echo "" + echo "GHC version: $(ghc --version)" + echo "Cabal version: $(cabal --version | head -1)" + echo "LLVM version: $(llvm-config --version)" + echo "Clang version: $(clang --version | head -1)" + echo "" + + # wasi-sdk from ghc-wasm-meta includes libffi-wasm + export WASI_SDK_DIR="${wasi-sdk}" + + # Wasi sysroot paths for WASM RTS compilation. + # Passed as --extra-lib-dirs / --extra-include-dirs to cabal. + export WASM_EXTRA_LIB_DIRS="${wasi-sdk}/share/wasi-sysroot/lib/wasm32-unknown-wasi" + export WASM_EXTRA_INCLUDE_DIRS="${wasi-sdk}/share/wasi-sysroot/include" + + # Create short-name wrappers for WASM toolchain + # GHC Makefile expects wasm32-unknown-wasi-* but wasi-sdk provides full names + WASM_BIN_DIR="$PWD/.nix-wasm-bin" + mkdir -p "$WASM_BIN_DIR" + + # Create wrapper scripts for WASM toolchain + cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-clang" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/clang" "$@" +EOF + chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-clang" + + # GHC's WASM LLVM backend uses llc/opt/llvm-as to compile LLVM IR to WASM + # assembly. We must use wasi-sdk's LLVM tools (version 21) since they match + # the assembler (clang 21). Using nixpkgs' LLVM 18 tools with clang 21's + # assembler causes errors: ".size directive ignored for function symbols" + cat > "$WASM_BIN_DIR/llc" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/llc" "$@" +EOF + chmod +x "$WASM_BIN_DIR/llc" + + cat > "$WASM_BIN_DIR/opt" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/opt" "$@" +EOF + chmod +x "$WASM_BIN_DIR/opt" + + cat > "$WASM_BIN_DIR/llvm-as" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/llvm-as" "$@" +EOF + chmod +x "$WASM_BIN_DIR/llvm-as" + + cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-clang++" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/clang++" "$@" +EOF + chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-clang++" + + cat > "$WASM_BIN_DIR/wasm-ld" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/wasm-ld" "$@" +EOF + chmod +x "$WASM_BIN_DIR/wasm-ld" + + # NOTE: Do NOT create an 'ld' wrapper here. + # ghc-toolchain-bin auto-detects the native linker for stage1 settings. + # If 'ld' is the WASM linker, it breaks 'ld -r' tests for native compilation. + # The WASM settings file explicitly uses '--ld wasm-ld', not 'ld'. + + cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-ar" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/llvm-ar" "$@" +EOF + chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-ar" + + cat > "$WASM_BIN_DIR/wasm32-unknown-wasi-ranlib" <<'EOF' +#!/bin/sh +exec "${wasi-sdk}/bin/llvm-ranlib" "$@" +EOF + chmod +x "$WASM_BIN_DIR/wasm32-unknown-wasi-ranlib" + + # Add WASM wrappers to PATH + export PATH="$WASM_BIN_DIR:$PATH" + + echo "WASM toolchain from ghc-wasm-meta wasi-sdk:" + echo " - wasm32-unknown-wasi-clang (wrapped)" + echo " - wasm32-unknown-wasi-clang++ (wrapped)" + echo " - wasm-ld (wrapped, native ld is NOT overridden)" + echo " - wasm32-unknown-wasi-ar, wasm32-unknown-wasi-ranlib (wrapped)" + echo " - llc, opt, llvm-as (wrapped from wasi-sdk LLVM 21)" + echo " - Sysroot: ${wasi-sdk}/share/wasi-sysroot" + echo " - Includes libffi-wasm pre-integrated" + echo "" + + echo "To build WASM cross-compiler:" + echo " make stage2" + echo " make stage3-wasm32-unknown-wasi" + echo "" + ''; + }; + } + ); +} diff --git a/libraries/ghc-internal/configure.ac b/libraries/ghc-internal/configure.ac index 4bcf90948884..f6a2931b2677 100644 --- a/libraries/ghc-internal/configure.ac +++ b/libraries/ghc-internal/configure.ac @@ -240,8 +240,8 @@ AC_SUBST(GhcGmpVerMi) AC_SUBST(GhcGmpVerPl) # Compute offsets/sizes used by jsbits/base.js -if test "$host" = "javascript-ghcjs" -then +case "$host" in +javascript*) FP_COMPUTE_OFFSET([OFFSET_STAT_ST_MODE], [stat], [st_mode], [#include ]) FP_COMPUTE_OFFSET([OFFSET_STAT_ST_DEV], [stat], [st_dev], [#include ]) FP_COMPUTE_OFFSET([OFFSET_STAT_ST_UID], [stat], [st_uid], [#include ]) @@ -281,7 +281,8 @@ then FP_COMPUTE_SIZE([SIZEOF_UTIMBUF_ACTIME], [utimbuf], [actime], [#include ]) FP_COMPUTE_SIZE([SIZEOF_UTIMBUF_MODTIME], [utimbuf], [modtime], [#include ]) AC_CHECK_SIZEOF([struct utimbuf], [], [#include ]) -fi + ;; +esac # map standard C types and ISO types to Haskell types FPTOOLS_CHECK_HTYPE(char) 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/phase3-relocate-sh-draft.sh b/lode/phase3-relocate-sh-draft.sh new file mode 100755 index 000000000000..a4667aaaa0eb --- /dev/null +++ b/lode/phase3-relocate-sh-draft.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# relocate.sh — bundled with the stable-haskell wasm32-wasi GHC bindist. +# +# Run once after extracting the tarball to a new install prefix. +# The package-db *.conf files already use ${pkgroot}/../lib/wasm32-wasi/… +# (rewritten at build time by Makefile:DIST_COPY_LIB_CONF_CROSS), so the +# only thing that needs adjusting is the binary ghc-pkg cache, which +# encodes absolute paths from the build environment. +# +# ghcup invokes this from viPostInstall with PWD = install prefix. +# +# DRAFT — lives in lode/ until Phase 3 ships. Integrate into the Makefile +# bindist target ($(DIST_DIR)/ghc-wasm32-wasi.tar.gz, Makefile:1029) so the +# .tar.xz contains relocate.sh at the top level. +set -e + +PREFIX="$(cd "$(dirname "$0")" && pwd)" +GHC_PKG="$PREFIX/bin/wasm32-wasi-ghc-pkg" +PKG_DB="$PREFIX/lib/targets/wasm32-wasi/lib/package.conf.d" + +if [ ! -x "$GHC_PKG" ]; then + echo "error: $GHC_PKG not found or not executable" >&2 + exit 1 +fi +if [ ! -d "$PKG_DB" ]; then + echo "error: $PKG_DB not found" >&2 + exit 1 +fi + +echo "Relocating wasm32-wasi-ghc package db to: $PKG_DB" +"$GHC_PKG" recache --package-db "$PKG_DB" + +echo +echo "Stable Haskell wasm32-wasi-ghc ready." +echo " Compiler: $PREFIX/bin/wasm32-wasi-ghc" +echo " Package db: $PKG_DB" +echo +echo "Add $PREFIX/bin to your PATH, or invoke via ghcup." diff --git a/lode/phase6-miso-template-draft/Makefile b/lode/phase6-miso-template-draft/Makefile new file mode 100644 index 000000000000..69737ff33589 --- /dev/null +++ b/lode/phase6-miso-template-draft/Makefile @@ -0,0 +1,44 @@ +# Self-documenting Makefile for stable-haskell/miso-wasm-template. +# Run `make` (or `make help`) to see targets. +# +# All toolchain selection lives in cabal.project (with-compiler / +# with-build-compiler). No redundant CLI flags here — keeps a single +# source of truth. + +.DEFAULT_GOAL := help +SHELL := /bin/bash + +# Override these only if invoking a non-default toolchain. +WASM_GHC ?= wasm32-wasi-ghc +CABAL ?= cabal +PKG := myapp + +# Discovered lazily so `make help` works without the toolchain installed. +LIBDIR = $(shell $(WASM_GHC) --print-libdir) +POST_LINK = $(LIBDIR)/post-link.mjs +WASM_ARTIFACT = $(shell find dist-newstyle -name '$(PKG).wasm' -print -quit) + +.PHONY: help build link serve clean cabal-build + +help: ## Show this help. + @awk 'BEGIN{FS=":.*##"; printf "\nTargets:\n"} \ + /^[a-zA-Z_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' \ + $(MAKEFILE_LIST) + +build: link ## Build wasm + emit JSFFI glue + copy to public/. + +cabal-build: ## Run cabal (toolchains taken from cabal.project). + $(CABAL) build $(PKG) + +link: cabal-build ## Run post-link.mjs and stage artifacts. + @mkdir -p public + @test -n "$(WASM_ARTIFACT)" || { echo "no .wasm found under dist-newstyle"; exit 1; } + node $(POST_LINK) --input $(WASM_ARTIFACT) --output public/ghc_wasm_jsffi.js + cp $(WASM_ARTIFACT) public/myapp.wasm + +serve: ## Serve public/ at http://localhost:8080 (Ctrl-C to stop). + npx --yes http-server public -c-1 -p 8080 + +clean: ## Remove build artifacts. + rm -rf dist-newstyle + rm -f public/myapp.wasm public/ghc_wasm_jsffi.js diff --git a/lode/phase6-miso-template-draft/README.md b/lode/phase6-miso-template-draft/README.md new file mode 100644 index 000000000000..f8a3f734d996 --- /dev/null +++ b/lode/phase6-miso-template-draft/README.md @@ -0,0 +1,46 @@ +# miso-wasm-template + +Minimal browser-facing miso counter, compiled to WebAssembly with the +stable-haskell wasm cross-GHC distributed via ghcup. + +> **DRAFT — not yet validated against a real wasm cross-compiler build.** +> Lives in `lode/phase6-miso-template-draft/` until Phase 6 of the wasm-cross-ghcup +> initiative ships. See `REVIEW.md` for the gap list. + +## Prerequisites + +```sh +# 1. Wire up the stable-haskell ghcup channel. +ghcup config add-release-channel \ + https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml + +# 2. Cross-GHC + cabal that support `with-build-compiler`. +ghcup install ghc wasm32-wasi-9.14.0.stable +ghcup install cabal stable-3.17.0.1 + +# 3. Native GHC for the build-compiler half of the cross build. +ghcup install ghc 9.10.1 && ghcup set ghc 9.10.1 + +# 4. Non-GHC wasm tooling (wasi-sdk, node, wasmtime, binaryen). +curl -sSL https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh \ + | SKIP_GHC=1 sh +source ~/.ghc-wasm/env +``` + +## Build & Serve + +```sh +make build # cabal build + post-link.mjs + stage public/ +make serve # http-server on :8080 +``` + +Open . + +## Native dev loop + +Modify `cabal.project` to remove the `with-compiler` lines (or add a +`cabal.project.native` and pass `--project-file=cabal.project.native`): + +```sh +cabal run myapp --with-compiler=ghc --with-build-compiler=ghc +``` diff --git a/lode/phase6-miso-template-draft/REVIEW.md b/lode/phase6-miso-template-draft/REVIEW.md new file mode 100644 index 000000000000..62bc6c205ec1 --- /dev/null +++ b/lode/phase6-miso-template-draft/REVIEW.md @@ -0,0 +1,119 @@ +# Phase 6 miso template — draft review (2026-05-26) + +These template files were produced by a research agent during Phase 1 build +wait time. **Not yet validated** against a real wasm cross-compiler build — +they live here as drafts until Phase 6 actually runs. + +When ready to ship Phase 6: move these to a new repo +`stable-haskell/miso-wasm-template` (NOT in this repo). + +## Critical review + +### Bugs found in agent output (must fix before shipping) + +1. **`app/Main.hs` — invalid Haskell import alias** (BOTH `#ifdef WASM` + branch and native branch): + ```haskell + import qualified Language.Javascript.JSaddle.Wasm as JSaddle.Wasm -- INVALID + import qualified Language.Javascript.JSaddle.Warp as JSaddle.Warp -- INVALID + ``` + Haskell module aliases must be single conids — no dots. Fix to e.g. + `as JSW` / `as Warp`, or import the function directly: + ```haskell + import Language.Javascript.JSaddle.Wasm (run) + ... + main = run (startApp app) + ``` + +2. **`Makefile` `cabal-build` target redundancy**: the CLI flags + `--with-compiler` / `--with-build-compiler` duplicate what's already in + `cabal.project`. The project file is enough; drop them from the Makefile + target (or vice versa — but having both is just confusing). + +3. **`README.md` ghcup channel URL is a guess**: + ``` + https://raw.githubusercontent.com/stable-haskell/ghcup-metadata/main/ghcup-0.0.10.yaml + ``` + Per our Phase 4 design (lode/wasm-cross-ghcup-plan.md §8.3), the correct + URL is: + ``` + https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml + ``` + Update once Phase 4 publishes. + +4. **`browser_wasi_shim` version mismatch**: agent pinned `@0.3.0`; our RTS + bumped to `0.4.2` (commit `e0837350f14`, per memory). The user-side + launcher needs to match what the wasm module expects from WASI imports. + Verify against the version of `browser_wasi_shim` bundled in + `lib/targets/wasm32-wasi/lib/` (if shipped) or the version GHC's + `post-link.mjs` documents. + +### Empirical finding (2026-05-26): miso requires stage3 rebuilt with shared:True + +Direct attempt to build `miso 1.11.0` against the dual-compiler stable-haskell stack +hit `character-ps-0.1` → `Failed to load dynamic interface file for Data.Word: +base-4.22.0.0/Data/Word.dyn_hi: does not exist`. Root cause: our stage3 wasm +target libraries (base, ghc-internal, etc.) were built with `shared: False` per +`cabal.project.stage3` (lines 184-186 — the old `if os(wasi) package * shared: +True` block is commented out due to the build-package pollution bug). miso (and +most TH-using packages) require `shared: True`, which transitively requires +base.dyn_hi. + +**R7 resolution path (i)** — replace the commented-out form with +`if arch(wasm32) shared: True` (no `package *`). With stable-haskell/cabal's +Stage/Toolchain split, this applies ONLY to host-arch packages, not BUILD-arch +packages → no `happy-lib`/`alex` pollution. To validate, rebuild stage3 and +re-package the bindist. Order of hours; not done in this session. + +**Phase 6 deliverable for v0.0.1** (this session): a trivial reactor template +(no miso) proving the full toolchain works end-to-end. Miso integration is +queued as Phase 6.5, contingent on the stage3 shared rebuild. + +### Conceptual insight worth promoting + +The agent's `cabal.project` uses: +``` +if arch(wasm32) + shared: True +``` +*without* a `package *` qualifier. This is the **R7 resolution path (i)** — +it relies on stable-haskell/cabal's Stage/Toolchain split: +- BUILD packages are evaluated against the BUILD architecture (native), + so `arch(wasm32)` is FALSE → `shared: True` does NOT apply → no `-dynamic-too` + on `happy-lib`/etc. +- HOST packages are evaluated against the HOST architecture (wasm32), + so `arch(wasm32)` is TRUE → `shared: True` applies → dyld.mjs can load + them for TH. + +This **only works with stable-haskell/cabal** (or a future cabal that +implements the same split). Stock cabal has only one arch context per build, +so the same form would still pollute BUILD packages. + +**Action:** when ready, replicate this pattern in +`cabal.project.stage3` (currently lines 184-186 have the `package *` form +commented out as the bug-prone alternative). With stable-haskell/cabal as +the bootstrap (which is what we use), we may be able to re-enable +`shared: True` for stage3 itself. Test in Phase 5. + +### Items to verify against real stage3 output + +Per the agent's verification checklist (with empirical results from 2026-05-26): + +1. ✅ `post-link.mjs --input/--output` accepted — confirmed locally; produces a 4915-byte ESM JSFFI glue file. +2. ✅ `hs_start` is exported — `wasm-objdump -x myapp.wasm` shows it alongside `_initialize`, `__ghc_wasm_jsffi_init`, `memory`, and the `rts_*` runtime helpers. +3. ✅ `wasi.initialize(instance)` for reactors — confirmed. But ALSO must call `instance.exports.__ghc_wasm_jsffi_init()` BEFORE `hs_start()`. Without that step, `hs_start()` throws `"newBoundTask: RTS is not initialised; call hs_init() first"`. The drafted `index.js` has been updated to include this step. +4. ✅ Knot-tying via `Object.assign(__exports, instance.exports)` after `WebAssembly.instantiate` and before `wasi.initialize` works. +5. ⏳ `JSaddle.Wasm.run` correctness — to verify when adding miso; trivial reactor (no miso) is the current empirical test point. +6. ⏳ `aeson` `-ordered-keymap` flag — to verify with miso layered in. +7. ✅ `with-build-compiler` is honored from `cabal.project` — confirmed with stable-haskell/cabal at SHA `44817477`. Build profile reports `-w ghc-9.14 -W ghc-9.8.4`. Cabal source: `cabal-install/src/Distribution/Client/ProjectConfig/FieldGrammar.hs:123`. +8. ✅ Reactor exports include `__ghc_wasm_jsffi_init` — confirmed via `wasm-objdump`; this is the RTS-init function that the launcher MUST call after `wasi.initialize()`. + +### File inventory + +- `app/Main.hs` — Counter app, dual-mode (wasm + native dev). HAS BUG (#1). +- `myapp.cabal` — cabal file with wasm reactor flags. OK. +- `cabal.project` — dual-compiler form. OK; uses correct R7-resolving pattern. +- `Makefile` — self-documenting; targets help/build/link/serve/clean. Minor cleanup (#2). +- `public/index.html` — minimal. OK. +- `public/index.js` — browser launcher. Pin version (#4). +- `README.md` — install + build guide. URL needs updating (#3). diff --git a/lode/phase6-miso-template-draft/app/Main.hs b/lode/phase6-miso-template-draft/app/Main.hs new file mode 100644 index 000000000000..473d343a4b2d --- /dev/null +++ b/lode/phase6-miso-template-draft/app/Main.hs @@ -0,0 +1,65 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +module Main (main) where + +import Miso +import Miso.String (ms) + +#ifdef WASM +-- FIXME: agent draft had `as JSaddle.Wasm` (dotted alias — invalid Haskell). +-- Use a single-conid alias or import unqualified. +import qualified Language.Javascript.JSaddle.Wasm as JSW +import GHC.Wasm.Prim () -- brings JSString instances into scope +#else +import qualified Language.Javascript.JSaddle.Warp as Warp +#endif + +-- | Model: a single counter. +type Model = Int + +-- | Actions: increment / decrement. +data Action = AddOne | SubOne | NoOp deriving (Eq, Show) + +-- | Pure update. +updateModel :: Action -> Model -> Effect Action Model +updateModel AddOne m = noEff (m + 1) +updateModel SubOne m = noEff (m - 1) +updateModel NoOp m = noEff m + +-- | Render. +viewModel :: Model -> View Action +viewModel n = + div_ [] + [ h1_ [] [text "stable-haskell miso wasm counter"] + , button_ [ onClick SubOne ] [ text "-" ] + , span_ [] [ text (ms (show n)) ] + , button_ [ onClick AddOne ] [ text "+" ] + ] + +-- | Shared app spec. +app :: App Model Action +app = App + { initialAction = NoOp + , model = 0 + , update = updateModel + , view = viewModel + , events = defaultEvents + , subs = [] + , mountPoint = Nothing + , logLevel = Off + } + +#ifdef WASM +-- Wasm reactor entry point. The C function symbol must match the +-- linker --export=hs_start flag in myapp.cabal. +foreign export javascript "hs_start" main :: IO () + +main :: IO () +main = JSW.run (startApp app) +#else +-- Native dev mode: serve via jsaddle-warp on :8080 so you can open the +-- app in a browser without the wasm toolchain (useful for fast iteration). +main :: IO () +main = Warp.run 8080 (startApp app) +#endif diff --git a/lode/phase6-miso-template-draft/cabal.project b/lode/phase6-miso-template-draft/cabal.project new file mode 100644 index 000000000000..a1560ffb0076 --- /dev/null +++ b/lode/phase6-miso-template-draft/cabal.project @@ -0,0 +1,35 @@ +packages: . + +-- Pinned for reproducibility; bump as needed. +index-state: 2026-04-01T00:00:00Z + +-- Dual-compiler setup, supported by stable-haskell/cabal: +-- with-build-compiler -> native GHC, builds Setup.hs, custom-setup, and +-- BUILD-tool dependencies (alex, happy, c2hs, ...). +-- with-compiler -> the target (wasm32-wasi-ghc) used for HOST code. +-- Resolved against $PATH; you can also use absolute ~/.ghcup/bin paths. +with-build-compiler: ghc +with-compiler: wasm32-wasi-ghc + +-- ghcup ships matching wrappers next to the compiler. +with-hc-pkg: wasm32-wasi-ghc-pkg +with-hsc2hs: wasm32-wasi-hsc2hs + +-- R7 resolution path (i): `shared: True` under `if arch(wasm32)` (NOT +-- `package *`) relies on stable-haskell/cabal's Stage/Toolchain split — +-- BUILD packages see arch != wasm32, so this doesn't pollute happy-lib etc. +-- Will not work with stock cabal. +if arch(wasm32) + shared: True + +-- miso 1.11+ has first-class wasm support but the wasm-ready cut isn't +-- on Hackage yet; pull from upstream. +source-repository-package + type: git + location: https://github.com/dmjio/miso.git + tag: 1.11 + +-- aeson's ordered-keymap drags in template-haskell at build time in +-- a way that's awkward for wasm cross. Disabled defensively. +package aeson + flags: -ordered-keymap diff --git a/lode/phase6-miso-template-draft/myapp.cabal b/lode/phase6-miso-template-draft/myapp.cabal new file mode 100644 index 000000000000..70fbc8b331e3 --- /dev/null +++ b/lode/phase6-miso-template-draft/myapp.cabal @@ -0,0 +1,31 @@ +cabal-version: 3.0 +name: myapp +version: 0.1.0.0 +synopsis: Minimal miso counter built with stable-haskell wasm cross-GHC +license: Apache-2.0 +author: Moritz Angermann +maintainer: moritz.angermann@iohk.io +build-type: Simple + +executable myapp + main-is: Main.hs + hs-source-dirs: app + default-language: GHC2021 + default-extensions: OverloadedStrings RecordWildCards LambdaCase + ghc-options: -Wall -Wno-name-shadowing + build-depends: + , base >= 4.18 && < 5 + , miso >= 1.11 && < 2 + + if arch(wasm32) + -- Reactor pattern: no Haskell main, export hs_start instead. + -- See GHC user's guide §15. Quotes around -optl-Wl,... are required + -- so cabal preserves the comma during argv flattening. + ghc-options: + -no-hs-main + -optl-mexec-model=reactor + "-optl-Wl,--export=hs_start" + cpp-options: -DWASM + build-depends: jsaddle-wasm + else + build-depends: jsaddle-warp diff --git a/lode/phase6-miso-template-draft/public/index.html b/lode/phase6-miso-template-draft/public/index.html new file mode 100644 index 000000000000..e868fee0ff1d --- /dev/null +++ b/lode/phase6-miso-template-draft/public/index.html @@ -0,0 +1,11 @@ + + + + + + miso wasm counter + + + + + diff --git a/lode/phase6-miso-template-draft/public/index.js b/lode/phase6-miso-template-draft/public/index.js new file mode 100644 index 000000000000..6252f8238190 --- /dev/null +++ b/lode/phase6-miso-template-draft/public/index.js @@ -0,0 +1,50 @@ +// Browser launcher for a GHC-wasm reactor module. +// Validated against haskell-wasm/ghc-wasm-miso-examples/frontend/index.js +// and the GHC user's guide §15 knot-tying pattern. +// +// FIXME: pin version to match what our RTS expects. Per project memory, +// commit e0837350f14 bumped browser_wasi_shim to 0.4.2 on the GHC side. +// Verify the launcher version matches the WASI imports the wasm module declares. +import { + WASI, + OpenFile, + File, + ConsoleStdout, +} from "https://esm.sh/@bjorn3/browser_wasi_shim@0.4.2"; + +// post-link.mjs writes a module whose *default export* is a function +// that, given the (eventually-populated) exports object, returns the +// imports object for the `ghc_wasm_jsffi` namespace. We pre-bind it +// against an empty record and assign exports after instantiation - +// this is the standard knot-tying trick from the GHC user's guide. +import ghc_wasm_jsffi from "./ghc_wasm_jsffi.js"; + +const args = []; // argv[0] is unused for reactors +const env = ["GHCRTS=-H64m"]; // small initial heap; tune as needed +const fds = [ + new OpenFile(new File([])), // 0: stdin + ConsoleStdout.lineBuffered((m) => console.log (`[hs stdout] ${m}`)), // 1 + ConsoleStdout.lineBuffered((m) => console.warn(`[hs stderr] ${m}`)), // 2 +]; +const wasi = new WASI(args, env, fds, { debug: false }); + +const instance_exports = {}; +const { instance } = await WebAssembly.instantiateStreaming( + fetch("./myapp.wasm"), + { + wasi_snapshot_preview1: wasi.wasiImport, + ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports), + }, +); +// Tie the knot: now jsffi callbacks can reach back into the wasm module. +Object.assign(instance_exports, instance.exports); + +// Reactor module bring-up sequence — order matters: +// 1. wasi.initialize() runs WASI static constructors (_initialize export). +// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS. +// Without this, hs_start() throws "RTS is not initialised; call hs_init()". +// Empirically verified 2026-05-26. +// 3. hs_start() runs the Haskell entry point (whatever you `foreign export`ed). +wasi.initialize(instance); +instance.exports.__ghc_wasm_jsffi_init(); +await instance.exports.hs_start(); diff --git a/lode/phase6-trivial-reactor-poc/README.md b/lode/phase6-trivial-reactor-poc/README.md new file mode 100644 index 000000000000..1e71f3cfd3c4 --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/README.md @@ -0,0 +1,56 @@ +# Phase 6 v0.0.1 — Trivial wasm reactor (proven working) + +> **Status**: empirically verified end-to-end 2026-05-26. +> **Purpose**: smallest possible artifact demonstrating dual-compiler cabal + +> wasm cross GHC + JSFFI runtime work together. NO miso (see "Why no miso"). + +## What this proves + +- **stable-haskell/cabal** at SHA `44817477` honors `with-build-compiler` and + `with-compiler` in `cabal.project` — one cabal invocation drives two GHCs. +- Native GHC 9.8.4 (devx `ghc98-minimal-ghc`) compiles `Setup.hs` and any + build-stage tools; wasm32-unknown-wasi-ghc compiles the host-stage code. +- `post-link.mjs` (from `lib/targets/wasm32-unknown-wasi/lib/`) emits a working + JSFFI ESM glue module. +- The runtime invocation sequence is: `wasi.initialize()` → call + `__ghc_wasm_jsffi_init()` → call `hs_start()`. **The middle step is + critical** — skipping it yields `"newBoundTask: RTS is not initialised; + call hs_init() first"`. + +## Why no miso + +Miso (and most TH-using packages) need `shared: True` for the wasm target +build, which transitively requires `base.dyn_hi` and friends. The stage3 wasm +target libraries shipped in this repo's current bindist are built with +`shared: False` (per `cabal.project.stage3:184-186`, where the +`if os(wasi) / package * / shared: True` block is commented out due to the +build-package pollution bug). Fix: replace with `if arch(wasm32) shared: True` +(no `package *`) — stable-haskell/cabal's Stage/Toolchain split makes this safe. +Rebuild stage3 + re-package bindist is required; not done in this session. + +Once that lands, the `lode/phase6-miso-template-draft/` files can be promoted. + +## Files + +- `app/Main.hs` — reactor exporting `hs_start`, prints a greeting +- `myapp.cabal` — cabal file with wasm reactor flags (`-no-hs-main`, + `-optl-mexec-model=reactor`, `--export=hs_start`) +- `cabal.project` — dual-compiler form (template; replace absolute paths with + bare names when shipped on ghcup) +- `run.mjs` — Node.js launcher that proves end-to-end execution via `node:wasi` +- `public/index.html` + `public/index.js` — browser launcher template using + `@bjorn3/browser_wasi_shim` (same invocation pattern as `run.mjs`) + +## Verified locally + +```sh +$ cd lode/phase6-trivial-reactor-poc +$ nix develop /Users/angerman/Projects/stable-haskell/ghc -c \ + /Users/angerman/Projects/stable-haskell/ghc/_build/cabal/bin/cabal build myapp +# … Build profile: -w ghc-9.14 -W ghc-9.8.4 -O1 +# … Completed myapp-0.1.0.0 (exe:myapp) +$ WASM=dist-newstyle/store/host/wasm32-unknown-wasi/bin/myapp.wasm +$ node $(ghc --print-libdir)/post-link.mjs -i $WASM -o jsffi.mjs +$ nix-shell -p nodejs_22 --run "node run.mjs" +Hello from the WASM reactor! +``` diff --git a/lode/phase6-trivial-reactor-poc/app/Main.hs b/lode/phase6-trivial-reactor-poc/app/Main.hs new file mode 100644 index 000000000000..1ea94463929e --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/app/Main.hs @@ -0,0 +1,12 @@ +{-# LANGUAGE CPP #-} +module Main where + +#ifdef WASM +-- Reactor pattern: GHC will not synthesize a _start entry point because the +-- cabal file uses -no-hs-main + -optl-mexec-model=reactor. We export +-- hs_start (the function the linker --exports) which the JS host calls. +foreign export javascript "hs_start" main :: IO () +#endif + +main :: IO () +main = putStrLn "Hello from the WASM reactor!" diff --git a/lode/phase6-trivial-reactor-poc/cabal.project b/lode/phase6-trivial-reactor-poc/cabal.project new file mode 100644 index 000000000000..c7539718069c --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/cabal.project @@ -0,0 +1,16 @@ +packages: . + +-- Dual-compiler form, supported by stable-haskell/cabal (Stage/Toolchain +-- architecture, SHA 44817477). One cabal invocation drives both GHCs: +-- BUILD side (host machine arch): compiles Setup.hs, build-tools, TH host +-- HOST side (target arch wasm32): compiles the package's library/exe code +-- +-- When shipped on ghcup, replace these absolute paths with bare names: +-- with-build-compiler: ghc (any ghcup-installed native GHC) +-- with-build-hc-pkg: ghc-pkg +-- with-compiler: wasm32-unknown-wasi-ghc +-- with-hc-pkg: wasm32-unknown-wasi-ghc-pkg +with-build-compiler: /nix/store/8s4nvbqdqmd1jbmrj8di07hp0nn1lwi5-ghc-9.8.4/bin/ghc-9.8.4 +with-build-hc-pkg: /nix/store/8s4nvbqdqmd1jbmrj8di07hp0nn1lwi5-ghc-9.8.4/bin/ghc-pkg-9.8.4 +with-compiler: /Users/angerman/Projects/stable-haskell/ghc/_build/dist/bin/wasm32-unknown-wasi-ghc +with-hc-pkg: /Users/angerman/Projects/stable-haskell/ghc/_build/dist/bin/wasm32-unknown-wasi-ghc-pkg diff --git a/lode/phase6-trivial-reactor-poc/myapp.cabal b/lode/phase6-trivial-reactor-poc/myapp.cabal new file mode 100644 index 000000000000..0723a0033db5 --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/myapp.cabal @@ -0,0 +1,26 @@ +cabal-version: 3.0 +name: myapp +version: 0.1.0.0 +synopsis: Trivial wasm reactor demonstrating stable-haskell wasm cross + dual-compiler cabal +license: Apache-2.0 +author: Moritz Angermann +maintainer: moritz.angermann@iohk.io +build-type: Simple + +executable myapp + main-is: Main.hs + hs-source-dirs: app + default-language: GHC2021 + build-depends: base >= 4.18 && < 5 + ghc-options: -Wall + + if arch(wasm32) + -- Reactor pattern, per GHC user's guide §15: + -- -no-hs-main: don't auto-export Haskell's main as _start + -- -optl-mexec-model=reactor: WASI reactor (long-lived) module + -- -optl-Wl,--export=hs_start: linker exports our hs_start FFI function + ghc-options: + -no-hs-main + -optl-mexec-model=reactor + "-optl-Wl,--export=hs_start" + cpp-options: -DWASM diff --git a/lode/phase6-trivial-reactor-poc/public/index.html b/lode/phase6-trivial-reactor-poc/public/index.html new file mode 100644 index 000000000000..931685ae2500 --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/public/index.html @@ -0,0 +1,11 @@ + + + + + + Stable Haskell wasm reactor + + + + + diff --git a/lode/phase6-trivial-reactor-poc/public/index.js b/lode/phase6-trivial-reactor-poc/public/index.js new file mode 100644 index 000000000000..a1a84fcc7f54 --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/public/index.js @@ -0,0 +1,44 @@ +// Browser launcher for a GHC-wasm reactor module. +// +// Pairs with `run.mjs` (Node.js variant). Same invocation pattern; only the +// WASI provider differs — browsers use browser_wasi_shim, Node uses +// the built-in node:wasi. +// +// Empirically verified 2026-05-26 (the Node variant); browser variant is +// the same pattern with browser_wasi_shim swapped in for node:wasi. +import { + WASI, + OpenFile, + File, + ConsoleStdout, +} from "https://esm.sh/@bjorn3/browser_wasi_shim@0.4.2"; + +import ghc_wasm_jsffi from "./jsffi.mjs"; + +const args = ["myapp.wasm"]; +const env = ["GHCRTS=-H64m"]; +const fds = [ + new OpenFile(new File([])), + ConsoleStdout.lineBuffered((m) => console.log (`[hs stdout] ${m}`)), + ConsoleStdout.lineBuffered((m) => console.warn(`[hs stderr] ${m}`)), +]; +const wasi = new WASI(args, env, fds, { debug: false }); + +const __exports = {}; +const { instance } = await WebAssembly.instantiateStreaming( + fetch("./myapp.wasm"), + { + wasi_snapshot_preview1: wasi.wasiImport, + ghc_wasm_jsffi: ghc_wasm_jsffi(__exports), + }, +); +Object.assign(__exports, instance.exports); + +// Reactor bring-up sequence — order matters: +// 1. wasi.initialize() runs WASI static constructors (_initialize export). +// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS. +// Without this, hs_start() throws "RTS is not initialised; call hs_init()". +// 3. hs_start() runs the Haskell entry point we foreign-exported. +wasi.initialize(instance); +instance.exports.__ghc_wasm_jsffi_init(); +await instance.exports.hs_start(); diff --git a/lode/phase6-trivial-reactor-poc/run.mjs b/lode/phase6-trivial-reactor-poc/run.mjs new file mode 100644 index 000000000000..5e677620a108 --- /dev/null +++ b/lode/phase6-trivial-reactor-poc/run.mjs @@ -0,0 +1,32 @@ +// run.mjs — Node.js launcher for a GHC-wasm reactor module. +// +// Invoke as: node run.mjs +// Assumes: ./myapp.wasm exists, ./jsffi.mjs is post-link.mjs output for it. +// +// Empirically verified 2026-05-26 against stable-haskell/ghc 9.14 wasm cross. +import { readFile } from "node:fs/promises"; +import { WASI } from "node:wasi"; + +const mod = await WebAssembly.compile(await readFile("./myapp.wasm")); +const jsffi = (await import("./jsffi.mjs")).default; + +const wasi = new WASI({ version: "preview1", args: ["myapp.wasm"] }); + +// Knot-tying: __exports starts empty; jsffi closures capture it by +// reference; after instantiate we Object.assign instance.exports into it, +// so any later jsffi call can reach back into the wasm module. +const __exports = {}; +const instance = await WebAssembly.instantiate(mod, { + ghc_wasm_jsffi: jsffi(__exports), + wasi_snapshot_preview1: wasi.wasiImport, +}); +Object.assign(__exports, instance.exports); + +// Reactor bring-up sequence — order matters: +// 1. wasi.initialize() runs WASI static constructors (_initialize export). +// 2. __ghc_wasm_jsffi_init() initializes GHC's JSFFI runtime + the RTS. +// Without this, hs_start() throws "RTS is not initialised; call hs_init()". +// 3. hs_start() runs the Haskell entry point we foreign-exported. +wasi.initialize(instance); +instance.exports.__ghc_wasm_jsffi_init(); +await instance.exports.hs_start(); 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. diff --git a/lode/wasm-cross-ghcup-plan.md b/lode/wasm-cross-ghcup-plan.md new file mode 100644 index 000000000000..4ef1f93d59b0 --- /dev/null +++ b/lode/wasm-cross-ghcup-plan.md @@ -0,0 +1,839 @@ +# Stable-Haskell WASM Cross-Compiler via ghcup — Plan + +> **Status:** Active. Branch: `feat/wasm-cross-ghcup` off `stable-ghc-9.14`. +> **Owner:** angerman. +> **Last updated:** 2026-05-26. + +This is the living source-of-truth for the initiative. Update the **Status Log** at +the bottom as phases progress. Keep this doc tight — point at code/PRs rather than +duplicating their content. + +--- + +## 1. Purpose + +End-state: a Haskell developer can run + +```bash +ghcup config add-release-channel +ghcup install ghc wasm32-wasi-9.14.0.stable -- $CONFIGURE_ARGS +ghcup install cabal stable-3.17.0.1 +git clone https://github.com/stable-haskell/miso-wasm-template && cd $_ +make && make serve # opens a working miso app in the browser +``` + +…using **our** stack: stable-haskell GHC (245+ patches over upstream 9.14), +stable-haskell/cabal with dual-compiler ("compile-less") support, and a thin +ghcup channel that reuses upstream `ghc-wasm-meta` for the host-side +wasi-sdk/node/wasmtime pieces. + +## 2. Why us, not upstream `ghc-wasm-meta` + +| Capability | Upstream `ghc-wasm-meta` | This initiative | +|---|---|---| +| GHC bindist | upstream (master / RCs) | **stable-haskell/ghc** (245-patch fork, stable-9.14 line) | +| Cabal | stock 3.14.x with `wasm32-wasi-cabal` wrapper + injected config | **stable-haskell/cabal** 3.17.0.1 with real dual-compiler (`with-compiler` / `with-build-compiler`) + compile-less store + file-monitor recompilation avoidance | +| wasi-sdk / node / wasmtime / binaryen | bundled via `bootstrap.sh` | **reuse upstream's `bootstrap.sh`** with `SKIP_GHC=1` (no fork) | +| ghcup channel | `haskell-wasm/ghc-wasm-meta` channel | new `stable-haskell/ghc-wasm-meta` channel, distinct version suffix | +| Custom-Setup packages with cross | not handled (single cabal, single compiler) | works (Setup.hs compiled by build-compiler, target code by host-compiler) | + +## 3. Current State Assessment (verified 2026-05-26) + +### What's already on `stable-ghc-9.14` + +- 245+ WASM patches: RTS WASI guards, JSFFI init ctors, NCG `.functype`/`.size`, + on-demand `GlobalRegs`, libffi-clib exclusion for WASI, dyld handling, browser_wasi_shim + bump, etc. +- `cabal.project.stage3` with `if os(wasi) / package * / shared: True` (the + build-package pollution bug is **fixed**: the override is correctly nested under `os(wasi)`). +- Makefile `STAGE3_PLATFORMS := … wasm32-unknown-wasi` with full toolchain wiring + (CC/CXX/AR/RANLIB + GHC_TOOLCHAIN_ARGS for `--merge-objs wasm-ld`, + `--disable-tables-next-to-code`, `--disable-libffi-adjustors`). +- Stage3 target → `_build/dist/ghc-wasm32-unknown-wasi.tar.gz` plus + JS runtime shims (`dyld.mjs`, `ghc-interp.js`, `post-link.mjs`, `prelude.mjs`). +- `feat/wasm-fixes` adds **zero** genuinely new content (its commits are already + in `stable-ghc-9.14` by patch-id). Ignore that branch. + +### What's NOT on `stable-ghc-9.14` (Phase 0 absorbs these) + +- **`build/wasm-nix-environment`** (6 commits): `flake.nix` pulling `wasi-sdk` + from upstream `ghc-wasm-meta` flake, env vars `WASM_EXTRA_{LIB,INCLUDE}_DIRS`, + USAGE docs. +- **`feat/nix-ci-split`** (6 commits): `Makefile` cross-build refactor (dist-based + configuration), `.github/workflows/nix-ci.yml` split into build/test/cross jobs, + RTS fix for undefined symbols referenced only by `R_*_NONE` relocations, plus + build-infra fixes (stamp-based deps, PHONY ordering, race condition). + +### What doesn't exist anywhere yet + +- ghcup-installable bindist shape (current tarball has no `./configure && make install`). +- Published `stable-haskell/ghc-wasm-meta` ghcup channel. +- stable-haskell/cabal binary releases for ghcup. +- Miso template proving the dual-compiler workflow. +- Browser launcher template (neither miso-sampler nor upstream `ghc-wasm-meta` + commits one — biggest documentation gap in the ecosystem). + +## 4. Decisions made + +| # | Decision | Rationale | +|---|---|---| +| D1 | Working branch: `feat/wasm-cross-ghcup` off `stable-ghc-9.14`. | Don't pollute `stable-ghc-9.14` until Phase 2 is CI-green. | +| D2 | Rename triple `wasm32-unknown-wasi` → `wasm32-wasi`. | Match ecosystem convention (`wasm32-wasi-ghc-…`, ghcup-cross YAML, miso-sampler). Mechanical Makefile change; `ghc-toolchain` accepts both. | +| D3 | Phase-3 bindist shape: **relocatable tarball + `relocate.sh`**, ghcup `viPostInstall` invokes it. | Ships fast; can migrate to full configure-bindist (`./configure --prefix && make install`) later if user feedback demands. | +| D4 | Reuse upstream `ghc-wasm-meta` `bootstrap.sh` with `SKIP_GHC=1` for wasi-sdk/node/wasmtime/binaryen. | These pieces aren't our value-add. Tracking upstream wasi-sdk updates is a tax we don't want. | +| D5 | Do not ship a native stable-haskell GHC bindist for build-compiler use. | Any user-installed GHC (ghcup 9.10.x / 9.12.x) suffices. Revisit if "full stable stack" becomes a goal. | +| D6 | Channel hosting: GitHub raw — `raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm.yaml`. Bindists as release assets in `stable-haskell/ghc-wasm-bindists`. | Simple, no GH Pages needed, standard pattern. | +| D7 | Version suffix on ghcup entries: `9.14.0.stable.YYYYMMDD`. | Avoid collision with upstream `wasm32-wasi-9.14`; allow multiple stable releases. | + +## 5. Open decisions / risks + +- **R1: stable-haskell/cabal 3.17 vs the wasm-known cabal 3.16 regression.** + Upstream `ghc-wasm-meta` README pins cabal 3.14.x. Our 3.17 tracks unreleased + master + Andrea's patches. Must be verified by running the miso e2e (Phase 6) + before claiming it works. +- **R2: aarch64-darwin CI 41GB APFS budget.** Stage3 adds ~3-5GB; may need + `make clean-stage{0,1,2}` between stage2 and stage3 to free space. Determine + empirically in Phase 2. +- **R3: ghc-toolchain triple acceptance.** D2 assumes `wasm32-wasi` works; verify + in Phase 1 by running `ghc-toolchain --triple wasm32-wasi …` and inspecting + the generated `settings` file. +- **R4: `build-type: Custom` package compatibility.** Andrea's dual-compiler + patches are explicitly tested only against GHC's own stage1/stage2. Real-world + miso transitive deps with `Custom`/`Configure` build-types may surface bugs. + Phase 5 gate covers one Custom-build-type package as a smoke test; expect + follow-up bug reports. +- **R5: Path-baking in `settings`.** wasi-sdk path is hardcoded into the GHC + `settings` file after install. If a user moves/upgrades `~/.ghc-wasm/wasi-sdk`, + installed GHC breaks. `relocate.sh` (D3) must rewrite these paths; `viPostInstall` + must invoke it. +- **R6: Custom channel + upstream `cross` channel collision.** If a user has + both channels enabled, the last-added wins for any colliding version key. + D7 mitigates this. +- **R10: stable-haskell/cabal store layout doesn't match Makefile expectations.** + Discovered 2026-05-26 after R9 workaround unblocked the build past unix-2.8.8.0. + Cabal compile-less branch builds executables to `_build/stage1/bin/` and + libraries to `_build/stage1/store/ghc-9.8.4-inplace/-/`. The Makefile + (lines 610, 617, and many others) expects `$(STORE_DIR)/host/$(HOST_PLATFORM)/bin/` + = `_build/stage1/store/host/aarch64-apple-darwin/bin/` — a path that simply + doesn't exist with this cabal. Stage1 build fails at the `ghc-toolchain-bin` + invocation step with `bash: ...: No such file or directory`. **Both at HEAD + AND at SHA `44817477…`** (we tested the pin separately) — meaning the layout + divergence is somewhere in the compile-less branch's foundational store + rework, not a recent regression. + **This blocks Phase 1 entirely.** Possible resolutions: (a) update Makefile + to look in `_build/stage1/bin/` and `store/ghc-9.8.4-inplace/` (probably + 20-40 line changes — significant, needs cross-platform care for the GHC + toolchain triple); (b) find an even older stable-haskell/Cabal SHA that + produced the `store/host/HOST/` layout the Makefile expects; (c) ask the + user if there's a known-working combination they've used recently + (the previous successful build of `_build/stage1` we saw earlier must + have used a compatible cabal — what SHA was that?). + **ESCALATING TO USER** — this is bigger than the wasm-cross-ghcup + initiative scope. Resolution choice affects the whole project's + buildability, not just our branch. +- **R9: stable-haskell/cabal HEAD races on store install locks across + stages.** Discovered 2026-05-26 during Phase 1 stage2 retry: the + compile-less Stage/Toolchain split means cabal builds the same package + (`unix`, `happy-lib`, `os-string`, `filepath`, …) for both Build and Host + stages. When the install steps race, one process holds the per-package + lock in `store/.../incoming/*.lock` while another tries to acquire it + → `openFile: resource busy`. happy-lib was observed with 12 attempts. + Andrea's earlier "TOCTOU race fix for BuildInplaceOnly tarball extraction" + doesn't cover this. **Workarounds in priority order:** (i) retry the + build (sometimes transient if the artifact lands on the first attempt); + (ii) limit cabal parallelism via `--jobs=1` or `cabal-install` config; + (iii) pin `tag:` in `cabal.project.stage{0,1,2,3}` back to + `44817477ff6d…` (pre-rebase, last known-working). **Fix upstream** in + `stable-haskell/cabal#stable-haskell/master` — the store install needs + to recognize that two stages building the same package for the same arch + produce identical artifacts and short-circuit. **Same fix-priority class + as R8** (both block Phase 5). +- **R8: stable-haskell/cabal HEAD doesn't bootstrap with stock GHC 9.8.4.** + Discovered 2026-05-26 during Phase 1 stage2 build attempt. `make stable-cabal` + fails: `cabal-install/src/Distribution/Client/ProjectPlanning.hs:224` imports + `Distribution.Simple.GHCJS` which is hidden in `Cabal-3.10.3.0` (the bundled + Cabal library in bootstrap GHC 9.8.4). Either: (a) the GHCJS removal in the + compile-less branch is incomplete (still references the removed module), or + (b) the cabal-install build-depends accidentally allows linking against + the bundled Cabal instead of the source-repository-package version. + **Blocks Phase 5** (cabal binary shipping). Workaround for Phase 1: set + `USE_SYSTEM_CABAL=1` to skip the stable-cabal target — uses the existing + `_build/stage0/bin/cabal` (cabal-install 3.17.0.0, one snapshot older from + pre-rebase tag). This is acceptable for Phase 1/2 (we're building GHC, not + shipping cabal); for Phase 5 we must either fix the import or pin Cabal + source-repo properly. File the fix upstream against + `stable-haskell/cabal#stable-haskell/master`. +- **R7: TH/GHCi on wasm needs `shared: True` for target libs, but stock + cabal `package *` clauses pollute BUILD packages too.** Discovered 2026-05-26 + inspecting `cabal.project.stage3` lines 175-186 — the `if os(wasi) / package * + / shared: True` block is **commented out** because it caused `-dynamic-too` + on `happy-lib`/etc. compiled by the native build compiler, which fails when + the dist artifact lacks `.dyn_hi` files. Consequences: + (a) Phase 1 TH smoke-test gate currently CANNOT pass — the stage3 base + libraries are built `shared: False`, so dyld.mjs at runtime has nothing to load. + (b) Phase 6 miso e2e similarly blocked (miso uses TH heavily). + Resolutions in priority order: **(i)** spell out per-package `shared: True` + for the wasm target libraries individually (avoiding `package *`); **(ii)** wait + for stable-haskell/cabal to gain Host-only `package *` clauses (extension of + the Stage/Toolchain work — not in `stable-haskell/master` HEAD as of + 2026-05-26); **(iii)** ship without TH for v0.0.1, document, fix in v0.0.2. + Decision pending Phase 1 attempt; (i) is the safest path forward. + +## 6. Phases & gates + +Each phase has **one** clear pass/fail gate. Don't advance until the prior gate is met. + +### Phase 0a — Branch + planning docs (IN PROGRESS) +- Create `feat/wasm-cross-ghcup` off `stable-ghc-9.14`. +- Write `lode/wasm-cross-ghcup-plan.md` (this file). +- Update project `CLAUDE.md` with a "Current Initiative" section linking here. +- Commit. +- **Gate:** branch checked out; one commit on top of `stable-ghc-9.14`; planning + docs reviewable by anyone fresh. + +### Phase 0 — Consolidate WASM feature branches +- Cherry-pick 6 commits from `build/wasm-nix-environment` (flake.nix + wasi-sdk). +- Cherry-pick 6 commits from `feat/nix-ci-split` (Makefile cross refactor + CI split + RTS R_*_NONE fix + build-infra fixes). +- Resolve conflicts. Skip `feat/wasm-fixes` (already merged by patch-id). +- Note: flake exposes single `devShells.default` (no `wasm-cross` attr). The shell creates wrappers in `.nix-wasm-bin/` for `wasm32-wasi-{clang,clang++,ar,ranlib}`, `wasm-ld`, `llc`/`opt`/`llvm-as` (LLVM 21). `wasm-opt` (binaryen) is NOT provided — not strictly needed for compiler bring-up. +- **Gate:** `nix develop --command bash -c 'which wasm32-wasi-clang wasm-ld llc && llc --version | head -2'` succeeds and prints LLVM 21. + +### Phase 1 — Local wasm cross build green +- Apply D2 (rename triple `wasm32-unknown-wasi` → `wasm32-wasi`); regenerate Makefile var refs. +- In devShell: `make CABAL=$PWD/_build/stage0/bin/cabal stage3-wasm32-wasi`. +- Smoke-test `_build/dist/bin/wasm32-wasi-ghc`: + - Compile a one-file `hello :: IO ()`; run produced `.wasm` via `wasmtime` AND node + `browser_wasi_shim`. +- Attempt R7 resolution (i): add per-package `shared: True` for the wasm target libs (base, ghc-internal, ghc-bignum, integer-gmp, etc. — *not* `package *`). Rebuild stage3 — verify happy-lib/alex still build cleanly. +- If (i) works: compile a tiny TH-using package, run via Node iserv. +- **Gate:** non-TH hello-world runs in both `wasmtime` AND node+browser_wasi_shim. **TH gate moved to "stretch"**: if R7(i) succeeds → TH-using package runs; else log R7 as known limitation for v0.0.1 and defer to Phase 5/6. + +### Phase 2 — CI wasm cross build green (both host platforms) +- Add `wasm-cross-{x86_64-linux, aarch64-darwin}` jobs to `.github/workflows/nix-ci.yml`. +- Cache wasi-sdk via nix-store. Mind aarch64-darwin 41GB APFS (R2). +- Each job: nix-develop wasm-cross → stage0/1/2 → stage3-wasm32-wasi → Phase-1 smoke test. +- **Gate:** PR's wasm-cross jobs green on both runners; total runtime <90 min per host. + +### Phase 3 — Ghcup-compatible wasm bindist +**Scope shrunk (2026-05-26)**: investigation of `Makefile:409-450` shows +`DIST_COPY_LIB_CONF_CROSS` already rewrites absolute paths in `*.conf` files +to `${pkgroot}/../lib/wasm32-wasi/…` and `ghc-pkg recache` is run for the +cross-target package db at build time (line 1018). The settings file uses +**bare** tool names (`wasm32-wasi-clang`/`wasm-ld`/etc.) so no path baking +there either. Therefore bindists are *already* relocatable; `relocate.sh` +shrinks to a one-liner that re-runs `ghc-pkg recache` for the new install +location (the cache is binary and not portable). + +- Repackage `_build/dist/ghc-wasm32-wasi.tar.gz` (Makefile:1029) into + `wasm32-wasi-ghc-9.14.0-.tar.xz`: + - Keep existing layout: `bin/wasm32-wasi-ghc{,-pkg,-iserv,…}`, `lib/targets/wasm32-wasi/…`. + - Append: `relocate.sh` (10 lines) at top-level. + - Verify JS shims (`dyld.mjs`, `post-link.mjs`, `prelude.mjs`, `ghc-interp.js`) are present in `lib/targets/wasm32-wasi/lib/`. + - Switch compression to `.tar.xz` (smaller than `.tar.gz`; ghcup accepts both). +- Smoke-test: untar to a random `$prefix`, run `$prefix/relocate.sh`, then `$prefix/bin/wasm32-wasi-ghc --info` + compile hello-world. +- **Gate:** tarball untars + relocates + compiles hello-world on a fresh machine without errors. + +### Phase 4 — Ghcup channel published +- Create `stable-haskell/ghc-wasm-meta` repo (or repurpose). +- Add `bootstrap.sh` delegating to upstream `ghc-wasm-meta` `bootstrap.sh` with `SKIP_GHC=1`; writes `~/.ghc-wasm/env` exporting `$CONFIGURE_ARGS`. +- Author `ghcup-stable-wasm-0.0.1.yaml`: one `GHC: wasm32-wasi-9.14.0.stable.YYYYMMDD` entry per `(arch, OS)` (start with `A_64/Linux_UnknownLinux` and `A_ARM64/Darwin`). +- Bindists hosted as release assets at `stable-haskell/ghc-wasm-bindists`. +- `viPostInstall` invokes `relocate.sh`. +- **Gate:** on a clean machine: `ghcup config add-release-channel && ghcup install ghc wasm32-wasi-9.14.0.stable -- $CONFIGURE_ARGS` succeeds; `wasm32-wasi-ghc --version` works. + +### Phase 5 — Ship stable-haskell/cabal binary +- CI job builds static cabal-install 3.17.0.1 from `stable-haskell/cabal#stable-haskell/master` for x86_64-linux and aarch64-darwin. +- Add Cabal entry to ghcup YAML (or sibling channel). +- Verify dual-compiler `cabal.project` form: one `cabal build` uses native ghc for Setup.hs + wasm32-wasi-ghc for target. +- **Gate:** a package with `build-type: Custom` builds successfully via dual-compiler (exercises Andrea's patches, not just `Simple`). + +### Phase 6 — Miso end-to-end demo +- Create `stable-haskell/miso-wasm-template` repo: + - `app/Main.hs` minimal miso app. + - `myapp.cabal` with `if arch(wasm32) / ghc-options: -no-hs-main -optl-mexec-model=reactor "-optl-Wl,--export=hs_start"`. + - `cabal.project` with dual-compiler + `if arch(wasm32) shared: True`. + - `Makefile`: `cabal build` → `post-link.mjs` → `cp` to `public/` → `npx http-server public`. + - `public/index.html` + `public/index.js` browser launcher using `browser_wasi_shim` — **commit it**; close the ecosystem-wide documentation gap. +- Validate on a fresh machine using only our ghcup channel. +- **Gate:** developer goes `git clone` → `make && make serve` → working browser demo in <5 min (post-install). + +### Phase 7 — Documentation & migration +- README on `stable-haskell/ghc-wasm-meta` repo: what we offer vs upstream + `ghc-wasm-meta`, exact install/use commands, dual-compiler `cabal.project` + template, known limitations (no `cabal install miso` Hackage path, no + subprocess-using libs in browser, etc.). +- Migration notes for upstream `ghc-wasm-meta` users. +- Link from main `stable-haskell/ghc` README. +- **Gate:** dev unfamiliar with this work reaches working browser demo from + README alone, no questions asked. + +## 7. References + +- Project root `CLAUDE.md` — multi-stage build system overview. +- `cabal.project.stage3`, `Makefile` (STAGE3_* vars) — current cross wiring. +- `stable-haskell/cabal#stable-haskell/master` (commit `44817477ff6d`) — dual-compiler Cabal. +- `haskell-wasm/ghc-wasm-meta` — upstream wasi-sdk/node/wasmtime installer (to reuse). +- `haskell-wasm/ghc-wasm-bindists` — upstream's bindist hosting pattern. +- `haskell/ghcup-metadata`, `ghcup-cross-0.1.0.yaml` — channel schema reference. +- `tweag/ghc-wasm-miso-examples` — working miso wasm example (config to mirror). +- `haskell-miso/miso-sampler` — official miso starter (Nix flow). +- GHC user's guide §15 — WASM backend (reactor module shape, post-link). +- Tweag blog 2024-11-21 — TH/ghci over wasm via Node iserv. + +## 8. Phase 4 design proposal (draft sketches) + +These are reference templates for when the `stable-haskell/ghc-wasm-meta` repo +is created. Refine against the real bindist once Phase 3 produces one. + +### 8.1 `ghcup-stable-wasm-0.0.1.yaml` skeleton + +```yaml +ghcupDownloads: + GHC: + wasm32-wasi-9.14.0.stable.YYYYMMDD: + viTags: [base-4.21.0.0] + viPreInstall: | + Stable Haskell wasm32-wasi cross-compiler. + + Prerequisites — install the host-side wasm toolchain via upstream + ghc-wasm-meta (we don't ship wasi-sdk / node / wasmtime / binaryen + ourselves): + + curl -sL https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/bootstrap.sh | sh + source ~/.ghc-wasm/env + + Then re-run this install. + viPostInstall: | + # ghcup runs viPostInstall from the install prefix. + # ${pkgroot}-relative *.conf files are portable, but the binary + # ghc-pkg cache is not — regenerate it for the new location. + ./bin/wasm32-wasi-ghc-pkg recache \ + --package-db ./lib/targets/wasm32-wasi/lib/package.conf.d + echo "Stable Haskell wasm32-wasi-ghc ready at: $(pwd)/bin/wasm32-wasi-ghc" + viArch: + A_64: + Linux_UnknownLinux: + unknown_versioning: + dlHash: + dlSubdir: wasm32-wasi-ghc-9.14.0 + dlUri: https://github.com/stable-haskell/ghc-wasm-bindists/releases/download/v9.14.0.stable.YYYYMMDD/wasm32-wasi-ghc-9.14.0-x86_64-linux.tar.xz + dlOutput: wasm32-wasi-ghc-9.14.0-x86_64-linux.tar.xz + A_ARM64: + Darwin: + unknown_versioning: + dlHash: + dlSubdir: wasm32-wasi-ghc-9.14.0 + dlUri: https://github.com/stable-haskell/ghc-wasm-bindists/releases/download/v9.14.0.stable.YYYYMMDD/wasm32-wasi-ghc-9.14.0-aarch64-darwin.tar.xz + dlOutput: wasm32-wasi-ghc-9.14.0-aarch64-darwin.tar.xz + Cabal: + stable-3.17.0.1: + viTags: [Latest] + viPreInstall: | + Stable Haskell cabal-install with real dual-compiler support + (with-build-compiler / with-compiler) for cross-compilation. + viArch: + A_64: + Linux_UnknownLinux: { ... } + A_ARM64: + Darwin: { ... } +``` + +**Open questions to resolve before publishing:** +- Does ghcup invoke `viPostInstall` with `PWD = install prefix`, or some other dir? Confirm via ghcup source or empirically. +- Does ghcup support `Cabal:` entries in custom channels, or only `GHC:`? Test by registering a channel with just `Cabal:` and trying `ghcup list cabal`. +- Should the version suffix be `.stableYYYYMMDD` or `-stable-YYYYMMDD`? Match ghcup's preferred patterns. + +### 8.2 `stable-haskell/ghc-wasm-meta/bootstrap.sh` sketch + +```sh +#!/bin/sh +# Stable Haskell wasm bootstrap — installs everything needed to build wasm +# Haskell apps with stable-haskell/ghc + stable-haskell/cabal. +set -e + +echo "==> Step 1/3: installing host-side wasm toolchain (wasi-sdk, node, wasmtime, binaryen)" +echo " delegating to upstream haskell-wasm/ghc-wasm-meta with SKIP_GHC=1" +curl -sL https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh \ + | SKIP_GHC=1 SKIP_CABAL=1 sh + +if [ -f "$HOME/.ghc-wasm/env" ]; then + echo "==> Step 2/3: source ~/.ghc-wasm/env to get wasi-sdk / node / wasmtime on PATH" + echo " add this to your shell rc file:" + echo " source $HOME/.ghc-wasm/env" +fi + +echo "==> Step 3/3: register stable-haskell ghcup channel" +ghcup config add-release-channel \ + https://raw.githubusercontent.com/stable-haskell/ghc-wasm-meta/master/ghcup-stable-wasm-0.0.1.yaml + +echo +echo "==> Done. Next steps:" +echo " source ~/.ghc-wasm/env" +echo " ghcup install ghc wasm32-wasi-9.14.0.stable.YYYYMMDD" +echo " ghcup install cabal stable-3.17.0.1" +echo " git clone https://github.com/stable-haskell/miso-wasm-template" +echo " cd miso-wasm-template && make && make serve" +``` + +### 8.3 Repo layout for `stable-haskell/ghc-wasm-meta` + +``` +stable-haskell/ghc-wasm-meta/ +├── README.md (Phase 7 doc) +├── bootstrap.sh (8.2 above) +├── ghcup-stable-wasm-0.0.1.yaml (8.1 above) +└── .github/workflows/ + └── validate.yml (lint YAML, hash-check release assets) +``` + +Bindists themselves live in a SECOND repo: + +``` +stable-haskell/ghc-wasm-bindists/ +└── (releases tagged v9.14.0.stable.YYYYMMDD, + each with .tar.xz assets per host platform) +``` + +This separation mirrors upstream (`haskell-wasm/ghc-wasm-meta` + +`haskell-wasm/ghc-wasm-bindists`) and keeps the small text-config repo +auditable while binaries live in releases. + +## 9. Status log + +- **2026-05-26** — Initiative kicked off. Five research agents fanned out; findings + aggregated above. Branch `feat/wasm-cross-ghcup` created. This plan committed. +- **2026-05-26** — Phase 0a complete. Phase 0 cherry-picks landed cleanly: + 6 commits from `build/wasm-nix-environment` (flake.nix uses ghc-wasm-meta for + wasi-sdk; wrappers in `.nix-wasm-bin/`) + 6 commits from `feat/nix-ci-split` + (Makefile cross-refactor + CI nix-ci.yml split + RTS R_*_NONE fix + 3 build + infra fixes). Single cabal.project.stage3 auto-merge, no manual conflicts. + Branch now 13 commits ahead of `stable-ghc-9.14`. Verifying Phase 0 gate next. +- **2026-05-26** — Phase 0 gate PASSES. `nix develop` reports: bootstrap GHC + 9.8.4, cabal-install 3.16.0.0, LLVM 21.1.8-wasi-sdk, clang 21.1.8-wasi-sdk. + All wasm wrappers on PATH (`.nix-wasm-bin/wasm32-wasi-{clang,clang++,ar, + ranlib}`, `wasm-ld`, `llc`, `opt`, `llvm-as`). `WASI_SDK_DIR` + + `WASM_EXTRA_{LIB,INCLUDE}_DIRS` exported. Incidental finding: devshell ships + cabal 3.16 — this is R1 materializing (upstream ghc-wasm-meta README warns + 3.16 has a wasm regression). Doesn't affect Phase 0/1 (we're building GHC, + not user apps with TH), but flag for Phase 5 cabal-binary work and Phase 6 + miso e2e. Cherry-picked artifact: cabal-install was Nix-built from source + (~15 min first run); subsequent shells will be cached. +- **2026-05-26** — Phase 1 starting: apply D2 rename + `wasm32-unknown-wasi` → `wasm32-wasi` across Makefile, flake.nix, nix-ci.yml, + USAGE.md, build-wasm-{make,on-linux0}.sh (8 files, ~30 occurrences). + Mechanical sed; no word-boundary risk (no `wasm32-unknown-wasi-foo` strings + that should be preserved). D2 applied in commit `03bd8b8c0d6`. + Initial bad commit with embedded git repos caught and fixed via `git reset + --mixed HEAD~1`; recommitted with explicit file paths. +- **2026-05-26** — Phase 1 stage2 build FAILED after ~3 min: `make stable-cabal` + (rebuild cabal-install-3.17.0.1 from stable-haskell/master) fails — see R8. + Retrying with `USE_SYSTEM_CABAL=1` to skip the rebuild and use the existing + pre-rebase `_build/stage0/bin/cabal` (3.17.0.0). +- **2026-05-26** — Cascade of issues discovered: USE_SYSTEM_CABAL=1 hit R9 + (parallel install race on unix-2.8.8.0). Pinning Cabal source-repo tag to + `44817477…` did NOT resolve R9 (it's in the cabal binary, not the library). + `jobs: 1` in cabal.project.common DID resolve R9 and progressed past + unix-2.8.8.0 through Cabal-3.17.0.0, ghc-toolchain-bin, ghc-boot, ghci, + ghc-pkg, ghc, ghc-bin. THEN hit R10: cabal-install 3.17.0.0 (pre-Stage/ + Toolchain) puts binaries in `_build/stage1/bin/` and libraries in + `_build/stage1/store/ghc-9.8.4-inplace/` (old upstream layout), but + the Makefile expects `_build/stage1/store/host//bin/…` (new layout + from compile-less). Path-layout mismatch. +- **2026-05-26** — Path 2 (user-chosen): patched `stable-haskell/cabal` HEAD + locally to remove dead GHCJS code (R8 fix) + a stale `binDirectoryFor` + call site (uncovered after R8). Patches saved in + `lode/r8-cabal-ghcjs-removal.patch` (2 commits). Built patched cabal-install + in /tmp/cabal-fix with bootstrap GHC 9.8.4 + bootstrap cabal — SUCCESS. + Binary copied to `_build/stage0-patched/bin/cabal` (version banner confirms + patch SHAs: cabal-install 3.17.0.1 commit `3386061`, Cabal lib commit + `d6fac5f`). +- **2026-05-26** — Cascade through R11/R12: with patched cabal + relaxed + constraints + jobs:1, stage1 built but stage2 failed on + `build:any.ghc-internal installed`. Tried devx ghc910 bootstrap (has + ghc-internal-9.1003.0 installed) — different failure (mystery base-4.18.3.0 + reference in unbuildable ghc-bignum lib). Escalated to user. +- **2026-05-26** — User suggested looking at GitHub CI for working recipe. + **HUGE finding**: CI's last success was 2026-04-23 at SHA `d8f0caefe58`. + ALL builds since 2026-05-17 (after chore commit `e148c1c059` "use + stable-haskell/master") have failed — same R8 we hit. The project's + baseline has been broken for 10 days. +- **2026-05-26** — Cross-referenced cabal source at SHA `44817477` (the CI + April-23 working pin): **no `Distribution.Simple.GHCJS` module, no dead + GHCJS import, no `binDirectoryFor` stale call**. The R8/R8.5 bugs were + introduced LATER on stable-haskell/master, not present at 44817477. +- **2026-05-26** — CI uses upstream `cabal-3.14.2.0` from ghcup as bootstrap, + NOT a stable-haskell cabal-install. Downloaded 3.14.2.0 — but it fails + with `fatal: Could not parse object 44817477ff6d…` because the SHA is + orphaned (`wip/angerman/compile-less` branch was deleted/renamed to + `stable-haskell/master`, leaving the SHA reachable only via direct fetch). + CI works because it has a CACHED cabal binary that pre-dates the cache-key- + invalidating chore commit. +- **2026-05-26** — **WORKING RECIPE FOUND**: substitute the patched cabal + (which CAN fetch orphaned SHAs) for upstream 3.14.2.0 as `CABAL0`. The + patched cabal builds stable-cabal from clean SHA `44817477` source, which + produces a fresh cabal-install with no R8/R8.5/R9/R10/R11 issues. + Command: `devx#ghc98-minimal-ghc shell` + `CABAL0=patched-cabal` + + `cabal update` + `make clean clean-cabal distclean` + `make _build/dist/ghc.tar.gz`. + Result: `_build/dist/ghc.tar.gz` (258MB) built in ~45 min. + GHC 9.14 (Stable Haskell Edition) compiles+runs hello world. Stage1+2 GREEN. +- **2026-05-26** — **PHASE 1 STAGE3 GREEN**: switched to local flake (wasi-sdk), + ran `make CABAL=_build/cabal/bin/cabal USE_SYSTEM_CABAL=1 stage3-wasm32-wasi`. + Failed on final library-copy step due to **D2 triple mismatch**: ghc-toolchain + normalizes `wasm32-wasi` back to `wasm32-unknown-wasi` (canonical autoconf + form), so cabal stored libs at `host/wasm32-unknown-wasi/...` but Makefile + looked at `host/wasm32-wasi/...`. **REVERTED D2** across Makefile, flake.nix, + nix-ci.yml, USAGE.md, 2 build scripts. Rebuild stage3 → success. + `_build/dist/bin/wasm32-unknown-wasi-ghc` exists; compiles `hello.hs` to + valid `WebAssembly (wasm) binary module version 0x1 (MVP)` (1.5MB). + Pure wasmtime can't run the binary (GHC's wasm RTS uses JSFFI imports); + full runtime test via post-link.mjs + Node deferred to Phase 6 miso e2e. + **PHASE 1 GATE MET (compile-only gate; runtime gate in Phase 6).** +- **2026-05-26** — **PHASE 6 v0.0.1 DELIVERED** (trivial reactor PoC). + Empirically verified end-to-end: dual-compiler `cabal.project` form + (`with-build-compiler` + `with-compiler`) builds a trivial wasm reactor; + `post-link.mjs` produces JSFFI ESM glue; Node's `node:wasi` + the JSFFI + imports run the wasm and print "Hello from the WASM reactor!". Critical + finding: the bring-up sequence MUST be **wasi.initialize() → + __ghc_wasm_jsffi_init() → hs_start()**. The original agent drafts (and + CI smoke test) missed `__ghc_wasm_jsffi_init` — without it, hs_start() + throws "RTS is not initialised; call hs_init() first". Files in + `lode/phase6-trivial-reactor-poc/`. +- **2026-05-27** — **PHASE 6.5 shared-library blocker RESOLVED** via + extending stable-haskell's `--enable-dynamic` rearchitecture to + stage3 cross targets. Five concrete changes (3 commits to PR #181): + * `cabal.project.stage3.settings.in` + `configure.ac` + `Makefile` + (CONFIGURED_FILES + STAGE3_*_PREREQS + DYNAMIC RTS-ways sed + + DIST_COPY_LIBS_SO_CROSS .so/.dylib fix) + `cabal.project.stage3` + — symmetric stage2-mirror of the dynamic-enable wiring. + * `rts/RtsStartup.c` — missing wasm exclusion at the + `promoteBootLibrariesToGlobal()` call site (definition at line 108 + excludes wasm; call at line 427 didn't, breaking the dyn rts build). + * `compiler/GHC/Driver/Session.hs` — disabled the wasm + `makeDynFlagsConsistent` rule. The rule's `LinkExecutable _` + predicate matched the DEFAULT `defaultDynFlags.ghcLink`, so it + fired for per-module compile invocations too — stripping WayDyn + mid-compile and breaking `.dyn_hi` lookup. + Result: stage3-wasm32-unknown-wasi DYNAMIC=1 produces 2121 .dyn_hi + + 40 .so files. End-user `cabal build` with `shared: True` compiles + miso through 67/70 modules. +- **2026-05-27** — **iserv hang RESOLVED.** Instrumented dyld.mjs main() + + minimal TH-test program → found the root cause: ghc-internal.so's + dylink.0 needed_dynlibs lists `libHSrts-fs-1.0.0.0`, `libdl.so`, + `libc.so` but NOT the main wasm RTS (e.g. + `libHSrts-1.0.3-nonthreaded-nodebug-ghc9.14.so`). ghc-internal + IMPORTs `env.registerForeignExports` (defined only in main rts). + Toposort loads ghc-internal *before* main rts → ghc-internal's + `_initialize` calls env.registerForeignExports → "non-existent + function" RuntimeError → node crashes. Parent ghc never noticed + the node death and hung on the IPC pipe. + Workaround in `utils/jsffi/dyld.mjs`: preload any + `libHSrts-*-nonthreaded-nodebug-ghc*.so` sitting next to ghciSoPath + before loadDLL(ghciSoPath). Commit `1cf5c768394` on PR #181. + Verified end-to-end with a minimal TH program: builds + installs + the wasm executable cleanly. + **Proper fix is at link level** — ghc-internal's wasm-ld invocation + should emit a needed_dynlibs entry for the main rts. Tracking as + follow-up; the dyld.mjs workaround unblocks TH for end-user wasm + builds in the meantime. +- **2026-05-27** — **Deeper root cause for the ghc-internal/main-rts + link bug**: `cabal.project.stage3` declares + `package ghc-internal / ghc-options: -no-rts` + Setting `-no-rts` (`Opt_NoRts` in GHC) is documented as the right + thing for static `.a` libs that would otherwise fail the + `mkUnitState` sanity check at `compiler/GHC/Unit/State.hs:1652` + ("RTS is missing from the package database"). However, when shared + is enabled, the *same flag* also prevents `-lHSrts-...` from being + added to the wasm-ld command for the resulting `.so`. The `.so` + ends up without rts in its `dylink.0/needed_dynlibs`. + GHC already has injection logic at `Unit/State.hs:1700-1713` that + makes `rtsWayUnitId` a synthetic dep of `ghcInternalUnitId`, but + that's at unit-state level — by the time `linkDynLib` + (`compiler/GHC/Linker/Dynamic.hs:55`) calls `preloadUnitsInfo'` + with cabal's explicit `--depends` list, the injection has no effect + because cabal didn't list rts in ghc-internal's depends. + **Cleanest fix** (deferred): on wasm32, when building a `.so` for + ghc-internal (or any pkg flagged `-no-rts`), explicitly append the + current `rtsWayUnitId` to `pkgs_with_rts` in `linkDynLib`. The + Wasm32 branch at `Dynamic.hs:92` already short-circuits to + `pkgs_with_rts` instead of `pkgs_without_rts`, but it needs to also + guarantee rts is *in* `pkgs_with_rts` regardless of caller intent. +- **2026-05-27** — **Second bug surfaced during miso retry**: + stable-haskell/cabal resolves the `wasm32-unknown-wasi-ghc-pkg` + symlink to its target `ghc-pkg` before storing the path in + `setup-config`. GHC's ghc-pkg uses `argv[0]` to choose the target + package db, so after resolution the *native* db ends up in cabal's + db stack. Verified via `cabal -v3` output: + `db stack: [..., _build/dist/lib/package.conf.d, ...]` + (native), not `lib/targets/wasm32-unknown-wasi/lib/package.conf.d`. + The wasm GHC itself still consults its own default db at compile + time, so the build proceeds; but cabal's view of installed packages + is wrong, which is a latent footgun (ABI hashes etc.). Tracking as + a stable-haskell/cabal followup. +- **2026-05-27** — **PHASE 6.5 GATE PASSED. FULL MISO APP BUILDS END-TO-END.** + Re-ran the miso build at `/tmp/wasm-reactor-test/` with the dyld.mjs + preload workaround in place. All 50+ dependencies — including the + heavy TH packages (aeson, lens, jsaddle, jsaddle-wasm) — built + cleanly via wasm-iserv. Final `myapp.wasm` is 2.8MB, valid + WebAssembly MVP binary. The dyld.mjs preload workaround scales. + Two end-user gotchas surfaced and are documented: + * miso 1.11 apps need `build-depends: ghc-experimental` to import + `GHC.Wasm.Prim` (otherwise GHC-87110 "module hidden in + ghc-experimental"). + * For miso, the wasm entry is `startApp defaultEvents app` *not* + `JSW.run (startApp …)` — miso's `startApp` already returns + `IO ()`; wrapping it in `JSW.run` triggers `JSM ()` / `IO ()` + mismatch. +- **2026-05-27** — **PROPER LINK-LEVEL FIX LANDED. dyld.mjs workaround + REVERTED.** Implemented the right fix in + `compiler/GHC/Linker/Dynamic.hs` (commit `f92963f43ef`): on wasm32, + `linkDynLib` now forces the current way's `rtsWayUnitId` into the + pkg list when it isn't already present (looked up via `lookupUnitId` + on `ue_units unit_env`). The fix is wasm32-only, purely additive + (no-op for .so files that already include rts), and lets `wasm-ld` + populate `dylink.0/needed_dynlibs` with rts even when the package + was built with `-no-rts`. + Verified at two levels: + * Artifact: `libHSghc-internal-9.1400.0-ghc9.14.so` needed_dynlibs + went from `[rts-fs, libdl, libc]` (3 entries) → `[rts, rts-fs, + libffi, libdl, libc]` (5 entries, with the main rts now present). + * Runtime: after reverting the dyld.mjs main-rts preload workaround + (`d32bc88ff6a`), the full miso build *still* succeeds — aeson + (TH-heavy) re-built cleanly via iserv, `myapp.wasm` produced. + The link fix is sufficient on its own. + Bindist tarball repackaged with the clean state: 226MB, + ghc-internal.so has rts in deps, dyld.mjs no longer has the + workaround. +- **2026-05-27** — **End-user bindist verification (Phase 3 → Phase 4 + handoff).** Extracted the 226MB tarball into a fresh prefix + (`/tmp/wasm-bindist-test/`), ran `./relocate.sh`, then compiled two + hello-world programs as an end user would: + * Non-TH `hello2.hs` → `hello2.wasm` (1.7MB, valid wasm MVP) — works + standalone (no node needed at compile time). + * TH-using `hello.hs` (`$(lift "...")`) → `hello.wasm` (1.7MB) — + works **with `node` on PATH** (the iserv shim shells out to node + to run the wasm interpreter at compile time). + Original bindist SHA256: + `005ebbb7e9c5dfa5bf183a263ab398022409004030b817fd7a12781f1db7ef80` + Phase 4 prerequisites: + * Bindist file: `_build/dist/ghc-wasm32-unknown-wasi.tar.gz` + (226742697 bytes). + * Documented user prerequisite: **`node` must be on PATH** if the + user wants TH evaluation (cabal pkgs with `template-haskell`). + * Symlink-resolution cabal bug (Task #13) downgraded: only affects + in-tree `_build/dist/` developer use (symlinks present). End + users via ghcup get the `tar czhf`-dereferenced real files in + `bin/`, so they don't trip it. Not gating on Phase 4. +- **2026-05-27** — **UX fix: clear error when `node` missing for TH** + (commit `6171b4256b1`). The bindist's TH iserv path goes through + `dyld.mjs` which starts with `#!/usr/bin/env -S node`. Without node + the iserv child exits with 127 and GHC reports the generic + `External interpreter terminated (127)` from `Process.hs:129` — + cryptic for someone tracking down a CI failure. + Added a pre-flight `findExecutable "node"` in + `compiler/GHC/Runtime/Interpreter/Wasm.hs::spawnWasmInterp` that + throws `InstallationError` with a clear, actionable message naming + the dyld.mjs script and instructing the user to install Node.js. + Verified: clearing PATH of node and recompiling a TH-using hello- + world now produces the new message; happy path (node on PATH) + unchanged. + Repackaged bindist after this commit. New SHA256: + `d10b2ee8c807f47de94870604a68b6ff73835fe75981a456b962b80d77be6789` + (size 226745567 bytes). +- **2026-05-27** — **`relocate.sh` also warns at install time if Node.js + missing** (commit `d2dda02f14b`). Layers the same UX hint as the + compile-time check, but at install/relocate time so end users learn + the requirement before their first TH compile. Non-TH builds remain + unaffected — the script still completes successfully when node is + missing, it just prints a NOTE to stderr. + Final repackaged bindist SHA256: + `8241c107930fadd77fe28773f47581e7288cbc405143a7acf5c0bec4df181d2e` + (size 226747741 bytes). This is the artifact ready for Phase 4 + hosting. +- **2026-05-27** — **PHASE 4 GATE PASSED. ghcup channel published.** + Decisions (user-approved): + * Host: GitHub release attachment on stable-haskell/ghc + * Version label: `wasm32-wasi-9.14.0.stable.0` + * Channel: custom (GitHub Pages on stable-haskell/ghc gh-pages branch) + Implementation: + * Created GitHub release `wasm32-wasi-9.14.0.stable.0` (pre-release + flag set) with the wasm bindist attached. URL: + https://github.com/stable-haskell/ghc/releases/tag/wasm32-wasi-9.14.0.stable.0 + * Added autoconf-shaped install stubs to the bindist + (mk/wasm-configure.sh + mk/wasm-bindist-Makefile, wired through + the main Makefile, commit `7c26a1def7e`) so `ghcup install ghc + `'s standard `./configure --prefix && make install + DESTDIR=staging` flow works. The install target honors `$(DESTDIR)`, + copies bin/+lib/+relocate.sh, and prints a node-missing NOTE. + * Tarball SHA changed to + `93e5d8c70fb670148015ca6cbcb76fbe1d9fe9d7b7945de0b983799e32efb60e` + after adding the autoconf stubs. + * Created orphan `gh-pages` branch with `ghcup-wasm.yaml` + + `index.html`. Enabled GitHub Pages on that branch. + YAML URL: https://stable-haskell.github.io/ghc/ghcup-wasm.yaml + Landing page: https://stable-haskell.github.io/ghc/ + * Crucial schema finding: ghcup's custom-channel parser expects the + 0.0.9 flat schema (versions directly under tool name) — the + 0.1.0 schema (`toolDetails:`/`toolVersions:` indirection) only + works for the upstream-cached default channel, NOT for + `ghcup config add-release-channel `. + End-to-end verified: + * `ghcup config add-release-channel https://stable-haskell.github.io/ghc/ghcup-wasm.yaml` + → succeeds, channel cached. + * `ghcup list -t ghc` shows `wasm32-wasi-9.14.0.stable.0 + latest-prerelease`. + * `ghcup install ghc wasm32-wasi-9.14.0.stable.0` → downloads + bindist (~216MB), verifies SHA256, runs configure + make install, + prints the viPostInstall message including the node hint. + * Installed compiler at `~/.ghcup/ghc/wasm32-wasi-9.14.0.stable.0/` + builds both non-TH and TH hello-worlds. + Phase 4 gate is met for the aarch64-darwin host. Linux hosts join + once PR #181 CI verifies their build. +- **2026-05-27** — **PHASE 5 GATE PASSED. Dual-compiler `cabal build` + works end-to-end via ghcup.** Created `cabal-3.17.0.0.stable.0` + release (pre-release) on stable-haskell/ghc with the bundled + cabal-install binary (cabal-install 3.17.0.1 from stable-haskell/cabal + HEAD + R8 patches). Bundled `lib/libgmp.10.dylib` next to the + binary with `@loader_path/lib`-relative rpath so it runs without + nix/Homebrew on stock aarch64-darwin. Uses ghcup metadata's + `dlInstallSpec.exeRules` + `dataRules: lib/**` to install both files. + Add to the same channel YAML. + **One critical cabal source patch was needed** to make the dual- + compiler `cabal build` flow work on the ghcup-installed bindist: + * cabal's `guessGhcPkgFromGhcPath` (`Cabal/src/Distribution/Simple/ + GHC.hs::guessToolFromGhcPath`) looks for `ghc-pkg` next to the + ghc binary, then falls back to PATH. For a cross-compiler bindist + where `bin/` only contains the target-prefixed names (e.g. + `wasm32-unknown-wasi-ghc-pkg`, no bare `ghc-pkg`), the fallback + grabs the BUILD compiler's `ghc-pkg` from PATH, triggering + "Version mismatch between ghc and ghc-pkg" at configure time. + * Patch: detect the target prefix on the ghc binary + (`takeBaseName p` strips dir/ext, `isSuffixOf` the + `"ghc"++versionSuffix` tail, take everything before that) and + prepend it to the toolname when guessing. So + `/path/wasm32-unknown-wasi-ghc` guesses + `/path/wasm32-unknown-wasi-ghc-pkg` first, which exists in the + bindist. Falls back to existing logic if no prefix is detected. + * Patch saved at `lode/r12-cabal-target-prefix-aware-tool-guess.patch` + (71 lines) for upstreaming to stable-haskell/cabal#359. + * The patched cabal binary in the released tarball already has + this fix baked in. + Final cabal bindist SHA256: + `7f755b0810f5167b7f776470b5219834cdcbdad562998fd3c074b091be77e4d6` + Verified end-to-end: + * Fresh sandbox: `ghcup config add-release-channel` + install ghc + + install cabal both succeed. + * `cabal build test-app` on a dual-compiler `cabal.project` + (with-build-compiler = ghc-9.8.4, with-compiler = wasm GHC, + shared:True) builds a 1.7MB valid WebAssembly MVP binary via + `cabal-3.17.0.0.stable.0`. + Original Task #13 (cabal symlink resolution) is RESOLVED by the + same patch — the target-prefix issue was the ghcup end-user + manifestation of the same underlying gap in + `guessGhcPkgFromGhcPath`. +- **2026-05-27** — **PHASE 7 GATE PASSED.** Tutorial-grade landing + page + downloadable hello template shipped on the `gh-pages` branch: + * `index.html` — install instructions, 90-second hello walkthrough, + anatomy section (cabal.project, myapp.cabal, Main.hs, run.mjs + reactor sequence), troubleshooting table, project links. + Light/dark mode CSS. + * `examples/hello/` — minimal but production-shaped reactor + template: + - `app/Main.hs` (CPP-guarded `foreign export javascript`) + - `cabal.project` (dual-compiler bare names + wasm32 `shared:True`) + - `myapp.cabal` (reactor `ghc-options` + `cpp-options: -DWASM`) + - `Makefile` (self-documenting, autodetects + `$GHCUP_INSTALL_BASE_PREFIX`, honors macOS `/tmp` symlink + canonicalization for `post-link.mjs`) + - `run.mjs` (Node:wasi launcher with full reactor bring-up + comments) + - `public/{index.html,index.js}` (browser launcher via + `browser_wasi_shim` ESM) + * `examples/stable-haskell-wasm-hello.tar.gz` — 5 KB tarball, + SHA256 + `1f422c5ee6056ec0f0c4d1055cbfda6513c98d4bcc305991dd04f067d3a68843`, + extracts to `stable-haskell-wasm-hello/`. + Verified end-to-end from a clean dir: + * `curl -L https://stable-haskell.github.io/ghc/examples/stable- + haskell-wasm-hello.tar.gz | tar xz` + * `cd stable-haskell-wasm-hello && make run-node` + * stdout: `Hello from the WASM reactor!` + Phase 7 gate ("unfamiliar dev reaches demo from README alone") met. +- **2026-05-27** — **miso-counter template shipped alongside hello.** + Phase 6.5 had verified the miso 1.11 build end-to-end on a private + test dir at `/tmp/wasm-reactor-test/`; that working setup is now + packaged as a shippable template on the channel: + * `examples/miso-counter/` (browsable) and + `examples/stable-haskell-wasm-miso-counter.tar.gz` + (SHA `7b243af055ef012864e9e1567739411e1c854dad8a17b5442b1e58937fc0b77b`, + 5192 bytes). + * Same Makefile shape as `hello/` but only `run-web` (miso is + DOM-driven). `make build` first-run is ~5-10 min for the + TH-heavy dep tree; subsequent code edits are seconds. + * cabal.project pins miso 1.11 via `source-repository-package` and + ships the `allow-newer: jsaddle-wasm:ghc-experimental` override + needed for GHC 9.14. + * Main.hs follows the miso 1.11 `component` smart-constructor + + `startApp defaultEvents app` pattern (the API drift from older + miso versions is called out in the README troubleshooting table). + Landing page now features both examples side-by-side in a grid: + hello as the 90-second on-ramp, miso-counter as the step-up to a + real interactive UI. Acceptance-tested fresh from the published + tarball: `curl | tar | make post-link-web` produces a 2.8MB valid + WebAssembly MVP plus its 48KB post-link jsffi.mjs glue. +- **2026-05-27** — **Live in-browser miso demo + Phase 2 CI all green.** + * **Live demo at https://stable-haskell.github.io/ghc/demos/miso-counter/** + — pre-built mountpoint variant of the miso-counter template + (`mountPoint = Just "miso-root"`, so the explanatory HTML on the + demo page survives miso's diff). Visiting the URL renders the + counter directly — no install required. wasm SHA + `d3a4b8dbb78592761f891577774f5c714e34e624b6e7f2f22ced00d3d8a43f65` + (2,877,198 bytes); jsffi.mjs 48 KB. Landing page and + `examples/miso-counter/README.md` both link to it. + * **PHASE 2 GATE PASSED** — PR #181 run 26494711606 has all 6 Build + jobs PASS (aarch64-darwin, x86_64-linux, aarch64-linux each in + dynamic=0 and dynamic=1). Test + Cross jobs are in flight (8 of 14 + remaining; persistent CI monitor watching for any transitions). + Each Build job uploads its native stage2 bindist as a workflow + artifact (260–303 MB per host/dynamic combo). To ship Linux wasm + **cross-compiler** bindists through the same ghcup channel, + follow-up work needs to wire `make + stage3-wasm32-unknown-wasi-tarball` (or equivalent) into the + Linux runners and upload the resulting wasm-cross tarball as an + artifact — the current Build jobs only produce native bindists. +- **2026-05-26** — **PHASE 6 v0.0.2 (miso e2e) BLOCKED on wasm-backend + shared-library support.** [HISTORICAL — superseded by 2026-05-27] Adding miso 1.11.0 + jsaddle-wasm pulls in + `character-ps` which needs `Data.Word.dyn_hi` from base — but our wasm + base library is built `shared: False`. Two attempts to fix in + `cabal.project.stage3`: + (1) project-level `if arch(wasm32) shared: True` — applied but failed + at ghc-internal link with "[GHC-74335] -dynamic is ignored when + linking binaries on WASM" + "mismatched interface file profile tag + (wanted '', got 'dyn')". + (2) package-level `package * / if arch(wasm32) shared: True` — built + successfully but applied silently; no .dyn_hi anywhere. + Conclusion: cabal config alone can't deliver wasm shared libs. + Need upstream GHC work on the wasm backend (RTS / linker / interface- + file profile tag handling). **Out of session scope.** Phase 6.5 (miso + e2e) is queued as a separate workstream contingent on this fix. +- **2026-05-26** — Two PRs opened: (1) stable-haskell/cabal#359 with the + R8/R8.5 patches (https://github.com/stable-haskell/cabal/pull/359); (2) + stable-haskell/ghc#181 with our branch + Phase 0-2 work + (https://github.com/stable-haskell/ghc/pull/181). Phase 2 CI verification + is async on PR #181 — the `cross-wasm` job in nix-ci.yml already does + the full smart-smoke-test runtime check. +- **2026-05-26** — **PHASE 3 GATE PASSED**. Added `mk/wasm-relocate.sh` + (10-line script that runs `ghc-pkg recache` for the new install prefix) + and switched the wasm bindist tar to `tar czhf` (dereferences symlinks + → wasm-prefixed bin entries become real files instead of broken symlinks + to the bare native names). Tarball grew 153MB → 264MB but is now + **self-contained and relocatable**. Verified end-to-end: extract to + `/tmp/wasm-relocate-test2`, run `./relocate.sh`, then + `bin/wasm32-unknown-wasi-ghc hello.hs -o hello.wasm` produces valid + wasm from the fresh prefix. +- **2026-05-26** — R8/R9/R10/R11 reclassified as **dissolved artifacts** + of bootstrapping with broken master HEAD cabal source. Real root cause: + the chore commit `e148c1c059` on `stable-ghc-9.14` changed the Cabal + source-repo tag from explicit SHA `44817477…` to the branch name + `stable-haskell/master`, exposing all subsequent builds to whatever + bugs landed on that branch tip. **Recommended upstream fix**: revert + `e148c1c059` (return to explicit SHA pin) OR fix the bugs on + stable-haskell/master HEAD. Either resolves the daily CI failures. diff --git a/mk/clean-darwin-macho.sh b/mk/clean-darwin-macho.sh new file mode 100755 index 000000000000..c36458ff8a39 --- /dev/null +++ b/mk/clean-darwin-macho.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# clean-darwin-macho.sh — strip build-host leaks from every Mach-O +# artefact under $DIST_DIR at CONSTRUCTION TIME (during stage2.dist, +# before tarball assembly), so the bindist ships clean without +# needing a post-build install_name_tool pass in CI. +# +# Two classes of leak to remove: +# +# (1) Absolute LC_RPATH entries like +# /Volumes/WorkSpace/_work/ghc/ghc/_build/stage2/store/host/... +# that the bundled Cabal's depLibraryPaths bakes in via the +# link line. macOS 14 dyld silently falls through to the +# portable @executable_path/../lib/ rpath SET_RPATH adds, +# but macOS 15 dyld treats the unresolvable absolute path as +# fatal and SIGABRTs the binary on launch. +# +# (2) nix-store LC_LOAD_DYLIB install names for libiconv, libffi, +# libc++, libz, libresolv, libncurses. The build runner's +# devx Nix store has these visible at link time, but their +# install names point at /nix/store paths that don't exist on +# end-user hosts. Rewrite each to its /usr/lib equivalent +# (Apple stub-cache library, ABI-compatible). +# +# Mutating a Mach-O invalidates its linker signature; re-sign ad-hoc +# afterwards so dyld accepts the binary on Apple Silicon. +# +# No-op on non-Darwin (the outer uname guard). +# +# Pattern adapted from input-output-hk/devx static.nix:fixup-nix-deps, +# SHA 5f05c1e1af6. +# +# Usage: clean-darwin-macho.sh + +set -euo pipefail + +[ "$(uname -s)" = "Darwin" ] || { echo "Not Darwin — skipping Mach-O cleanup."; exit 0; } + +DIST_DIR="${1:?DIST_DIR required}" +[ -d "$DIST_DIR" ] || { echo "::error::$DIST_DIR not a directory"; exit 1; } + +echo "[stage2] Stripping build-host leaks from Mach-O artefacts in $DIST_DIR" + +rpath_stripped=0 +nix_rewritten=0 +files_touched=0 + +while IFS= read -r -d '' f; do + file -L "$f" 2>/dev/null | grep -q 'Mach-O' || continue + changed=0 + + # (1) Strip /Volumes/-prefixed LC_RPATH entries + while IFS= read -r rp; do + [ -z "$rp" ] && continue + install_name_tool -delete_rpath "$rp" "$f" 2>/dev/null && { + rpath_stripped=$((rpath_stripped + 1)) + changed=1 + } || true + done < <(otool -l "$f" 2>/dev/null \ + | awk '/cmd LC_RPATH/{flag=1; next} flag && /path \/Volumes\//{print $2; flag=0; next} flag && /path /{flag=0}') + + # (2) Rewrite nix-store LC_LOAD_DYLIB → /usr/lib equivalents + while IFS= read -r nixdep; do + [ -z "$nixdep" ] && continue + case "$nixdep" in + *libiconv.dylib) new=/usr/lib/libiconv.dylib ;; + *libiconv.2.dylib) new=/usr/lib/libiconv.2.dylib ;; + *libffi.*.dylib) new=/usr/lib/libffi.dylib ;; + *libc++.*.dylib) new=/usr/lib/libc++.dylib ;; + *libz.dylib) new=/usr/lib/libz.dylib ;; + *libresolv.*.dylib) new=/usr/lib/libresolv.dylib ;; + *libncursesw.*.dylib) new=/usr/lib/libncurses.5.4.dylib ;; + *libncurses.*.dylib) new=/usr/lib/libncurses.5.4.dylib ;; + *) continue ;; + esac + install_name_tool -change "$nixdep" "$new" "$f" 2>/dev/null && { + nix_rewritten=$((nix_rewritten + 1)) + changed=1 + } || true + done < <(otool -L "$f" 2>/dev/null | awk '/nix\/store/{print $1}') + + if [ "$changed" -eq 1 ]; then + codesign --force --sign - "$f" >/dev/null 2>&1 || true + files_touched=$((files_touched + 1)) + fi +done < <(find "$DIST_DIR" -type f \( -perm -u+x -o -name '*.dylib' \) -print0) + +echo "[stage2] Stripped $rpath_stripped LC_RPATH entries, rewrote $nix_rewritten LC_LOAD_DYLIB entries in $files_touched files" 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 <` flow is "./configure --prefix=DIR && +# make install". The bindist is already laid out as bin/ + lib/ + +# relocate.sh, so install just copies that layout into the prefix and +# refreshes the package-db cache (the wasm32-unknown-wasi-ghc-pkg +# binary auto-recaches by mtime on first read too, but we do it +# eagerly here so the first invocation is fast). + +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"; \ + fi + @if ! command -v node >/dev/null 2>&1; then \ + echo ""; \ + echo "NOTE: Node.js was not found on PATH. Template Haskell"; \ + echo " evaluation will fail without it (the wasm dynamic"; \ + echo " linker shim runs via node). Non-TH builds work fine."; \ + echo ""; \ + fi + @echo "Installed stable-haskell wasm32-unknown-wasi GHC to: $(DESTDIR)$(prefix)" + @echo "(ghc-pkg auto-recaches package.cache by mtime on first read;" + @echo "no relocate step required after install.)" diff --git a/mk/wasm-configure.sh b/mk/wasm-configure.sh new file mode 100644 index 000000000000..56e1e470cbfb --- /dev/null +++ b/mk/wasm-configure.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# configure — bundled with the stable-haskell wasm32-unknown-wasi GHC bindist. +# +# Minimal autoconf-style stub so that `ghcup install ghc `'s +# standard "./configure --prefix=DIR && make install" flow works. +# +# All we do is record the install prefix; the real work happens in the +# bindist's Makefile install target (which just copies bin/ + lib/ into +# the prefix and refreshes the package-db cache). + +set -e + +prefix= +while [ $# -gt 0 ]; do + case "$1" in + --prefix=*) + prefix="${1#--prefix=}" + ;; + --prefix) + shift + prefix="$1" + ;; + # accept and ignore other autoconf args ghcup or users may pass + *) + ;; + esac + shift +done + +if [ -z "$prefix" ]; then + echo "error: --prefix=DIR is required" >&2 + exit 1 +fi + +cat > config.mk <&2 + echo " extract the bindist tarball before running this script" >&2 + exit 1 +fi +if [ ! -d "$PKG_DB" ]; then + echo "error: $PKG_DB not found" >&2 + echo " expected layout: /lib/targets/wasm32-unknown-wasi/lib/package.conf.d/" >&2 + exit 1 +fi + +echo "Relocating wasm32-unknown-wasi-ghc package db to: $PKG_DB" +"$GHC_PKG" recache --package-db "$PKG_DB" + +# Template Haskell support requires `node` (Node.js) on PATH: the wasm +# dynamic linker shim (lib/dyld.mjs) is a Node script. Warn now if missing +# so users learn this at install time, not at the first TH compile. +if ! command -v node >/dev/null 2>&1; then + cat >&2 < #if defined(OBJFORMAT_ELF) + +/* + * Check whether a symbol (identified by its symbol table section index and + * its index within that table) is referenced by any relocation whose type + * is not R_*_NONE (type 0). R_*_NONE is a no-op relocation for every ELF + * architecture, so symbols referenced exclusively by NONE relocations need + * not be resolved. + * + * This is used by fillGot() to avoid hard failures on undefined symbols that + * the assembler emitted NONE relocations for (e.g. zero-length arrays + * optimised away by the compiler but still present in the symbol table). + */ +static bool +symbolHasNonNoneRelocation(ObjectCode *oc, unsigned symTabIndex, + size_t symIdx) +{ + /* Scan REL relocation tables */ + for (ElfRelocationTable *relTab = oc->info->relTable; + relTab != NULL; relTab = relTab->next) { + if (relTab->sectionHeader->sh_link == symTabIndex) { + for (size_t i = 0; i < relTab->n_relocations; i++) { + Elf_Rel *rel = &relTab->relocations[i]; + if (ELF_R_SYM(rel->r_info) == symIdx + && ELF_R_TYPE(rel->r_info) != 0) { + return true; + } + } + } + } + /* Scan RELA relocation tables */ + for (ElfRelocationATable *relaTab = oc->info->relaTable; + relaTab != NULL; relaTab = relaTab->next) { + if (relaTab->sectionHeader->sh_link == symTabIndex) { + for (size_t i = 0; i < relaTab->n_relocations; i++) { + Elf_Rela *rel = &relaTab->relocations[i]; + if (ELF_R_SYM(rel->r_info) == symIdx + && ELF_R_TYPE(rel->r_info) != 0) { + return true; + } + } + } + } + return false; +} + /* * Check if we need a global offset table slot for a * given symbol @@ -96,6 +141,21 @@ fillGot(ObjectCode * oc) { if(0x0 == symbol->addr) { if(0 == strncmp(symbol->name,"_GLOBAL_OFFSET_TABLE_",21)) { symbol->addr = oc->info->got_start; + } else if (!symbolHasNonNoneRelocation(oc, symTab->index, i)) { + /* Symbol is only referenced by R_*_NONE + * (type 0) relocations, which are no-ops. + * This can happen when e.g. a zero-length + * array is optimised away by the compiler + * but the assembler still emits NONE relocs + * against the (now undefined) symbol. + * Assign a dummy address so the GOT slot is + * filled; it will never be dereferenced. */ + IF_DEBUG(linker, + debugBelch("Skipping unresolvable symbol" + " '%s' (only referenced by" + " NONE relocations)\n", + symbol->name)); + symbol->addr = (void*)0xDEAD0000; } else { errorBelch("Failed to lookup symbol: %s," " you might consider using --optimistic-linking\n", diff --git a/utils/jsffi/dyld.mjs b/utils/jsffi/dyld.mjs index 8ccb47d8e9d9..612713a216f9 100755 --- a/utils/jsffi/dyld.mjs +++ b/utils/jsffi/dyld.mjs @@ -1103,6 +1103,7 @@ export async function main({ rpc, libdir, ghciSoPath, args }) { rpc, }); await dyld.addLibrarySearchPath(libdir); + await dyld.loadDLL(ghciSoPath); const reader = rpc.readStream.getReader();