From dcd377109e0645de6a664001c755caf93f5b43c0 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 15:22:53 -0400 Subject: [PATCH 01/14] fix(sol9): put the cross toolchain on PATH for the sol9libs stage mrustc spawns `$SOL9_TARGET-gcc` by name from the target spec, so the stdlib build needs ~/sol9-toolchain/opt/bin on PATH. The script never added it, so the stage only worked inside docker/sol9.Dockerfile, where the toolchain is already there. On a local toolchain it silently fell through to the host x86 gcc and died with "bad value 'v9' for '-mtune=' switch" -- an error that reads like a broken target spec rather than a missing PATH entry. Co-Authored-By: Claude Opus 5 --- scripts/build-sol9.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/build-sol9.sh b/scripts/build-sol9.sh index 69c2d74b..416207b0 100755 --- a/scripts/build-sol9.sh +++ b/scripts/build-sol9.sh @@ -50,6 +50,8 @@ FEATURES="${FEATURES:-native-zstd,remote,tui,rust173-polyfill}" SOL9_TOOLCHAIN="${SOL9_TOOLCHAIN:-$HOME/sol9-toolchain}" SOL9_BIN="${SOL9_BIN:-$SOL9_TOOLCHAIN/opt/bin}" SOL9_SYSROOT="${SOL9_SYSROOT:-$SOL9_TOOLCHAIN/sysroot}" +# mrustc spawns $SOL9_TARGET-gcc by name, so the cross toolchain has to be on PATH for sol9libs. +if [ -d "$SOL9_BIN" ]; then export PATH="$SOL9_BIN:$PATH"; fi # The C shim must reach the final link line, so default it here rather than in the environment. SOL9_SHIM="${SOL9_SHIM:-$CRATE_DIR/shim/sol9-compat.c}" From 2d9944d90608bee2aaaf49f8105d5de38edfcc45 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 17:44:30 -0400 Subject: [PATCH 02/14] fix(sol9): name the sysroot secret for its arch, and three build fixes Rename the CI secret to SOL9_SPARC64_SYSROOT_URL. Solaris also shipped on x86 (rusty-backup already parses Solaris-x86 VTOCs), so an arch-less name would have to be renamed the moment a second Solaris target appears. Nothing is orphaned: the old name was never created as a repo secret. Three fixes found while exercising the path end to end: * The build container never set SOL9_LIBGCC, whose default lives under $SOL9_TOOLCHAIN -- a path that does not exist in the image. The `dist` stage refuses to package without it, so the pipeline job would have died the first time it ran with the secret set. It never did run, which is why this stayed hidden. * `mksysroot.sh disk` passed the directory itself as the destination, but `rb-cli get -r` lays a directory source out *under* the destination, so the result was usr/include/include/stdio.h and the trailing sanity check failed. Disk mode has never worked; only the media path was exercised. * The same mode left every symlink as the text file rb-cli writes in place of one, so `-lc` would find a 12-byte libc.so and fail far from the cause. Parse the paths rb-cli reports and recreate the links. Verified against a real Solaris disk image: 2400 links recreated, and libc/libm/libsocket/libnsl/ librt/libdl/libpthread all resolve. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 10 +++++----- docker/README.md | 2 +- docker/sol9-cross/build.sh | 10 +++++----- docker/sol9-cross/mksysroot.sh | 22 ++++++++++++++++++++-- docker/sol9.Dockerfile | 3 +++ 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48047f88..9299d236 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: type: boolean default: true sol9: - description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SYSROOT_URL secret)' + description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SPARC64_SYSROOT_URL secret)' type: boolean default: true runner_image: @@ -1516,7 +1516,7 @@ jobs: name: Build rb-cli for Solaris 9 (SPARC) # Only runs where the sysroot secret exists. A Solaris 9 sysroot is a copy of a # licensed install's headers and libraries, so it cannot live in the repo or a - # public registry -- SOL9_SYSROOT_URL points at a private copy. Without it the + # public registry -- SOL9_SPARC64_SYSROOT_URL points at a private copy. Without it the # job skips rather than failing, so forks still get a green pipeline. if: ${{ github.event_name != 'workflow_dispatch' || inputs.sol9 }} needs: generate-version @@ -1526,15 +1526,15 @@ jobs: continue-on-error: true env: VER: ${{ needs.generate-version.outputs.version }} - SOL9_SYSROOT_URL: ${{ secrets.SOL9_SYSROOT_URL }} + SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} steps: - uses: actions/checkout@v6 - name: Check the sysroot secret is configured id: gate run: | - if [ -z "$SOL9_SYSROOT_URL" ]; then - echo "SOL9_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 + if [ -z "$SOL9_SPARC64_SYSROOT_URL" ]; then + echo "SOL9_SPARC64_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" diff --git a/docker/README.md b/docker/README.md index 39926eaf..5b439441 100644 --- a/docker/README.md +++ b/docker/README.md @@ -23,7 +23,7 @@ so unlike the PowerPC Mac build it can run unattended in the release pipeline. Its base image is the exception to the one-liner above: it needs a Solaris 9 sysroot, which Sun does not permit redistributing, so the image cannot be published and has to be built once from a Solaris 9 sysroot (`docker/sol9-cross/`, -which CI fetches via the `SOL9_SYSROOT_URL` secret; `Notes/SolarisSysroot.md` in the +which CI fetches via the `SOL9_SPARC64_SYSROOT_URL` secret; `Notes/SolarisSysroot.md` in the mrustc tree builds one from scratch). Full recipe in `docker/sol9.Dockerfile` and [`docs/build-sol9-mrustc.md`](../docs/build-sol9-mrustc.md). Verified on a Sun Blade 2500 (SunOS 5.9, sun4u): both parity gates agree with diff --git a/docker/sol9-cross/build.sh b/docker/sol9-cross/build.sh index dbd3e759..2fe51665 100755 --- a/docker/sol9-cross/build.sh +++ b/docker/sol9-cross/build.sh @@ -6,7 +6,7 @@ # Solaris 9 predates OpenSolaris by three years so no free substitute exists. Supply one of: # # ./mksysroot-from-iso.sh DVD.iso build one from the install DVD -- no Solaris box needed -# SOL9_SYSROOT_URL=https://... download a prepared tarball (what CI uses; keep it private) +# SOL9_SPARC64_SYSROOT_URL=https://... download a prepared tarball (what CI uses; keep it private) # SOL9_HOST=user@host pull one off a live Solaris 9 SPARC install # ./sysroot.tar.gz drop your own next to this script # @@ -15,15 +15,15 @@ set -eu SOL9_HOST="${SOL9_HOST:-}" -SOL9_SYSROOT_URL="${SOL9_SYSROOT_URL:-}" +SOL9_SPARC64_SYSROOT_URL="${SOL9_SPARC64_SYSROOT_URL:-}" IMAGE="${IMAGE:-mrustc-sol9-cross}" TAR="${TAR:-/opt/csw/bin/gtar}" cd "$(dirname "$0")" if [ ! -f sysroot.tar.gz ]; then - if [ -n "$SOL9_SYSROOT_URL" ]; then + if [ -n "$SOL9_SPARC64_SYSROOT_URL" ]; then echo "==> Downloading sysroot" - curl -fsSL "$SOL9_SYSROOT_URL" -o sysroot.tar.gz.tmp + curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o sysroot.tar.gz.tmp mv sysroot.tar.gz.tmp sysroot.tar.gz elif [ -n "$SOL9_HOST" ]; then echo "==> Pulling sysroot from $SOL9_HOST" @@ -31,7 +31,7 @@ if [ ! -f sysroot.tar.gz ]; then ssh "$SOL9_HOST" "sudo $TAR czf - -C / usr/include usr/ccs/lib usr/lib" > sysroot.tar.gz.tmp mv sysroot.tar.gz.tmp sysroot.tar.gz else - echo "error: no sysroot.tar.gz, and neither SOL9_SYSROOT_URL nor SOL9_HOST is set." >&2 + echo "error: no sysroot.tar.gz, and neither SOL9_SPARC64_SYSROOT_URL nor SOL9_HOST is set." >&2 exit 1 fi fi diff --git a/docker/sol9-cross/mksysroot.sh b/docker/sol9-cross/mksysroot.sh index 71542be9..8404d922 100755 --- a/docker/sol9-cross/mksysroot.sh +++ b/docker/sol9-cross/mksysroot.sh @@ -33,12 +33,30 @@ disk) # /lib matters on Solaris 10, where the core libraries live there and /usr/lib holds # symlinks pointing back; on Solaris 9 /lib is itself a symlink to usr/lib and this # copy finds nothing. Take both and let the trailing check decide which happened. + # `get -r` lays a directory source out *under* the destination, so pass the parent: + # naming the directory itself would give usr/include/include. + ERR="$OUT/sysroot-build/get.err"; LINKS="$OUT/sysroot-build/symlinks.tsv"; : > "$LINKS" for d in usr/include usr/lib usr/ccs/lib lib; do echo " $d" - mkdir -p "$ROOT/$(dirname "$d")" - "$RB" get "$SRC@$SLICE" "/$d" "$ROOT/$d" -r >/dev/null 2>&1 || \ + parent="$ROOT/$(dirname "$d")" + mkdir -p "$parent" + "$RB" get "$SRC@$SLICE" "/$d" "$parent" -r >/dev/null 2>"$ERR" || \ echo " (skipped $d -- not present on this install)" >&2 + # rb-cli writes a symlink as a text file holding its target, and names each one it + # did that to; collect them so they can be turned back into links below. + sed -n 's/^ symlink as text: \(.*\) -> \(.*\) (use platform tools.*$/\1\t\2/p' \ + "$ERR" >> "$LINKS" done + + # Without this every symlink is a plain file, so `-lc` finds a 12-byte text libc.so + # and the link fails far from the cause. + if [ -s "$LINKS" ]; then + echo "==> Recreating $(wc -l < "$LINKS" | tr -d " ") symlinks" + while IFS="$(printf "\t")" read -r dst target; do + [ -n "$dst" ] && [ -n "$target" ] || continue + rm -f "$dst" && ln -s "$target" "$dst" + done < "$LINKS" + fi ;; media9) command -v xorriso >/dev/null || { echo "error: xorriso is required" >&2; exit 1; } diff --git a/docker/sol9.Dockerfile b/docker/sol9.Dockerfile index bedf22ca..5cb3f1f5 100644 --- a/docker/sol9.Dockerfile +++ b/docker/sol9.Dockerfile @@ -63,10 +63,13 @@ RUN make -f minicargo.mk LIBS \ # scripts/build-sol9.sh reads every path from the environment, so the image only # has to say where things ended up. +# SOL9_LIBGCC is explicit because its default is under $SOL9_TOOLCHAIN, which does not +# exist in this image -- the `dist` stage would refuse to package without it. ENV MRUSTC_DIR=/opt/mrustc \ RB_DIR=/src \ SOL9_BIN=/opt/sol9/bin \ SOL9_SYSROOT=/opt/sol9/sysroot \ + SOL9_LIBGCC=/opt/sol9/${TARGET}/lib/sparcv9/libgcc_s.so.1 \ RUSTC_VERSION=1.74.0 WORKDIR /src From f96e2704b5b8245e7c9f2c2c8e4811019ec5c710 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 17:51:13 -0400 Subject: [PATCH 03/14] ci(sol9): put the Solaris build on a matrix and name it for sparc64 Every other platform leg is a matrix over `arch` and uploads `rb-cli---.tar.gz`; the Solaris job was the one exception, producing a bare `rb-cli-sol9` artifact with no arch and no version. Bring it into line: one matrix row, `arch: sparc64`, artifact `rb-cli-solaris9-sparc64-.tar.gz`. The row carries the mrustc triple and threads it into the container as a build arg and an environment variable, so the matrix value is what actually selects the target rather than the script's default. Solaris also shipped on x86 and the sysroot secret is already named per-arch, so a second target is a row here rather than another job. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9299d236..85187a55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1513,7 +1513,7 @@ jobs: run: sudo chmod -R a+rX buildroot/dl buildroot/ccache 2>/dev/null || true build-sol9-sparc: - name: Build rb-cli for Solaris 9 (SPARC) + name: Build rb-cli for Solaris 9 (${{ matrix.arch }}) # Only runs where the sysroot secret exists. A Solaris 9 sysroot is a copy of a # licensed install's headers and libraries, so it cannot live in the repo or a # public registry -- SOL9_SPARC64_SYSROOT_URL points at a private copy. Without it the @@ -1524,9 +1524,18 @@ jobs: # Non-fatal like the other vintage-media jobs: this one also depends on a # self-hosted download and a ~40 minute toolchain build. continue-on-error: true + strategy: + # One entry today. Solaris also ran on x86, and the sysroot secret is already named + # per-arch, so a second leg is a matrix row rather than a second job. + fail-fast: false + matrix: + include: + - arch: sparc64 + target: sparcv9-sun-solaris2.9 env: VER: ${{ needs.generate-version.outputs.version }} SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} + SOL9_TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@v6 @@ -1560,23 +1569,23 @@ jobs: if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit == 'true' run: docker load -i /tmp/mrustc-sol9-cross.tar - - name: Build rb-cli for sparcv9-sun-solaris2.9 + - name: Build rb-cli for ${{ matrix.target }} if: steps.gate.outputs.run == 'true' run: | - docker build -t rb-sol9 - < docker/sol9.Dockerfile - docker run --rm -v "$PWD":/src -e RELEASE_VERSION="$VER" rb-sol9 + docker build -t rb-sol9 --build-arg "TARGET=$SOL9_TARGET" - < docker/sol9.Dockerfile + docker run --rm -v "$PWD":/src -e RELEASE_VERSION="$VER" -e SOL9_TARGET rb-sol9 ls -lh dist/rb-cli-sol9.tar.gz - name: Name the artifact for the release if: steps.gate.outputs.run == 'true' - run: cp dist/rb-cli-sol9.tar.gz "dist/rb-cli-sol9-${VER}.tar.gz" + run: cp dist/rb-cli-sol9.tar.gz "dist/rb-cli-solaris9-${{ matrix.arch }}-${VER}.tar.gz" - name: Upload the Solaris 9 artifact if: steps.gate.outputs.run == 'true' uses: actions/upload-artifact@v7 with: - name: rb-cli-sol9 - path: dist/rb-cli-sol9-*.tar.gz + name: rb-cli-solaris9-${{ matrix.arch }}-${{ needs.generate-version.outputs.version }} + path: dist/rb-cli-solaris9-${{ matrix.arch }}-*.tar.gz build-cb-dos: name: Build cb-dos media (FreeDOS floppy + CD) From 39f8b96ddf07be6fc1dea3714fdeb68f89ba64ae Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 18:04:18 -0400 Subject: [PATCH 04/14] ci(sol9): host the prebuilt toolchain, and stop rejecting valid sysroots Two fixes to the first real run of this job. The toolchain no longer has to be built in CI. The image already contains the sysroot, so it is exactly as unpublishable and exactly as hostable as the sysroot is; hosting it turns a ~40 minute GCC 4.9.4 build into a 262 MB download. Set SOL9_SPARC64_TOOLCHAIN_URL to a `docker save mrustc-sol9-cross | gzip` and the job fetches it; building from the sysroot stays the fallback when that secret is absent, and the run cache still short-circuits both. With the toolchain hosted the sysroot secret is optional, so the gate now accepts either. The sysroot layout check named `usr/include/stdio.h` as an exact tar member, which rejects a perfectly good `./usr/...` tarball -- what `tar -c .` produces, as against `tar -C / -c usr`. Match both, and on failure print the tarball's top-level entries so a wrong upload identifies itself instead of costing a round trip. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 21 ++++++++++++++++++--- docker/sol9-cross/build.sh | 10 ++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85187a55..cd7bdd21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1535,6 +1535,7 @@ jobs: env: VER: ${{ needs.generate-version.outputs.version }} SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} + SOL9_SPARC64_TOOLCHAIN_URL: ${{ secrets.SOL9_SPARC64_TOOLCHAIN_URL }} SOL9_TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@v6 @@ -1542,8 +1543,8 @@ jobs: - name: Check the sysroot secret is configured id: gate run: | - if [ -z "$SOL9_SPARC64_SYSROOT_URL" ]; then - echo "SOL9_SPARC64_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 + if [ -z "$SOL9_SPARC64_SYSROOT_URL" ] && [ -z "$SOL9_SPARC64_TOOLCHAIN_URL" ]; then + echo "Neither SOL9_SPARC64_SYSROOT_URL nor SOL9_SPARC64_TOOLCHAIN_URL is set; skipping." >&2 echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" @@ -1559,8 +1560,22 @@ jobs: path: /tmp/mrustc-sol9-cross.tar key: sol9-cross-${{ hashFiles('docker/sol9-cross/Dockerfile') }} + # A prebuilt image is worth hosting next to the sysroot: it already contains that + # sysroot, so it is no more publishable and no harder to host, and it turns a + # ~40 minute GCC 4.9.4 build into a download. Building stays the fallback. + - name: Fetch the prebuilt cross-toolchain image + id: toolchain-fetch + if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' && env.SOL9_SPARC64_TOOLCHAIN_URL != '' + run: | + curl -fsSL "$SOL9_SPARC64_TOOLCHAIN_URL" -o /tmp/toolchain.tar.gz + gzip -t /tmp/toolchain.tar.gz || { echo "toolchain download is not valid gzip" >&2; exit 1; } + gunzip -c /tmp/toolchain.tar.gz > /tmp/mrustc-sol9-cross.tar + docker load -i /tmp/mrustc-sol9-cross.tar + docker image inspect mrustc-sol9-cross >/dev/null + echo "fetched=true" >> "$GITHUB_OUTPUT" + - name: Build the cross-toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' + if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' && steps.toolchain-fetch.outputs.fetched != 'true' run: | sh docker/sol9-cross/build.sh docker save mrustc-sol9-cross -o /tmp/mrustc-sol9-cross.tar diff --git a/docker/sol9-cross/build.sh b/docker/sol9-cross/build.sh index 2fe51665..e4efe8cc 100755 --- a/docker/sol9-cross/build.sh +++ b/docker/sol9-cross/build.sh @@ -38,8 +38,14 @@ fi # A truncated or HTML-error download fails deep inside the GCC build otherwise. gzip -t sysroot.tar.gz || { echo "error: sysroot.tar.gz is not a valid gzip file" >&2; exit 1; } -tar tzf sysroot.tar.gz usr/include/stdio.h >/dev/null 2>&1 \ - || { echo "error: sysroot.tar.gz has no usr/include/stdio.h; is it rooted at / ?" >&2; exit 1; } +# Accept both `usr/...` and `./usr/...`: `tar -C / -c usr` gives the first, `tar -c .` the +# second, and both unpack identically. Naming the member exactly would reject the second. +if ! tar tzf sysroot.tar.gz | grep -qE '^(\./)?usr/include/stdio\.h$'; then + echo "error: sysroot.tar.gz has no usr/include/stdio.h; is it rooted at / ?" >&2 + echo " its top-level entries are:" >&2 + tar tzf sysroot.tar.gz | sed 's|^\./||' | awk -F/ 'NF>1{print $1"/"$2}' | sort -u | head -10 >&2 + exit 1 +fi echo "==> Building $IMAGE" exec docker build -t "$IMAGE" "$@" . From 49a997faddc8edcc9b9d65990539bc77c58f1aeb Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 18:37:34 -0400 Subject: [PATCH 05/14] fix(sol9): move the toolchain base off EOL Debian 11 bullseye reached EOL and its security suite is mid-migration: deb.debian.org still serves the Release file but the packages behind it 404, and archive.debian.org has no bullseye-security to fall back to. The base image already carries deb11u14 packages that archive's main (deb11u11) cannot satisfy, so pointing at the archive gives unmet dependencies instead. There is no version of that pin left to hold, so move to debian:12-slim. The Dockerfile always said the pin was for reproducibility rather than necessity -- GCC 4.9.4 is verified building under host GCC 13.3, and Debian 12 gives 12.2, inside that range. Also stop `tar | grep -q` killing tar with SIGPIPE, which printed "tar: stdout: write error" directly above the real failure and read like the cause. Co-Authored-By: Claude Opus 5 --- docker/sol9-cross/Dockerfile | 7 +++++-- docker/sol9-cross/build.sh | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docker/sol9-cross/Dockerfile b/docker/sol9-cross/Dockerfile index f1de8e96..61f23c8e 100644 --- a/docker/sol9-cross/Dockerfile +++ b/docker/sol9-cross/Dockerfile @@ -5,7 +5,10 @@ # the port in the next release, which shipped as GCC 5. So 4.9.4 is the newest GCC # that can target Solaris 9 at all. GCC 4.9's own sources predate C++11-strict compilers # and need -std=gnu++98 -fpermissive; with those it does build under a current host GCC -# (verified on 13.3), so the Debian 11 (GCC 10) pin is for reproducibility, not necessity. +# (verified on 13.3), so the base image pin is for reproducibility, not necessity. It was +# Debian 11 until bullseye went EOL: apt then rejected the expired security Release file, +# and the packages behind it 404'd while archive.debian.org had no bullseye-security to +# fall back to, so there was no version of that pin left to hold. # # GCC 4.9 has but not __builtin_{add,sub,mul}_overflow, which arrived in # GCC 5. mrustc covers that gap by emitting its own helpers -- see the @@ -14,7 +17,7 @@ # The sysroot is not downloadable: Sun's headers and libraries are not redistributable. # Supply sysroot.tar.gz in the build context, as ./build.sh does. See README.md. -ARG BASE=debian:11-slim +ARG BASE=debian:12-slim FROM ${BASE} AS builder diff --git a/docker/sol9-cross/build.sh b/docker/sol9-cross/build.sh index e4efe8cc..8ef90100 100755 --- a/docker/sol9-cross/build.sh +++ b/docker/sol9-cross/build.sh @@ -40,10 +40,11 @@ fi gzip -t sysroot.tar.gz || { echo "error: sysroot.tar.gz is not a valid gzip file" >&2; exit 1; } # Accept both `usr/...` and `./usr/...`: `tar -C / -c usr` gives the first, `tar -c .` the # second, and both unpack identically. Naming the member exactly would reject the second. -if ! tar tzf sysroot.tar.gz | grep -qE '^(\./)?usr/include/stdio\.h$'; then +listing="$(tar tzf sysroot.tar.gz)" +if ! printf '%s\n' "$listing" | grep -qE '^(\./)?usr/include/stdio\.h$'; then echo "error: sysroot.tar.gz has no usr/include/stdio.h; is it rooted at / ?" >&2 echo " its top-level entries are:" >&2 - tar tzf sysroot.tar.gz | sed 's|^\./||' | awk -F/ 'NF>1{print $1"/"$2}' | sort -u | head -10 >&2 + printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ 'NF>1{print $1"/"$2}' | sort -u | head -10 >&2 exit 1 fi From c04ee40f56b7219c835e1a00b7633762608e7883 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 18:42:52 -0400 Subject: [PATCH 06/14] ci(sol9): one secret, and detect which artifact it points at SOL9_SPARC64_SYSROOT_URL now accepts either a sysroot or a `docker save` of the toolchain image, and the job sniffs the download to decide: manifest.json means load it, usr/include/stdio.h means build from it, anything else is rejected with the tarball's top-level entries so a wrong upload names itself. Both artifacts carry Sun's headers, so both are equally unpublishable and equally hostable -- which made a second secret pure bookkeeping, and the wrong file has already been uploaded to the wrong one twice today. A saved image also skips the ~40 minute GCC build entirely. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 54 ++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd7bdd21..a4a7513d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: type: boolean default: true sol9: - description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SPARC64_SYSROOT_URL secret)' + description: 'Build rb-cli for Solaris 9 SPARC (SOL9_SPARC64_SYSROOT_URL: a sysroot or a saved toolchain image)' type: boolean default: true runner_image: @@ -1535,7 +1535,6 @@ jobs: env: VER: ${{ needs.generate-version.outputs.version }} SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} - SOL9_SPARC64_TOOLCHAIN_URL: ${{ secrets.SOL9_SPARC64_TOOLCHAIN_URL }} SOL9_TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@v6 @@ -1543,8 +1542,8 @@ jobs: - name: Check the sysroot secret is configured id: gate run: | - if [ -z "$SOL9_SPARC64_SYSROOT_URL" ] && [ -z "$SOL9_SPARC64_TOOLCHAIN_URL" ]; then - echo "Neither SOL9_SPARC64_SYSROOT_URL nor SOL9_SPARC64_TOOLCHAIN_URL is set; skipping." >&2 + if [ -z "$SOL9_SPARC64_SYSROOT_URL" ]; then + echo "SOL9_SPARC64_SYSROOT_URL is not set; skipping the Solaris 9 build." >&2 echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" @@ -1560,25 +1559,34 @@ jobs: path: /tmp/mrustc-sol9-cross.tar key: sol9-cross-${{ hashFiles('docker/sol9-cross/Dockerfile') }} - # A prebuilt image is worth hosting next to the sysroot: it already contains that - # sysroot, so it is no more publishable and no harder to host, and it turns a - # ~40 minute GCC 4.9.4 build into a download. Building stays the fallback. - - name: Fetch the prebuilt cross-toolchain image - id: toolchain-fetch - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' && env.SOL9_SPARC64_TOOLCHAIN_URL != '' - run: | - curl -fsSL "$SOL9_SPARC64_TOOLCHAIN_URL" -o /tmp/toolchain.tar.gz - gzip -t /tmp/toolchain.tar.gz || { echo "toolchain download is not valid gzip" >&2; exit 1; } - gunzip -c /tmp/toolchain.tar.gz > /tmp/mrustc-sol9-cross.tar - docker load -i /tmp/mrustc-sol9-cross.tar - docker image inspect mrustc-sol9-cross >/dev/null - echo "fetched=true" >> "$GITHUB_OUTPUT" - - - name: Build the cross-toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' && steps.toolchain-fetch.outputs.fetched != 'true' - run: | - sh docker/sol9-cross/build.sh - docker save mrustc-sol9-cross -o /tmp/mrustc-sol9-cross.tar + # The URL may hold either artifact, and both are equally unpublishable because both + # carry Sun's headers. A prebuilt image skips a ~40 minute GCC build, so prefer it; + # a sysroot is the fallback that builds one. Sniff the download rather than making + # its shape a second secret to keep straight. + - name: Provide the cross-toolchain image + if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' + run: | + curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o /tmp/sol9-artifact.tar.gz + gzip -t /tmp/sol9-artifact.tar.gz \ + || { echo "the SOL9_SPARC64_SYSROOT_URL download is not valid gzip" >&2; exit 1; } + listing="$(tar tzf /tmp/sol9-artifact.tar.gz)" + if printf '%s\n' "$listing" | grep -qE '^(\./)?manifest\.json$'; then + echo "==> Artifact is a saved Docker image; loading it" + gunzip -c /tmp/sol9-artifact.tar.gz > /tmp/mrustc-sol9-cross.tar + docker load -i /tmp/mrustc-sol9-cross.tar + docker image inspect mrustc-sol9-cross >/dev/null \ + || { echo "that image does not provide the mrustc-sol9-cross tag" >&2; exit 1; } + elif printf '%s\n' "$listing" | grep -qE '^(\./)?usr/include/stdio\.h$'; then + echo "==> Artifact is a sysroot; building the toolchain from it (~40 min)" + cp /tmp/sol9-artifact.tar.gz docker/sol9-cross/sysroot.tar.gz + sh docker/sol9-cross/build.sh + docker save mrustc-sol9-cross -o /tmp/mrustc-sol9-cross.tar + else + echo "error: the artifact is neither a saved Docker image nor a sysroot." >&2 + echo " its top-level entries are:" >&2 + printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ '{print $1}' | sort -u | head -10 >&2 + exit 1 + fi - name: Load the cached toolchain image if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit == 'true' From eba26c7e39d9b68e55847d75233585daf039df50 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 8 Sep 2026 21:06:34 -0400 Subject: [PATCH 07/14] ci(sol9): build from a 47 MB seed instead of a container CI was reconstructing, on every run, an environment that already exists on a developer machine: ~40 minutes building GCC 4.9.4 from source in a Debian image, then ~40 more rebuilding mrustc and both standard libraries. It now downloads a seed -- the cross toolchain, its sysroot, mrustc and the two prebuilt stdlibs -- unpacks it at /, and runs scripts/build-sol9.sh. 47 MB against 7 GB for the equivalent image, and about 20 minutes against 100. The container was also a liability. Debian 11 reached EOL mid-flight and broke the build twice in one evening, first through an expired Release file and then through its security packages 404ing out of the pool, with archive.debian.org carrying no bullseye-security to fall back on. None of that touches a seed. Two new scripts replace the three Dockerfiles: * build-sol9-toolchain.sh builds binutils and GCC 4.9.4 natively. GitHub's ubuntu-latest and a current desktop are both Ubuntu 24.04 with GCC 13.3, and 4.9.4 builds under that with -std=gnu++98 -fpermissive -- so the toolchain is built on the OS that will run it. (A 4.9.4 built on Debian 12 also runs unmodified on 24.04; glibc is forward compatible.) * pack-sol9-seed.sh packs the seed. It keeps *.rlib.hir: the .rlib is a 0-byte marker and .hir is where mrustc keeps the crate metadata, so stripping it as an intermediate yields a seed where every dependent crate dies with "Unable to deserialise crate metadata". Dropping the emitted *.rlib.c is the size win. mksysroot.sh moves to scripts/mksysroot-solaris.sh -- extracting a sysroot was never docker-specific. The job also now takes a rust toolchain the way the rest of the workflow does: Ubuntu's packaged cargo is too old for `cargo vendor` here, since a crate in the graph needs edition2024. Verified end to end in a bare ubuntu:24.04 container -- eight apt packages plus the seed -- producing a 16 MB bundle whose rb-cli is SPARC V9 with only the three weak GCC/GCJ hooks unresolved, matching the binary this machine builds. Co-Authored-By: Claude Opus 5 --- .dockerignore | 11 ++ .github/workflows/release.yml | 91 +++++++------- .gitignore | 4 +- docker/README.md | 19 ++- docker/sol9-cross/Dockerfile | 113 ------------------ docker/sol9-cross/README.md | 94 --------------- docker/sol9-cross/build.sh | 52 -------- docker/sol9.Dockerfile | 76 ------------ docs/build-sol9-mrustc.md | 77 ++++++------ scripts/build-sol9-toolchain.sh | 98 +++++++++++++++ scripts/build-sol9.sh | 2 +- .../mksysroot-solaris.sh | 0 scripts/pack-sol9-seed.sh | 69 +++++++++++ 13 files changed, 277 insertions(+), 429 deletions(-) create mode 100644 .dockerignore delete mode 100644 docker/sol9-cross/Dockerfile delete mode 100644 docker/sol9-cross/README.md delete mode 100755 docker/sol9-cross/build.sh delete mode 100644 docker/sol9.Dockerfile create mode 100755 scripts/build-sol9-toolchain.sh rename docker/sol9-cross/mksysroot.sh => scripts/mksysroot-solaris.sh (100%) create mode 100755 scripts/pack-sol9-seed.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9bdb64a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# The build context is sent to the daemon in full before anything runs, and this repo is +# ~20 GB of test corpora and vendored trees. Only docker/sol9-cross/ ships a file (the +# sysroot), and it builds from its own directory, so exclude the heavy trees for anyone +# who builds with `.` as the context. +regression-tests/ +rb-cli-vintage/ +rb-cli-ppc/ +rb-cli-sol9/vendor/ +target/ +dist/ +.git/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4a7513d..3aa56471 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ on: type: boolean default: true sol9: - description: 'Build rb-cli for Solaris 9 SPARC (SOL9_SPARC64_SYSROOT_URL: a sysroot or a saved toolchain image)' + description: 'Build rb-cli for Solaris 9 SPARC (needs the SOL9_SPARC64_SYSROOT_URL seed secret)' type: boolean default: true runner_image: @@ -1514,19 +1514,18 @@ jobs: build-sol9-sparc: name: Build rb-cli for Solaris 9 (${{ matrix.arch }}) - # Only runs where the sysroot secret exists. A Solaris 9 sysroot is a copy of a - # licensed install's headers and libraries, so it cannot live in the repo or a - # public registry -- SOL9_SPARC64_SYSROOT_URL points at a private copy. Without it the - # job skips rather than failing, so forks still get a green pipeline. + # Only runs where the seed secret exists. The seed carries Sun's headers and libraries, + # so it cannot live in the repo or a public registry -- SOL9_SPARC64_SYSROOT_URL points + # at a private copy. Without it the job skips rather than failing, so forks still get a + # green pipeline. if: ${{ github.event_name != 'workflow_dispatch' || inputs.sol9 }} needs: generate-version runs-on: ubuntu-latest - # Non-fatal like the other vintage-media jobs: this one also depends on a - # self-hosted download and a ~40 minute toolchain build. + # Non-fatal like the other vintage-media jobs: it depends on a self-hosted download. continue-on-error: true strategy: - # One entry today. Solaris also ran on x86, and the sysroot secret is already named - # per-arch, so a second leg is a matrix row rather than a second job. + # One entry today. Solaris also ran on x86, and the secret is already named per-arch, + # so a second target is a matrix row rather than a second job. fail-fast: false matrix: include: @@ -1536,10 +1535,21 @@ jobs: VER: ${{ needs.generate-version.outputs.version }} SOL9_SPARC64_SYSROOT_URL: ${{ secrets.SOL9_SPARC64_SYSROOT_URL }} SOL9_TARGET: ${{ matrix.target }} + # Where scripts/pack-sol9-seed.sh stages things. GCC bakes its sysroot path at + # configure time, so the tree has to land where it was configured. + SOL9_BIN: /opt/sol9/bin + SOL9_SYSROOT: /opt/sol9/sysroot + SOL9_LIBGCC: /opt/sol9/${{ matrix.target }}/lib/sparcv9/libgcc_s.so.1 + MRUSTC_DIR: /opt/mrustc steps: - uses: actions/checkout@v6 - - name: Check the sysroot secret is configured + # `cargo vendor` only. Nothing here is built with it -- mrustc compiles the engine, + # and its language mode is pinned to 1.74. Ubuntu's packaged cargo is too old: a + # dependency in the graph needs edition2024. + - uses: dtolnay/rust-toolchain@stable + + - name: Check the seed secret is configured id: gate run: | if [ -z "$SOL9_SPARC64_SYSROOT_URL" ]; then @@ -1549,54 +1559,41 @@ jobs: echo "run=true" >> "$GITHUB_OUTPUT" fi - # GCC 4.9.4 + binutils 2.35.2 against the sysroot. Cache it: the toolchain - # only changes when docker/sol9-cross does, and rebuilding costs ~40 minutes. - - name: Restore the cross-toolchain image + # libgmp/libmpfr/libmpc/zlib are what GCC 4.9.4 links; python3 runs + # apply-vendor-patches.py, and patch/make/file/rsync are used by build-sol9.sh. + # Everything else it needs is in the seed. Building the toolchain here instead would + # cost ~40 minutes and put us at the mercy of whichever Debian release just went EOL. + - name: Install what the cross GCC links against if: steps.gate.outputs.run == 'true' - id: toolchain-cache - uses: actions/cache@v4 - with: - path: /tmp/mrustc-sol9-cross.tar - key: sol9-cross-${{ hashFiles('docker/sol9-cross/Dockerfile') }} + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + libgmp10 libmpfr6 libmpc3 zlib1g file rsync python3 patch make - # The URL may hold either artifact, and both are equally unpublishable because both - # carry Sun's headers. A prebuilt image skips a ~40 minute GCC build, so prefer it; - # a sysroot is the fallback that builds one. Sniff the download rather than making - # its shape a second secret to keep straight. - - name: Provide the cross-toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit != 'true' + # The seed is the cross toolchain, its sysroot, mrustc and both prebuilt standard + # libraries -- about 43 MB, against 7 GB for the equivalent container image. + - name: Fetch and unpack the build seed + if: steps.gate.outputs.run == 'true' run: | - curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o /tmp/sol9-artifact.tar.gz - gzip -t /tmp/sol9-artifact.tar.gz \ + curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o /tmp/sol9-seed.tar.gz + gzip -t /tmp/sol9-seed.tar.gz \ || { echo "the SOL9_SPARC64_SYSROOT_URL download is not valid gzip" >&2; exit 1; } - listing="$(tar tzf /tmp/sol9-artifact.tar.gz)" - if printf '%s\n' "$listing" | grep -qE '^(\./)?manifest\.json$'; then - echo "==> Artifact is a saved Docker image; loading it" - gunzip -c /tmp/sol9-artifact.tar.gz > /tmp/mrustc-sol9-cross.tar - docker load -i /tmp/mrustc-sol9-cross.tar - docker image inspect mrustc-sol9-cross >/dev/null \ - || { echo "that image does not provide the mrustc-sol9-cross tag" >&2; exit 1; } - elif printf '%s\n' "$listing" | grep -qE '^(\./)?usr/include/stdio\.h$'; then - echo "==> Artifact is a sysroot; building the toolchain from it (~40 min)" - cp /tmp/sol9-artifact.tar.gz docker/sol9-cross/sysroot.tar.gz - sh docker/sol9-cross/build.sh - docker save mrustc-sol9-cross -o /tmp/mrustc-sol9-cross.tar - else - echo "error: the artifact is neither a saved Docker image nor a sysroot." >&2 + listing="$(tar tzf /tmp/sol9-seed.tar.gz)" + if ! printf '%s\n' "$listing" | grep -qE '^(\./)?opt/sol9/bin/'; then + echo "error: that artifact is not a seed built by scripts/pack-sol9-seed.sh." >&2 echo " its top-level entries are:" >&2 - printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ '{print $1}' | sort -u | head -10 >&2 + printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ '{print $1"/"$2}' | sort -u | head -10 >&2 exit 1 fi - - - name: Load the cached toolchain image - if: steps.gate.outputs.run == 'true' && steps.toolchain-cache.outputs.cache-hit == 'true' - run: docker load -i /tmp/mrustc-sol9-cross.tar + sudo tar xzf /tmp/sol9-seed.tar.gz -C / + "$SOL9_BIN/$SOL9_TARGET-gcc" --version | head -1 - name: Build rb-cli for ${{ matrix.target }} if: steps.gate.outputs.run == 'true' run: | - docker build -t rb-sol9 --build-arg "TARGET=$SOL9_TARGET" - < docker/sol9.Dockerfile - docker run --rm -v "$PWD":/src -e RELEASE_VERSION="$VER" -e SOL9_TARGET rb-sol9 + RELEASE_VERSION="$VER" scripts/build-sol9.sh vendor + RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9 + RELEASE_VERSION="$VER" scripts/build-sol9.sh dist ls -lh dist/rb-cli-sol9.tar.gz - name: Name the artifact for the release diff --git a/.gitignore b/.gitignore index d5ab6b9c..fd8f5702 100644 --- a/.gitignore +++ b/.gitignore @@ -99,5 +99,7 @@ regression-tests/**/*.local.toml # tree means a coverage change shows up in a diff. Regenerate rather than edit. # Solaris 9 sysroot: a licensed install's headers and libraries, never committed. -docker/sol9-cross/sysroot.tar.gz +# Licensed Sun headers and libraries, and the CI seed built from them; never commit one. +sol9-seed.tar.gz +sol9-sysroot.tar.gz docker/sol9-cross/sysroot.tar.gz.tmp diff --git a/docker/README.md b/docker/README.md index 5b439441..a9199727 100644 --- a/docker/README.md +++ b/docker/README.md @@ -12,22 +12,19 @@ builds**, so a build is a one-liner. Linux cross artifacts land in | `cross-i586-musl.Dockerfile` | `rb-cli` i586 | **static** (musl) | Pentium+ Linux | ✅ runs | | `cross-i486.Dockerfile` | `rb-cli` i486 | dynamic (glibc) | 486+ Linux¹ | ✅ builds | | `cb-dos.Dockerfile` | DOS `.exe`s | — | DOS (486+) | ✅ runs (DOSBox-X) | -| `sol9.Dockerfile` | `rb-cli` sparcv9 | dynamic (Solaris) | Solaris 9+ SPARC² | ✅ runs (Blade 2500) | ¹ i486 *codegen* (no CMPXCHG8B), but Debian's 32-bit glibc is i686-baseline, so the binary needs an **i486 rootfs** (Buildroot/musl) to actually run on bare 486 hardware — see below. -² Solaris 9 is the one **vintage** target that cross-builds end to end on Linux, -so unlike the PowerPC Mac build it can run unattended in the release pipeline. -Its base image is the exception to the one-liner above: it needs a Solaris 9 -sysroot, which Sun does not permit redistributing, so the image cannot be -published and has to be built once from a Solaris 9 sysroot (`docker/sol9-cross/`, -which CI fetches via the `SOL9_SPARC64_SYSROOT_URL` secret; `Notes/SolarisSysroot.md` in the -mrustc tree builds one from scratch). Full recipe in -`docker/sol9.Dockerfile` and [`docs/build-sol9-mrustc.md`](../docs/build-sol9-mrustc.md). -Verified on a Sun Blade 2500 (SunOS 5.9, sun4u): both parity gates agree with -the desktop build byte for byte, and the ratatui TUI runs. +**Solaris 9 SPARC is no longer here.** It used to build through a container. GCC 4.9.4 is the newest compiler +that can target it, and it builds fine on a current host, so the toolchain is built +natively by [`scripts/build-sol9-toolchain.sh`](../scripts/build-sol9-toolchain.sh) and +packed for CI by [`scripts/pack-sol9-seed.sh`](../scripts/pack-sol9-seed.sh) -- a 47 MB +seed against the 7 GB image the container approach needed, because it carries only the +toolchain, the sysroot, mrustc and the two prebuilt standard libraries. The release +pipeline unpacks that seed and runs `scripts/build-sol9.sh`; it installs no compiler and +builds no image. See [`docs/build-sol9-mrustc.md`](../docs/build-sol9-mrustc.md). ```sh # build the toolchain image, then build the artifact (default CMD): diff --git a/docker/sol9-cross/Dockerfile b/docker/sol9-cross/Dockerfile deleted file mode 100644 index 61f23c8e..00000000 --- a/docker/sol9-cross/Dockerfile +++ /dev/null @@ -1,113 +0,0 @@ -# Cross toolchain: x86_64 Linux -> 64-bit SPARC Solaris 9 (sparcv9-sun-solaris2.9) -# -# Why 4.9.4, and why an old base image: -# GCC obsoleted Solaris 9 in 4.9 (buildable only with --enable-obsolete) and deleted -# the port in the next release, which shipped as GCC 5. So 4.9.4 is the newest GCC -# that can target Solaris 9 at all. GCC 4.9's own sources predate C++11-strict compilers -# and need -std=gnu++98 -fpermissive; with those it does build under a current host GCC -# (verified on 13.3), so the base image pin is for reproducibility, not necessity. It was -# Debian 11 until bullseye went EOL: apt then rejected the expired security Release file, -# and the packages behind it 404'd while archive.debian.org had no bullseye-security to -# fall back to, so there was no version of that pin left to hold. -# -# GCC 4.9 has but not __builtin_{add,sub,mul}_overflow, which arrived in -# GCC 5. mrustc covers that gap by emitting its own helpers -- see the -# `emulate-overflow-intrinsics` target flag used by the sparcv9-sun-solaris2.9 target. -# -# The sysroot is not downloadable: Sun's headers and libraries are not redistributable. -# Supply sysroot.tar.gz in the build context, as ./build.sh does. See README.md. - -ARG BASE=debian:12-slim - -FROM ${BASE} AS builder - -ARG BINUTILS_VERSION=2.35.2 -ARG GCC_VERSION=4.9.4 -ARG TARGET=sparcv9-sun-solaris2.9 -ARG PREFIX=/opt/sol9 - -ENV SYSROOT=${PREFIX}/sysroot - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential ca-certificates curl xz-utils bzip2 file \ - libgmp-dev libmpfr-dev libmpc-dev m4 texinfo \ - && rm -rf /var/lib/apt/lists/* - -# Solaris 9 ships no crt1.o/crti.o/crtn.o -- on Solaris those come with the compiler, -# not the OS -- so only headers and libraries are needed here. GCC builds its own crt -# files from libgcc/config/sparc/sol2-c1.S. -COPY sysroot.tar.gz /tmp/ -RUN mkdir -p "${SYSROOT}" \ - && tar xzf /tmp/sysroot.tar.gz -C "${SYSROOT}" \ - && rm /tmp/sysroot.tar.gz \ - && ln -sfn usr/lib "${SYSROOT}/lib" - -WORKDIR /src -RUN curl -fsSLO "https://ftp.gnu.org/gnu/binutils/binutils-${BINUTILS_VERSION}.tar.xz" \ - && tar xJf "binutils-${BINUTILS_VERSION}.tar.xz" \ - && rm "binutils-${BINUTILS_VERSION}.tar.xz" -RUN curl -fsSLO "https://ftp.gnu.org/gnu/gcc/gcc-${GCC_VERSION}/gcc-${GCC_VERSION}.tar.bz2" \ - && tar xjf "gcc-${GCC_VERSION}.tar.bz2" \ - && rm "gcc-${GCC_VERSION}.tar.bz2" - -# GNU as/ld: Solaris' own are not available to a cross build, and mrustc's target spec -# already avoids the GNU-only linker options that Solaris ld lacks. -RUN mkdir -p /build/binutils && cd /build/binutils \ - && "/src/binutils-${BINUTILS_VERSION}/configure" \ - --target="${TARGET}" --prefix="${PREFIX}" --with-sysroot="${SYSROOT}" \ - --disable-nls --disable-werror \ - && make -j"$(nproc)" && make install-strip \ - && cd / && rm -rf /build/binutils - -ENV PATH=${PREFIX}/bin:${PATH} - -# -std=gnu++98 -fpermissive: GCC 4.9's own sources do not compile under a C++11-or-later -# default. libsanitizer is disabled because it does not build against modern glibc headers -# and is of no use to a Rust codegen backend. -RUN mkdir -p /build/gcc && cd /build/gcc \ - && "/src/gcc-${GCC_VERSION}/configure" \ - --target="${TARGET}" --prefix="${PREFIX}" \ - --with-sysroot="${SYSROOT}" --with-build-sysroot="${SYSROOT}" \ - --enable-obsolete \ - --enable-languages=c \ - --enable-threads=posix \ - --with-gnu-as --with-gnu-ld \ - --with-as="${PREFIX}/bin/${TARGET}-as" --with-ld="${PREFIX}/bin/${TARGET}-ld" \ - --disable-nls --disable-libssp --disable-libgomp --disable-libatomic \ - --disable-libitm --disable-libsanitizer --disable-libquadmath --disable-libvtv \ - --disable-libcilkrts \ - MAKEINFO=missing \ - CFLAGS="-O2 -w" CXXFLAGS="-O2 -w -std=gnu++98 -fpermissive" \ - && make -j"$(nproc)" && make install-strip \ - && cd / && rm -rf /build/gcc /src - -# Solaris 9's defines INTPTR_MAX and UINTPTR_MAX as *empty* -# macros -- pre-C99 they were existence flags ("a pointer fits in an integral -# type"), and C99 later gave them values. GCC copies the header into include-fixed -# without fixing this, so C99 code doing `#if UINTPTR_MAX == ...` fails with -# "operator '==' has no left operand" (mbedTLS, among others). GCC's own -# __INTPTR_MAX__/__UINTPTR_MAX__ are per-multilib, so this is right for both. -RUN H="${PREFIX}/lib/gcc/${TARGET}/${GCC_VERSION}/include-fixed/sys/int_limits.h" \ - && sed -i -e 's|^#define[ \t]*INTPTR_MAX[ \t]*$|#define\tINTPTR_MAX\t__INTPTR_MAX__|' \ - -e 's|^#define[ \t]*UINTPTR_MAX[ \t]*$|#define\tUINTPTR_MAX\t__UINTPTR_MAX__|' "$H" \ - && grep -q '__UINTPTR_MAX__' "$H" - - -FROM ${BASE} - -ARG TARGET=sparcv9-sun-solaris2.9 -ARG PREFIX=/opt/sol9 - -# build-essential/python3/curl/patch are mrustc's own build and run dependencies, so this -# one image can build mrustc and then cross-compile its C output. -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential ca-certificates curl git patch python3 zlib1g-dev file \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder ${PREFIX} ${PREFIX} - -ENV PATH=${PREFIX}/bin:${PATH} -# mrustc looks for `-gcc`, overridable via CC_ -ENV CC_sparcv9_sun_solaris2_9=${TARGET}-gcc - -WORKDIR /work diff --git a/docker/sol9-cross/README.md b/docker/sol9-cross/README.md deleted file mode 100644 index 0b2aa4af..00000000 --- a/docker/sol9-cross/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# `sparcv9-sun-solaris2.9` cross toolchain - -A container holding a GCC that can compile mrustc's C output for **64-bit SPARC -Solaris 9**, plus mrustc's own build dependencies, so one image does both halves of a -cross build. - -## Why this exists - -Solaris 9 sits in a gap that no package can fill: - -| | | -|---|---| -| Newest GCC in any OpenCSW Solaris 9 catalog | 4.6.4 | -| Newest GCC that supports Solaris 9 at all | 4.9.x, and only with `--enable-obsolete` | -| Needed for `` in mrustc's output | 4.9 | -| Needed for `__builtin_{add,sub,mul}_overflow` | 5.0 | - -GCC obsoleted Solaris 9 in 4.9 and deleted the port in the next release, which shipped -as GCC 5 — so 4.9.4 is the ceiling, and it is missing the overflow builtins. mrustc -closes that gap from its side: the `sparcv9-sun-solaris2.9` target sets -`emulate-overflow-intrinsics`, and `src/trans/codegen_c.cpp` emits type-suffixed -`__builtin_*_overflow_u32`-style helpers instead of the type-generic builtins. - -GCC 4.9 needs `-std=gnu++98 -fpermissive` to build at all; with those it builds under a -current host GCC too (verified on 13.3), so the Debian 11 (GCC 10) pin is for -reproducibility rather than necessity. Building on the Blade itself was never an option — -every translation unit would compile on a 1.6GHz UltraSPARC IIIi. - -## The sysroot - -No sysroot ships here: it is a copy of a licensed Solaris 9 install's headers and -libraries, and Solaris 9 predates OpenSolaris by three years so no free substitute -exists. Whether a copy may be redistributed depends on the licence yours came -under. Building one from scratch: `Notes/SolarisSysroot.md` in the mrustc tree. Supply -one from a live Solaris 9 SPARC install: - -```sh -SOL9_HOST=user@192.168.99.176 ./build.sh -``` - -or drop your own `sysroot.tar.gz` (rooted at `/`, containing `usr/include`, `usr/lib` -and `usr/ccs/lib`) next to this file and run `./build.sh`. - -The image also patches one header. Solaris 9's `` defines -`INTPTR_MAX` and `UINTPTR_MAX` as *empty* macros: before C99 they were existence -flags rather than values, and GCC copies the header into `include-fixed` without -fixing it. Any C99 code doing `#if UINTPTR_MAX == ...` then fails with "operator -'==' has no left operand" — mbedTLS does exactly that. - -Solaris 9 ships **no `crt1.o`/`crti.o`/`crtn.o`** — on Solaris those come with the -compiler rather than the OS, and the package database has no entry for them. That is -expected and harmless: GCC builds its own from `libgcc/config/sparc/sol2-c1.S`. - -Pull the sysroot as root. Parts of `/usr/lib` are not world-readable, and a partial -sysroot fails much later and far less clearly. - -## Using it - -```sh -docker run --rm -v "$PWD:/work" -w /work mrustc-sol9-cross \ - make -f minicargo.mk LIBS \ - MRUSTC_TARGET=sparcv9-sun-solaris2.9 \ - OVERRIDE_SUFFIX=-solaris \ - STD_ENV_ARCH=sparc64 -``` - -mrustc invokes `-gcc`, so `sparcv9-sun-solaris2.9-gcc` is what it picks up off -`PATH`. `CC_sparcv9_sun_solaris2_9` overrides that (`-` becomes `_`), as does `CC`. - -`STD_ENV_ARCH=sparc64` is needed because `std::env::consts::ARCH` is otherwise derived -from the first component of the target triple, which is `sparcv9`. - -## Deploying to the target - -Solaris 9 has no `libgcc_s.so.1` of its own, and Rust's `unwind` crate asks for it by name -(`#[link(name = "gcc_s")]`), which defeats `-static-libgcc`. Install the toolchain's copy -on the target once: - -```sh -scp /opt/sol9/sparcv9-sun-solaris2.9/lib/sparcv9/libgcc_s.so.1 HOST:/tmp/ -ssh HOST 'sudo cp /tmp/libgcc_s.so.1 /usr/lib/sparcv9/ && sudo chmod 755 /usr/lib/sparcv9/libgcc_s.so.1' -``` - -The 64-bit library lives under `lib/sparcv9/`; `lib/` holds the 32-bit one, following the -Solaris multilib layout rather than GCC's usual one. - -## Notes - -- The compiler defaults to 64-bit: `sparcv9-*-solaris2*` selects `sparc/default-64.h`. - 32-bit remains available through the usual multilib, and the target spec passes - `-m64 -mcpu=v9` explicitly anyway — 32-bit SPARC has no `__int128`. -- GNU `as`/`ld` are used, since Solaris' own are not available to a cross build. The - target spec already avoids the GNU-only linker options Solaris `ld` lacks, so nothing - regresses when linking natively on the target instead. diff --git a/docker/sol9-cross/build.sh b/docker/sol9-cross/build.sh deleted file mode 100755 index 8ef90100..00000000 --- a/docker/sol9-cross/build.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -# Build the sparcv9-sun-solaris2.9 cross-toolchain image (mrustc-sol9-cross). -# -# Needs a Solaris 9 sysroot, which is the one part of this that cannot be fetched from a -# public source: it is a copy of a licensed Solaris 9 install's headers and libraries, and -# Solaris 9 predates OpenSolaris by three years so no free substitute exists. Supply one of: -# -# ./mksysroot-from-iso.sh DVD.iso build one from the install DVD -- no Solaris box needed -# SOL9_SPARC64_SYSROOT_URL=https://... download a prepared tarball (what CI uses; keep it private) -# SOL9_HOST=user@host pull one off a live Solaris 9 SPARC install -# ./sysroot.tar.gz drop your own next to this script -# -# Building one from scratch: Notes/SolarisSysroot.md in the mrustc tree. -# The tarball is rooted at /, holding usr/include, usr/ccs/lib and usr/lib. -set -eu - -SOL9_HOST="${SOL9_HOST:-}" -SOL9_SPARC64_SYSROOT_URL="${SOL9_SPARC64_SYSROOT_URL:-}" -IMAGE="${IMAGE:-mrustc-sol9-cross}" -TAR="${TAR:-/opt/csw/bin/gtar}" -cd "$(dirname "$0")" - -if [ ! -f sysroot.tar.gz ]; then - if [ -n "$SOL9_SPARC64_SYSROOT_URL" ]; then - echo "==> Downloading sysroot" - curl -fsSL "$SOL9_SPARC64_SYSROOT_URL" -o sysroot.tar.gz.tmp - mv sysroot.tar.gz.tmp sysroot.tar.gz - elif [ -n "$SOL9_HOST" ]; then - echo "==> Pulling sysroot from $SOL9_HOST" - # sudo: parts of /usr/lib are not world-readable, and a partial sysroot fails late. - ssh "$SOL9_HOST" "sudo $TAR czf - -C / usr/include usr/ccs/lib usr/lib" > sysroot.tar.gz.tmp - mv sysroot.tar.gz.tmp sysroot.tar.gz - else - echo "error: no sysroot.tar.gz, and neither SOL9_SPARC64_SYSROOT_URL nor SOL9_HOST is set." >&2 - exit 1 - fi -fi - -# A truncated or HTML-error download fails deep inside the GCC build otherwise. -gzip -t sysroot.tar.gz || { echo "error: sysroot.tar.gz is not a valid gzip file" >&2; exit 1; } -# Accept both `usr/...` and `./usr/...`: `tar -C / -c usr` gives the first, `tar -c .` the -# second, and both unpack identically. Naming the member exactly would reject the second. -listing="$(tar tzf sysroot.tar.gz)" -if ! printf '%s\n' "$listing" | grep -qE '^(\./)?usr/include/stdio\.h$'; then - echo "error: sysroot.tar.gz has no usr/include/stdio.h; is it rooted at / ?" >&2 - echo " its top-level entries are:" >&2 - printf '%s\n' "$listing" | sed 's|^\./||' | awk -F/ 'NF>1{print $1"/"$2}' | sort -u | head -10 >&2 - exit 1 -fi - -echo "==> Building $IMAGE" -exec docker build -t "$IMAGE" "$@" . diff --git a/docker/sol9.Dockerfile b/docker/sol9.Dockerfile deleted file mode 100644 index 5cb3f1f5..00000000 --- a/docker/sol9.Dockerfile +++ /dev/null @@ -1,76 +0,0 @@ -# Build rb-cli (CLI + TUI) for Solaris 9 on SPARC, via mrustc. -# See docs/build-sol9-mrustc.md and rb-cli-sol9/. -# -# This is the one vintage target that cross-builds end to end on Linux, so -# unlike the PowerPC Mac build (which needs real PowerPC hardware to compile its -# C) it can run in the release pipeline unattended. That is the whole point of -# this image. -# -# PREREQUISITE: the base image. It carries a GCC 4.9.4 that can target Solaris 9 -# plus a Solaris 9 sysroot, and it CANNOT be published -- Sun's headers and -# libraries are not redistributable. Build it once from the mrustc tree: -# -# cd ~/repos/mrustc/docker/sol9-cross && SOL9_HOST=user@ ./build.sh -# -# Then build this image (bakes in mrustc + the Solaris stdlib, so each later -# build is only the engine), and build the artifact with the repo bind-mounted: -# -# docker build -t rb-sol9 - < docker/sol9.Dockerfile -# docker run --rm -v "$PWD":/src rb-sol9 -# # -> ./dist/rb-cli-sol9.tar.gz -# -# Override the stage to do less than the default full run: -# docker run --rm -v "$PWD":/src rb-sol9 scripts/build-sol9.sh check - -ARG BASE=mrustc-sol9-cross -FROM ${BASE} - -ARG MRUSTC_REPO=https://github.com/danifunker/mrustc -# The Solaris 9 target lives on this branch: the target spec itself, the -# emitted-overflow-helper support GCC 4.9 needs, and the CC_${TRIPLE} name -# sanitisation that a triple with dots in it requires. -ARG MRUSTC_BRANCH=sparc-solaris-10 -ARG RUSTC_VERSION=1.74.0 -ARG TARGET=sparcv9-sun-solaris2.9 - -# A modern cargo, for `cargo vendor` only -- nothing here is built with it. The -# engine is compiled by mrustc, whose language mode is pinned to 1.74. -RUN apt-get update && apt-get install -y --no-install-recommends \ - cargo rsync \ - && rm -rf /var/lib/apt/lists/* - -# mrustc, and the rustc source it bootstraps its standard library from. -RUN git clone --branch "${MRUSTC_BRANCH}" --single-branch "${MRUSTC_REPO}" /opt/mrustc -WORKDIR /opt/mrustc -RUN make -j"$(nproc)" && make -f minicargo.mk bin/minicargo RUSTC_VERSION="${RUSTC_VERSION}" -RUN make RUSTCSRC RUSTC_VERSION="${RUSTC_VERSION}" - -# The host standard library, then the Solaris 9 one. Baking both in is what -# makes a pipeline run cost only the engine transpile. -# -# OVERRIDE_SUFFIX is picked from the *host* OS by minicargo.mk, so a cross build -# from Linux would silently take the -linux build-script overrides. -# STD_ENV_ARCH is needed because std::env::consts::ARCH is otherwise derived -# from the first triple component, which is `sparcv9` rather than `sparc64`. -RUN make -f minicargo.mk LIBS RUSTC_VERSION="${RUSTC_VERSION}" -j"$(nproc)" -RUN make -f minicargo.mk LIBS \ - RUSTC_VERSION="${RUSTC_VERSION}" \ - MRUSTC_TARGET="${TARGET}" \ - OVERRIDE_SUFFIX=-solaris \ - STD_ENV_ARCH=sparc64 \ - PARLEVEL="$(nproc)" \ - && ls "output-${RUSTC_VERSION}-${TARGET}/libstd.rlib" - -# scripts/build-sol9.sh reads every path from the environment, so the image only -# has to say where things ended up. -# SOL9_LIBGCC is explicit because its default is under $SOL9_TOOLCHAIN, which does not -# exist in this image -- the `dist` stage would refuse to package without it. -ENV MRUSTC_DIR=/opt/mrustc \ - RB_DIR=/src \ - SOL9_BIN=/opt/sol9/bin \ - SOL9_SYSROOT=/opt/sol9/sysroot \ - SOL9_LIBGCC=/opt/sol9/${TARGET}/lib/sparcv9/libgcc_s.so.1 \ - RUSTC_VERSION=1.74.0 - -WORKDIR /src -CMD ["scripts/build-sol9.sh"] diff --git a/docs/build-sol9-mrustc.md b/docs/build-sol9-mrustc.md index fafcb91b..1d534ab5 100644 --- a/docs/build-sol9-mrustc.md +++ b/docs/build-sol9-mrustc.md @@ -681,10 +681,17 @@ are **done** as of 2026-09-07; 8 (doc sync) is the remainder. ## Putting it in the pipeline -The build is already containerised - `docker/sol9.Dockerfile` clones mrustc, -builds it, fetches the rustc source, builds the host and target stdlibs and -runs `scripts/build-sol9.sh`. Everything in that chain is fetched from source -at build time **except one file**, and there is one blocker. +CI does not build a toolchain. It downloads a **seed** -- the cross toolchain, its +sysroot, mrustc and both prebuilt standard libraries, about 47 MB -- unpacks it at `/`, +and runs `scripts/build-sol9.sh`. No container, no compiler build, no package repo beyond +eight Ubuntu packages. + +That is a deliberate reversal. The pipeline used to build GCC 4.9.4 from source in a +Debian image on every cache miss, roughly 40 minutes, and then rebuild mrustc and both +standard libraries on *every* run, roughly 40 more. All of it reproduced work that already +existed on a developer machine. It also broke twice in one evening for reasons that had +nothing to do with this project: Debian 11 reached EOL mid-flight, first through an +expired `Release` file and then through its security packages 404ing out of the pool. ### What it needs @@ -692,44 +699,46 @@ at build time **except one file**, and there is one blocker. |---|---|---| | `rb-cli-sol9/` manifest + `shim/sol9-compat.c` | this repo | yes, committed | | `scripts/build-sol9.sh`, vendor patches | this repo | yes, committed | -| `docker/sol9.Dockerfile` | this repo | yes, committed | -| mrustc fork, branch `sparc-solaris-10` | `danifunker/mrustc` | **blocked - see below** | -| Base image `mrustc-sol9-cross` | mrustc's `docker/sol9-cross/` | all but the sysroot | -| binutils 2.35.2, gcc 4.9.4 | ftp.gnu.org, at build time | yes | -| rustc 1.74.0 source | fetched by `make RUSTCSRC` | yes | +| `scripts/build-sol9-toolchain.sh` (builds the toolchain natively) | this repo | yes, committed | +| `scripts/pack-sol9-seed.sh` (packs the seed) | this repo | yes, committed | +| binutils 2.35.2, gcc 4.9.4 | ftp.gnu.org, when building a toolchain | yes | +| rustc 1.74.0 source | `make RUSTCSRC`, when building stdlibs | yes | | Crate sources | `cargo vendor`, at build time | yes (needs crates.io) | -| **Solaris 9 sysroot** (`sysroot.tar.gz`, 109 MB) | `docker/sol9-cross/`, **gitignored** | **no - not redistributable** | +| **The seed** (toolchain + sysroot + mrustc + stdlibs) | `SOL9_SPARC64_SYSROOT_URL` | **no - carries Sun's files** | -Two things on this machine are *not* needed and should not be mistaken for -dependencies: `~/sol9-deps/prefix` (nothing references it - zstd and zlib are -compiled from source by cc-rs for the target), and the Blade itself, which is -needed only for the parity gates, never for the build. +### Building the toolchain, and packing a seed -### The mrustc branch + scripts/mksysroot-solaris.sh disk DISK.img # sysroot from a Solaris disk image + scripts/build-sol9-toolchain.sh sysroot.tar.gz # binutils + gcc 4.9.4, ~40 min + scripts/build-sol9.sh sol9libs # the Solaris standard library + scripts/pack-sol9-seed.sh sol9-seed.tar.gz # -> 47 MB, host this privately -`docker/sol9.Dockerfile` clones `danifunker/mrustc` at branch -`sparc-solaris-10`, so that branch must carry the Solaris target for the image -to build. **It does, as of 2026-09-07** (`71910c7c`) - the branch was pushed -that day. If a container build ever fails at -`make -f minicargo.mk LIBS MRUSTC_TARGET=...` with an unknown target, check -that ref first; the whole image depends on it. +GCC bakes its sysroot path in at configure time, so the seed stages absolute paths at +`/opt/sol9` and `/opt/mrustc` and unpacks with `sudo tar xzf sol9-seed.tar.gz -C /`. Build +the toolchain with `--prefix=/opt/sol9` if you intend to pack one. -### The sysroot +The toolchain is built on the same OS that consumes it -- GitHub's `ubuntu-latest` and a +current desktop are both Ubuntu 24.04 with GCC 13.3, and GCC 4.9.4 builds under that given +`-std=gnu++98 -fpermissive`. A GCC 4.9.4 built on Debian 12 also runs unmodified on Ubuntu +24.04; glibc is forward compatible. -Sun does not permit redistributing Solaris 9, so `sysroot.tar.gz` cannot go in -a public image or the repo (it is gitignored for that reason). For CI it has to -arrive out of band - a private registry holding the pre-built -`mrustc-sol9-cross`, or the tarball as a secret artifact restored before -`docker build`. Building the base image is a one-off; only the layer above it -needs to re-run per commit. +### Two traps in the seed + +`libstd.rlib` is a **0-byte marker**. mrustc keeps the crate metadata in `libstd.rlib.hir` +and the code in `libstd.rlib.o`, so a packer that strips `*.hir` as an intermediate +produces a seed where every dependent crate dies with "Unable to deserialise crate +metadata". The emitted `*.rlib.c` really is regenerable, and dropping it is most of the +size win. + +Ubuntu's packaged `cargo` is too old for `cargo vendor` here: a crate in the graph needs +`edition2024`. The job uses `dtolnay/rust-toolchain@stable`, as the rest of the workflow +does. ### Cost -The engine transpile dominates: a from-scratch container build is tens of -minutes and wants ~25 GB of disk (mrustc's tree plus the rustc source plus the -generated C). Cache the base image and the `output-1.74.0-` stdlib and -a normal commit rebuilds only the engine and the link. Cap parallelism at 4 - -see `docs/build-memory-crashes.md`, which applies to this build too. +About 20-25 minutes: 30 s of apt, 10 s for the seed, 2 min to vendor 217 crates, and +15-20 min to transpile, cross-compile and link 219. Cap parallelism at 4 - see +`docs/build-memory-crashes.md`, which applies to this build too. ### Gating a release on the hardware @@ -766,7 +775,7 @@ depends on the last: |---|---|---| | g | `73c570f3` | `emulate-overflow-intrinsics`, so a pre-GCC-5 compiler can build mrustc's output. Needs (e) | | h | `400e373a` | The `sparcv9-sun-solaris` target (Solaris 10) | -| i | `6421bfef`, `b3aa8ae6` | `sparcv9-sun-solaris2.9`, `emulate-c99-math`, `emulate-posix2001`, and the `docker/sol9-cross` toolchain container | +| i | `6421bfef`, `b3aa8ae6` | `sparcv9-sun-solaris2.9`, `emulate-c99-math`, `emulate-posix2001`, and the toolchain container that later became `scripts/build-sol9-toolchain.sh` | | j | **not yet written** | Finding 10: mrustc lowers a fieldless `#[repr(u32)]` enum to a one-field struct and passes it **by value** in `extern "C"` signatures, where the callee expects a scalar. On any 64-bit big-endian target the value lands in the wrong half of the register. Should emit the underlying integer in extern signatures and at call sites | Two practical notes. The branch is **not linear** - it contains a merge commit diff --git a/scripts/build-sol9-toolchain.sh b/scripts/build-sol9-toolchain.sh new file mode 100755 index 00000000..0853be49 --- /dev/null +++ b/scripts/build-sol9-toolchain.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# build-sol9-toolchain.sh -- build the sparcv9-sun-solaris2.9 cross toolchain natively. +# +# No container. GitHub's ubuntu-latest runners and this machine are both Ubuntu 24.04 with +# GCC 13.3, and GCC 4.9.4 builds under that given the flags below -- so the toolchain can +# be built on the same OS that will use it, and packed as a seed for CI. +# +# scripts/build-sol9-toolchain.sh SYSROOT.tar.gz [PREFIX] +# +# PREFIX defaults to ~/sol9-toolchain/opt, matching scripts/build-sol9.sh's own default. +# The sysroot tarball is rooted at / and holds usr/include, usr/ccs/lib and usr/lib. +# +# Why 4.9.4: GCC obsoleted Solaris 9 in 4.9 (buildable only with --enable-obsolete) and +# deleted the port in the next release, which shipped as GCC 5. +set -euo pipefail + +SYSROOT_TGZ="${1:?usage: $0 SYSROOT.tar.gz [PREFIX]}" +PREFIX="${2:-$HOME/sol9-toolchain/opt}" +SYSROOT="${SOL9_SYSROOT:-$(dirname "$PREFIX")/sysroot}" + +BINUTILS_VERSION="${BINUTILS_VERSION:-2.35.2}" +GCC_VERSION="${GCC_VERSION:-4.9.4}" +TARGET="${TARGET:-sparcv9-sun-solaris2.9}" +SRC="${SRC:-${TMPDIR:-/tmp}/sol9-toolchain-src}" +JOBS="${JOBS:-$(nproc)}" + +say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } + +[ -f "$SYSROOT_TGZ" ] || { echo "error: no sysroot tarball at $SYSROOT_TGZ" >&2; exit 1; } +tar tzf "$SYSROOT_TGZ" | grep -qE '^(\./)?usr/include/stdio\.h$' \ + || { echo "error: $SYSROOT_TGZ has no usr/include/stdio.h; is it rooted at / ?" >&2; exit 1; } + +say "Unpacking the sysroot into $SYSROOT" +mkdir -p "$SYSROOT" +tar xzf "$SYSROOT_TGZ" -C "$SYSROOT" +# Solaris 9 keeps its libraries only in /usr/lib; Solaris 10 has the real ones in /lib with +# symlinks in /usr/lib, so linking unconditionally would destroy them. +[ -d "$SYSROOT/lib" ] || ln -sfn usr/lib "$SYSROOT/lib" + +mkdir -p "$SRC"; cd "$SRC" +if [ ! -d "binutils-$BINUTILS_VERSION" ]; then + say "Fetching binutils $BINUTILS_VERSION" + curl -fsSLO "https://ftp.gnu.org/gnu/binutils/binutils-$BINUTILS_VERSION.tar.xz" + tar xJf "binutils-$BINUTILS_VERSION.tar.xz"; rm "binutils-$BINUTILS_VERSION.tar.xz" +fi +if [ ! -d "gcc-$GCC_VERSION" ]; then + say "Fetching gcc $GCC_VERSION" + curl -fsSLO "https://ftp.gnu.org/gnu/gcc/gcc-$GCC_VERSION/gcc-$GCC_VERSION.tar.bz2" + tar xjf "gcc-$GCC_VERSION.tar.bz2"; rm "gcc-$GCC_VERSION.tar.bz2" +fi + +# GNU as/ld: Solaris' own are not available to a cross build, and mrustc's target spec +# already avoids the GNU-only linker options Solaris ld lacks. +say "Building binutils -> $PREFIX" +rm -rf "$SRC/build-binutils"; mkdir -p "$SRC/build-binutils"; cd "$SRC/build-binutils" +"$SRC/binutils-$BINUTILS_VERSION/configure" \ + --target="$TARGET" --prefix="$PREFIX" --with-sysroot="$SYSROOT" \ + --disable-nls --disable-werror >/dev/null +make -j"$JOBS" >/dev/null +make install-strip >/dev/null + +# -std=gnu++98 -fpermissive: GCC 4.9's own sources predate C++11-strict compilers, which +# every current host compiler is. libsanitizer does not build against modern glibc headers +# and is of no use to a Rust codegen backend. +say "Building gcc $GCC_VERSION -> $PREFIX (the slow part, ~30 min)" +rm -rf "$SRC/build-gcc"; mkdir -p "$SRC/build-gcc"; cd "$SRC/build-gcc" +PATH="$PREFIX/bin:$PATH" "$SRC/gcc-$GCC_VERSION/configure" \ + --target="$TARGET" --prefix="$PREFIX" \ + --with-sysroot="$SYSROOT" --with-build-sysroot="$SYSROOT" \ + --enable-obsolete --enable-languages=c --enable-threads=posix \ + --with-gnu-as --with-gnu-ld \ + --with-as="$PREFIX/bin/$TARGET-as" --with-ld="$PREFIX/bin/$TARGET-ld" \ + --disable-nls --disable-libssp --disable-libgomp --disable-libatomic \ + --disable-libitm --disable-libsanitizer --disable-libquadmath --disable-libvtv \ + --disable-libcilkrts \ + MAKEINFO=missing \ + CFLAGS="-O2 -w" CXXFLAGS="-O2 -w -std=gnu++98 -fpermissive" >/dev/null +PATH="$PREFIX/bin:$PATH" make -j"$JOBS" >/dev/null +make install-strip >/dev/null + +# Solaris 9's defines INTPTR_MAX and UINTPTR_MAX as *empty* macros -- +# pre-C99 they were existence flags, and C99 later gave them values. GCC copies the header +# into include-fixed without fixing this, so C99 code testing them fails to compile. +H="$PREFIX/lib/gcc/$TARGET/$GCC_VERSION/include-fixed/sys/int_limits.h" +if [ -f "$H" ] && grep -qE '^#define[[:space:]]*U?INTPTR_MAX[[:space:]]*$' "$H"; then + say "Repairing empty INTPTR_MAX/UINTPTR_MAX in include-fixed" + sed -i -e 's|^#define[ \t]*INTPTR_MAX[ \t]*$|#define\tINTPTR_MAX\t__INTPTR_MAX__|' \ + -e 's|^#define[ \t]*UINTPTR_MAX[ \t]*$|#define\tUINTPTR_MAX\t__UINTPTR_MAX__|' "$H" + grep -q '__UINTPTR_MAX__' "$H" +fi + +say "Checking it works" +echo 'int main(void){return 0;}' > "$SRC/t.c" +"$PREFIX/bin/$TARGET-gcc" -m64 -mcpu=v9 "$SRC/t.c" -o "$SRC/t" +file "$SRC/t" | grep -q 'SPARC V9' || { echo "error: not a SPARC V9 binary" >&2; exit 1; } +rm -rf "$SRC/build-binutils" "$SRC/build-gcc" +say "Toolchain ready: $("$PREFIX/bin/$TARGET-gcc" --version | head -1)" diff --git a/scripts/build-sol9.sh b/scripts/build-sol9.sh index 416207b0..058cd6ec 100755 --- a/scripts/build-sol9.sh +++ b/scripts/build-sol9.sh @@ -4,7 +4,7 @@ # mrustc and cross-compile it to a Solaris 9 SPARC `rb-cli` + TUI. # # Unlike scripts/build-ppc.sh this is a ONE-machine pipeline. A cross gcc -# targeting Solaris 9 exists (mrustc's docker/sol9-cross, or ~/sol9-toolchain), +# targeting Solaris 9 exists (scripts/build-sol9-toolchain.sh, or the CI seed at /opt/sol9), # so there is no remote compiler, no remote archiver and no split-TU step: # # This machine: Rust --mrustc--> C99 --sparcv9-...-gcc--> SPARC ELF diff --git a/docker/sol9-cross/mksysroot.sh b/scripts/mksysroot-solaris.sh similarity index 100% rename from docker/sol9-cross/mksysroot.sh rename to scripts/mksysroot-solaris.sh diff --git a/scripts/pack-sol9-seed.sh b/scripts/pack-sol9-seed.sh new file mode 100755 index 00000000..2c0bada0 --- /dev/null +++ b/scripts/pack-sol9-seed.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# pack-sol9-seed.sh -- pack a prebuilt Solaris 9 build environment for CI. +# +# CI has no business rebuilding a 2016 GCC on every run, and a container image carries a +# whole Debian plus the rustc source purely to redo work already done here. This packs only +# what scripts/build-sol9.sh actually reads: the cross toolchain and its sysroot, mrustc's +# two binaries, and the two prebuilt standard libraries. +# +# scripts/pack-sol9-seed.sh [OUT.tar.gz] +# +# Paths are staged absolute, at /opt/sol9 and /opt/mrustc, because GCC bakes its sysroot +# path in at configure time -- the tree has to land where it was configured. Unpack with +# `sudo tar xzf sol9-seed.tar.gz -C /`. +# +# TOOLCHAIN_SRC is a toolchain prefix holding bin/, sysroot/ and /, as +# scripts/build-sol9-toolchain.sh produces. It defaults to /opt/sol9, which is where the +# seed unpacks, so re-packing an already-seeded host needs no arguments. +# +# The result contains Sun's headers and libraries, so it is NOT redistributable -- host it +# privately, exactly as for the sysroot it is built from. +set -euo pipefail + +OUT="${1:-$PWD/sol9-seed.tar.gz}" +MRUSTC_DIR="${MRUSTC_DIR:-$HOME/repos/mrustc}" +RUSTC_VERSION="${RUSTC_VERSION:-1.74.0}" +TARGET="${SOL9_TARGET:-sparcv9-sun-solaris2.9}" +TOOLCHAIN_SRC="${TOOLCHAIN_SRC:-/opt/sol9}" + +say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } +need() { [ -e "$1" ] || { echo "error: missing $1 -- $2" >&2; exit 1; }; } + +need "$MRUSTC_DIR/bin/mrustc" "build mrustc first" +need "$MRUSTC_DIR/bin/minicargo" "build minicargo first" +need "$MRUSTC_DIR/output-$RUSTC_VERSION/libstd.rlib" "build the host stdlib first" +need "$MRUSTC_DIR/output-$RUSTC_VERSION-$TARGET/libstd.rlib" "run build-sol9.sh sol9libs first" + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/opt" + +say "Taking the toolchain from $TOOLCHAIN_SRC" +need "$TOOLCHAIN_SRC/bin/$TARGET-gcc" "not a toolchain prefix -- run scripts/build-sol9-toolchain.sh" +cp -a "$TOOLCHAIN_SRC" "$STAGE/opt/sol9" +need "$STAGE/opt/sol9/sysroot/usr/include/stdio.h" "the toolchain carries no sysroot" + +say "Staging mrustc and the prebuilt standard libraries" +mkdir -p "$STAGE/opt/mrustc/bin" +cp -a "$MRUSTC_DIR/bin/mrustc" "$MRUSTC_DIR/bin/minicargo" "$STAGE/opt/mrustc/bin/" +cp -a "$MRUSTC_DIR/output-$RUSTC_VERSION" "$STAGE/opt/mrustc/" +cp -a "$MRUSTC_DIR/output-$RUSTC_VERSION-$TARGET" "$STAGE/opt/mrustc/" + +# The emitted C and mrustc's debug dumps are regenerated on demand and are most of the +# weight. Keep .rlib.hir: the .rlib itself is a 0-byte marker, and .hir is where the crate +# metadata actually lives -- without it every dependent crate fails with "Unable to +# deserialise crate metadata". +say "Dropping regenerable intermediates" +find "$STAGE/opt/mrustc" \( -name '*_dbg.txt' -o -name '*.rlib.c' \) -delete + +say "Writing $OUT" +tar czf "$OUT" -C "$STAGE" opt +say "Seed ready: $(du -h "$OUT" | cut -f1)" +cat < Date: Wed, 9 Sep 2026 10:27:16 -0400 Subject: [PATCH 08/14] feat(solaris): enumerate raw devices without opening them Solaris fell into the catch-all arms of src/os/mod.rs, so `show devices` listed nothing and device writes were refused outright. There is now a src/os/solaris.rs beside linux.rs and macos.rs -- it has to live there, because rb-cli-sol9 carries no Rust source of its own and compiles ../src/lib.rs. Enumeration deliberately opens no device. A wedged driver blocks in open(2) and ignores O_NONBLOCK: measured on a Sun Blade 2500 where a USB stick behind scsa2usb left processes that kill -9 could not touch. Solaris' own tools have the same flaw -- format(1M) and rmformat(1) both hang on that disk, and Oracle warns never to point format at a USB drive -- so listing by opening is a hang waiting for one bad device. The first version of this module did exactly that and hung. Instead the module reads what the drivers publish, which is what iostat -E does and the only source that keeps working: readlink /dev/rdsk/ into /devices, /etc/path_to_inst for the driver instance, then libkstat for Size, Vendor and Product. A device that has stopped answering reports zero and is skipped. libdiskmgt, which format and the installer use, is 32-bit only on Solaris 9 and so unavailable to a sparcv9 binary. Only kstat_named_t is modelled; kstat_t stays opaque because kstat_data_lookup returns a pointer into the library's own buffer, which removes most of the FFI risk. libc alone otherwise: rb-cli-sol9 drops nix, which linux.rs and macos.rs both use. The whole-disk node differs by architecture and both are probed: SPARC has only SMI slices and calls slice 2 the whole disk, while x86 wraps them in an fdisk table and calls it p0. Oracle's USB documentation shows p0 throughout because those examples are x86, so this is commented and unit-tested in both spellings to stop someone "fixing" it to match. Cargo.toml now names libc for solaris as well. rb-cli-sol9 always declared it, so the target built anyway, but without it `cargo check --target sparcv9-sun-solaris` cannot type-check this file -- which turns a 20-minute mrustc round trip into 24 seconds. Verified on the hardware with the wedged stick still attached: both healthy disks listed with correct sizes and inquiry strings, the bad one skipped, in 112 ms. USB mass storage is documented as beta, in docs/solaris-raw-devices.md and in the shipped bundle's README. scripts/solaris-usb-unblock.sh reports the state without touching a disk and prints Oracle's scsa2usb remedy, applying it only with --apply. Also simplifies a boolean in src/fs/sfs.rs that a newer clippy flags as nonminimal, which was failing the pre-commit hook for every commit. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 4 +- docs/solaris-raw-devices.md | 77 ++++++++ scripts/build-sol9.sh | 13 +- scripts/solaris-usb-unblock.sh | 77 ++++++++ src/fs/sfs.rs | 2 +- src/os/mod.rs | 16 +- src/os/solaris.rs | 340 +++++++++++++++++++++++++++++++++ 7 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 docs/solaris-raw-devices.md create mode 100755 scripts/solaris-usb-unblock.sh create mode 100644 src/os/solaris.rs diff --git a/Cargo.toml b/Cargo.toml index 53526755..f522a0d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -277,7 +277,9 @@ lzma-rs = "0.3.0" lzfse_rust = "0.2.1" des = { version = "0.9.0", optional = true } -[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +# Solaris is built through rb-cli-sol9, which declares libc itself; naming it here too lets +# `cargo check --target sparcv9-sun-solaris` type-check src/os/solaris.rs without mrustc. +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "solaris"))'.dependencies] libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/docs/solaris-raw-devices.md b/docs/solaris-raw-devices.md new file mode 100644 index 00000000..e7180381 --- /dev/null +++ b/docs/solaris-raw-devices.md @@ -0,0 +1,77 @@ +# Raw devices on Solaris + +How `rb-cli` sees disks on Solaris, why enumeration works the way it does, and what to do +when a USB device wedges the driver. + +## Device naming, and the difference between SPARC and x86 + +Solaris exposes every disk twice. `/dev/dsk/...` is the buffered block device; `/dev/rdsk/...` +is the raw character device. Only the raw node gives unbuffered access, and it insists that +every read and write be a whole multiple of the sector size -- an unaligned one fails +`EINVAL` rather than returning a short count. That is why `SectorAlignedWriter` is mandatory +here rather than an optimisation. + +The whole-disk node is spelled differently per architecture: + +| | Whole disk | Partitions | +|---|---|---| +| **SPARC** | `cXtYdZs2` -- slice 2 is the whole disk by SMI/VTOC convention | `s0`-`s7` | +| **x86** | `cXtYdZp0` -- the fdisk table | `p1`-`p4`, with `s0`-`s7` inside the Solaris one | + +`src/os/solaris.rs` probes `p0` then `s2`, so one binary handles both. This matters because +Oracle's own USB documentation shows `p0` throughout -- those examples are from x86 systems. +On SPARC **every** device gets `s0`-`s7`, including USB sticks; there are no `p` nodes at all. + +## Enumeration never opens a device + +A wedged driver blocks in `open(2)` and **ignores `O_NONBLOCK`**. Measured on a Sun Blade 2500 +running Solaris 9: a USB stick behind `scsa2usb` left processes stuck in the kernel that +`kill -9` could not touch. Solaris' own tools have the same problem -- + + format(1M) scans by opening /dev/rdsk/*s2 -> hangs + rmformat(1) the documented removable tool -> hangs + iostat -E reads published kstats -> returns instantly + +-- so listing disks by opening them is not merely slow, it is a hang waiting for one bad +device. Oracle explicitly warns never to point `format` at a USB drive. + +`rb-cli` therefore reads what the driver already publishes: + +1. `/dev/rdsk/{p0,s2}` is a symlink into `/devices` -- `readlink`, no open. +2. `/etc/path_to_inst` maps that physical path to a driver instance (`sd`, instance 32). +3. `libkstat` reads `err:` for `Size`, `Vendor` and `Product`. +4. `/etc/mnttab` supplies mount points. + +A device that has stopped answering publishes `Size 0`, which is how it is excluded without +anything touching it. `libdiskmgt` -- the API `format` and the installer use -- is **32-bit +only on Solaris 9**, so it is unavailable to a `sparcv9` binary. + +The one ioctl still used is `DKIOCREMOVABLE`, and only for disks whose kstat already reports +a real size, so the device is known to be answering first. + +## USB mass storage is beta + +USB support on Solaris 9 is **beta** and depends more on the device than on the OS. The +`scsa2usb` driver issues a full SCSI command set that many cheap flash bridges do not +implement; those devices then wedge on open, exactly as above. Symptoms, all visible without +touching the device: + + iostat -En Size: 0.00GB <0 bytes>, high Transport Errors + kstat -p sderr::: err::Size 0 + +`scripts/solaris-usb-unblock.sh` reports this state and prints the documented remedies. + +The supported fix is to tell the driver to use the reduced command set, per +. In +`/kernel/drv/scsa2usb.conf`: + + attribute-override-list = "vid=* reduced-cmd-support=true"; + +then `update_drv -f scsa2usb`. This edits kernel driver configuration and affects **all** USB +storage on the machine, so it is not something rb-cli does for you. Note that the driver +cannot reload while processes are still stuck holding it open, and that a malformed `.conf` +will not be noticed until the next boot -- which matters on a machine whose `auto-boot?` is +false. + +Once wedged, the only reliable recovery is to unplug the device; the stuck processes clear +when it goes away. diff --git a/scripts/build-sol9.sh b/scripts/build-sol9.sh index 058cd6ec..5d788b03 100755 --- a/scripts/build-sol9.sh +++ b/scripts/build-sol9.sh @@ -262,8 +262,17 @@ libgcc_s.so.1 ships alongside because Solaris 9 has none of its own and Rust's unwinder needs it. rb-cli finds it next to itself, so keep the two together; nothing has to be installed system-wide. -Built with mrustc against a Solaris 9 sysroot. Raw device access is not -available on this platform; disk *images* work normally. +Built with mrustc against a Solaris 9 sysroot. + +Raw devices: `rb-cli show devices` lists local disks, reading what the drivers +publish rather than opening them, so a disk that has stopped answering is +skipped instead of hanging the tool. Run it as root -- /dev/rdsk nodes are not +readable by an ordinary user. + +USB mass storage is BETA. Solaris' scsa2usb driver wedges on devices that do +not implement the full SCSI command set, and once wedged such a device cannot +be recovered without unplugging it. scripts/solaris-usb-unblock.sh diagnoses +that state, and docs/solaris-raw-devices.md explains it. TXT ( cd "$SOL9_OUT/dist" && tar czf "$RB_DIR/dist/rb-cli-sol9.tar.gz" rb-cli-sol9 ) note "bundle at $RB_DIR/dist/rb-cli-sol9.tar.gz ($(du -h "$RB_DIR/dist/rb-cli-sol9.tar.gz" | cut -f1))" diff --git a/scripts/solaris-usb-unblock.sh b/scripts/solaris-usb-unblock.sh new file mode 100755 index 00000000..c859ad0c --- /dev/null +++ b/scripts/solaris-usb-unblock.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# solaris-usb-unblock.sh -- diagnose a USB disk that has wedged Solaris' scsa2usb driver. +# +# scripts/solaris-usb-unblock.sh report only (default, touches nothing) +# scripts/solaris-usb-unblock.sh --apply also write the scsa2usb reduced-command +# override and reload the driver +# +# Run this on the Solaris machine. Many cheap flash bridges do not implement the full SCSI +# command set scsa2usb issues, and then wedge on open(2) -- ignoring O_NONBLOCK, leaving +# processes unkillable. `format` and `rmformat` hang on such a device too, so everything here +# reads published data instead: nothing below opens a disk. +# +# See docs/solaris-raw-devices.md. +set -eu + +APPLY=0 +[ "${1:-}" = "--apply" ] && APPLY=1 + +CONF=/kernel/drv/scsa2usb.conf +OVERRIDE='attribute-override-list = "vid=* reduced-cmd-support=true";' + +say() { printf '==> %s\n' "$*"; } + +say "Disks the drivers are publishing (no device is opened)" +# `kstat -p` is module:instance:name:statistic value, so the value is everything +# after the tab -- splitting on ':' as well would cut a product string containing one. +kstat -p 'sderr:::' 2>/dev/null | awk -F'\t' ' + { n = split($1, k, ":"); stat = k[n]; inst = k[2] } + stat == "Size" { size[inst] = $2 } + stat == "Vendor" { vend[inst] = $2 } + # Some drivers run the product field into the next INQUIRY label; drop the artifact. + stat == "Product" { sub(/ *Revision$/, "", $2); prod[inst] = $2 } + END { + for (i in size) + printf " inst %-4s %-28s %s bytes%s\n", i, vend[i] " " prod[i], size[i], + (size[i] == 0 ? " <-- NOT ANSWERING" : "") + }' | sort + +say "Processes stuck on a raw disk" +# A process wedged in the driver cannot be killed; it clears when the device is unplugged. +stuck=$(ps -ef | grep '[/]dev/rdsk' | awk '{print " pid " $2 " " $8 " " $9}') +if [ -n "$stuck" ]; then + printf '%s\n' "$stuck" + echo " (these are in uninterruptible sleep -- kill -9 will not clear them)" +else + echo " none" +fi + +say "USB mass storage the kernel has attached" +dmesg 2>/dev/null | grep -i 'scsa2usb\|USB-device' | tail -4 | sed 's/^/ /' || echo " none" + +say "scsa2usb command-set override" +if grep -q 'reduced-cmd-support' "$CONF" 2>/dev/null; then + echo " already set in $CONF" +elif [ "$APPLY" -eq 1 ]; then + # Documented remedy for non-compliant devices; see the Oracle link in the docs. + cp "$CONF" "$CONF.bak.$$" + echo "$OVERRIDE" >> "$CONF" + echo " appended to $CONF (backup at $CONF.bak.$$)" + if update_drv -f scsa2usb 2>&1; then + echo " driver reloaded" + else + echo " reload failed -- the driver is busy while processes remain stuck" >&2 + fi +else + echo " not set. To apply the documented remedy for non-compliant devices:" + echo + echo " echo '$OVERRIDE' >> $CONF" + echo " update_drv -f scsa2usb" + echo + echo " Re-run with --apply to do that. It affects ALL USB storage on this machine," + echo " a bad .conf is only noticed at the next boot, and the driver cannot reload" + echo " while processes are still stuck holding it open." +fi + +say "If the device stays wedged" +echo " Unplug it. That is the only reliable recovery, and it clears the stuck processes." diff --git a/src/fs/sfs.rs b/src/fs/sfs.rs index fe64631f..dcac3be8 100644 --- a/src/fs/sfs.rs +++ b/src/fs/sfs.rs @@ -1945,7 +1945,7 @@ impl SfsFilesystem { }); // Full: the parent is full only when every child is. Emptied: the // parent has room again either way. - if (full && all_full) || !full { + if !full || all_full { return self.set_container_full_flag(blk, full); } return Ok(()); diff --git a/src/os/mod.rs b/src/os/mod.rs index 40baeed2..693807c7 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -24,6 +24,11 @@ pub mod darwin_devices; #[cfg(target_os = "linux")] pub mod linux; +// Compiled under `test` on any unix as well, so the iostat and slice parsers -- pure string +// handling, and the part most likely to regress -- are covered by the normal test run. +#[cfg(any(target_os = "solaris", all(test, unix)))] +pub mod solaris; + #[cfg(target_os = "windows")] pub mod windows; @@ -788,7 +793,16 @@ pub fn enumerate_devices() -> Vec { { windows::enumerate_devices() } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + #[cfg(target_os = "solaris")] + { + solaris::enumerate_devices() + } + #[cfg(not(any( + target_os = "macos", + target_os = "linux", + target_os = "windows", + target_os = "solaris" + )))] { Vec::new() } diff --git a/src/os/solaris.rs b/src/os/solaris.rs new file mode 100644 index 00000000..49aaf6a5 --- /dev/null +++ b/src/os/solaris.rs @@ -0,0 +1,340 @@ +//! Solaris device enumeration and raw-device access. +//! +//! Solaris exposes disks twice: `/dev/dsk/...` is buffered, `/dev/rdsk/...` is the raw +//! character device. Only the raw node gives unbuffered access, and it demands that every +//! read and write be a whole multiple of the sector size -- an unaligned one fails EINVAL +//! rather than returning a short count, so `SectorAlignedWriter` is not optional here. +//! +//! **Enumeration deliberately opens nothing.** A wedged driver blocks in `open(2)` and +//! ignores `O_NONBLOCK`: measured on a Sun Blade 2500 running Solaris 9, where a USB stick +//! behind `scsa2usb` left processes unkillable in the kernel. Solaris' own tools have the +//! same problem -- `format(1M)` and `rmformat(1)` both hang on that disk -- so listing by +//! opening is not merely slow, it is a hang waiting for one bad device. Sizes therefore come +//! from the kstats the driver publishes, which is what `iostat -E` reads and the only source +//! that keeps working. See docs/solaris-raw-devices.md. +//! +//! This module uses `libc` and `libkstat` alone: `rb-cli-sol9` drops `nix`, which `linux.rs` +//! and `macos.rs` both rely on, so anything reached from here is a raw call. + +#![cfg_attr(not(target_os = "solaris"), allow(dead_code))] + +use crate::device::{DiskDevice, MountedPartition}; +use std::ffi::CString; +use std::fs; +use std::os::raw::{c_char, c_int, c_void}; +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; + +/// From : DKIOC is (0x04 << 8), and DKIOCREMOVABLE is DKIOC|16. +const DKIOCREMOVABLE: c_int = (0x04 << 8) | 16; + +/// : 30 chars plus NUL. +const KSTAT_STRLEN: usize = 31; +const KSTAT_DATA_CHAR: u8 = 0; +const KSTAT_DATA_UINT64: u8 = 4; + +/// kstat_named_t. The value is a union whose largest arm is `char c[16]`, and +/// it follows a 31-byte name plus a type byte -- so the union lands 8-aligned at offset 32 +/// and the whole struct is 48 bytes. Only this layout is needed: `kstat_t` stays opaque, +/// because `kstat_data_lookup` hands back a pointer into a buffer the library owns. +#[repr(C)] +struct KstatNamed { + name: [c_char; KSTAT_STRLEN], + data_type: u8, + value: [u8; 16], +} + +#[cfg_attr(target_os = "solaris", link(name = "kstat"))] +extern "C" { + fn kstat_open() -> *mut c_void; + fn kstat_close(kc: *mut c_void) -> c_int; + fn kstat_lookup( + kc: *mut c_void, + module: *const c_char, + inst: c_int, + name: *const c_char, + ) -> *mut c_void; + fn kstat_read(kc: *mut c_void, ksp: *mut c_void, buf: *mut c_void) -> c_int; + fn kstat_data_lookup(ksp: *mut c_void, name: *const c_char) -> *mut c_void; +} + +/// An open kstat chain. Solaris publishes one `err` set per disk instance, carrying +/// the size and the inquiry strings without anyone having to touch the device. +struct Kstat(*mut c_void); + +impl Kstat { + fn open() -> Option { + // SAFETY: no arguments, and a null return is the documented failure. + let kc = unsafe { kstat_open() }; + if kc.is_null() { + return None; + } + Some(Kstat(kc)) + } + + /// Read one named statistic out of `err:`. + fn named(&self, driver: &str, instance: i32, field: &str) -> Option { + let module = CString::new(format!("{driver}err")).ok()?; + let field = CString::new(field).ok()?; + // SAFETY: the chain is open, and a null name matches any kstat in the module. + unsafe { + let ksp = kstat_lookup( + self.0, + module.as_ptr(), + instance as c_int, + std::ptr::null::(), + ); + if ksp.is_null() || kstat_read(self.0, ksp, std::ptr::null_mut()) == -1 { + return None; + } + let p = kstat_data_lookup(ksp, field.as_ptr()) as *const KstatNamed; + if p.is_null() { + return None; + } + let n = &*p; + match n.data_type { + KSTAT_DATA_UINT64 => Some(Named::U64(u64::from_ne_bytes( + n.value[..8].try_into().ok()?, + ))), + // A char value is a fixed 16-byte field, NUL-padded rather than terminated. + KSTAT_DATA_CHAR => { + let end = n + .value + .iter() + .position(|&b| b == 0) + .unwrap_or(n.value.len()); + Some(Named::Str( + String::from_utf8_lossy(&n.value[..end]).trim().to_string(), + )) + } + _ => None, + } + } + } + + fn size(&self, driver: &str, instance: i32) -> Option { + match self.named(driver, instance, "Size")? { + Named::U64(v) => Some(v), + Named::Str(_) => None, + } + } + + fn text(&self, driver: &str, instance: i32, field: &str) -> String { + match self.named(driver, instance, field) { + Some(Named::Str(s)) => s, + _ => String::new(), + } + } +} + +impl Drop for Kstat { + fn drop(&mut self) { + // SAFETY: the pointer came from kstat_open and is closed exactly once. + unsafe { kstat_close(self.0) }; + } +} + +enum Named { + U64(u64), + Str(String), +} + +/// The whole-disk node for a disk, which is spelled differently per architecture: SPARC has +/// only SMI slices and calls slice 2 the whole disk, while x86 wraps those in an fdisk table +/// and calls the whole disk `p0`. Both spellings are probed because the Oracle documentation +/// shows `p0` throughout and someone reading it would otherwise "fix" this to match. +fn whole_disk_node(disk: &str) -> Option { + for suffix in ["p0", "s2"] { + let p = PathBuf::from("/dev/rdsk").join(format!("{disk}{suffix}")); + if p.exists() { + return Some(p); + } + } + None +} + +/// Split `c0t0d0s2` or `c1t0d0p0` into the disk name and its slice or partition suffix. +fn split_node(name: &str) -> Option<(&str, &str)> { + let idx = name.rfind(['s', 'p'])?; + let (disk, suffix) = name.split_at(idx); + if disk.is_empty() || suffix.len() < 2 || !suffix[1..].bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some((disk, suffix)) +} + +/// Map each `/dev/rdsk` disk to its driver and instance, which is what a kstat is keyed on. +/// The node is a symlink into /devices; `/etc/path_to_inst` names the instance for that +/// physical path. Both are reads -- no device is opened. +fn instances() -> Vec<(String, String, i32)> { + let table = fs::read_to_string("/etc/path_to_inst").unwrap_or_default(); + let mut by_path = Vec::new(); + for line in table.lines() { + // "/pci@1d,700000/scsi@4/sd@0,0" 2 "sd" + let mut parts = line.split('"'); + let path = match parts.nth(1) { + Some(p) => p, + None => continue, + }; + let rest = parts.next().unwrap_or("").trim(); + let driver = parts.next().unwrap_or(""); + if let Ok(inst) = rest.parse::() { + by_path.push((path.to_string(), driver.to_string(), inst)); + } + } + + let entries = match fs::read_dir("/dev/rdsk") { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + let mut out: Vec<(String, String, i32)> = Vec::new(); + for entry in entries.flatten() { + let name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + let disk = match split_node(&name) { + Some((d, sfx)) if sfx == "s2" || sfx == "p0" => d.to_string(), + _ => continue, + }; + if out.iter().any(|(d, _, _)| *d == disk) { + continue; + } + // The link points at /devices/:; the instance table keys on . + let link = match fs::read_link(entry.path()) { + Ok(l) => l, + Err(_) => continue, + }; + let link = link.to_string_lossy(); + let phys = match link.split("/devices").nth(1) { + Some(p) => p.split(':').next().unwrap_or("").to_string(), + None => continue, + }; + if let Some((_, driver, inst)) = by_path.iter().find(|(p, _, _)| *p == phys) { + out.push((disk, driver.clone(), *inst)); + } + } + out.sort(); + out +} + +/// Mount points keyed by their device name, read from the live mount table. +fn mounts() -> Vec<(String, PathBuf, String)> { + let table = fs::read_to_string("/etc/mnttab").unwrap_or_default(); + let mut out = Vec::new(); + for line in table.lines() { + let mut f = line.split_whitespace(); + let (special, mount_point, fstype) = match (f.next(), f.next(), f.next()) { + (Some(a), Some(b), Some(c)) => (a, b, c), + _ => continue, + }; + if !special.starts_with("/dev/dsk/") { + continue; + } + out.push(( + special.trim_start_matches("/dev/dsk/").to_string(), + PathBuf::from(mount_point), + fstype.to_string(), + )); + } + out +} + +/// Whether the media is removable. Only called for disks whose kstat reports a real size, so +/// the device is known to be answering before anything opens it. +fn is_removable(path: &Path) -> bool { + let file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + { + Ok(f) => f, + Err(_) => return false, + }; + let mut removable: c_int = 0; + // SAFETY: the fd is open for the call and the driver writes a single int. + let rc = unsafe { libc::ioctl(file.as_raw_fd(), DKIOCREMOVABLE as _, &mut removable) }; + rc == 0 && removable != 0 +} + +/// Enumerate disks from the kstats their drivers publish, never opening a device to do it. +pub fn enumerate_devices() -> Vec { + let kstat = match Kstat::open() { + Some(k) => k, + None => return Vec::new(), + }; + let mounted = mounts(); + + let mut devices = Vec::new(); + for (disk, driver, instance) in instances() { + // A zero size means the device is not answering -- an empty drive, or one whose + // driver has wedged. Opening it to find out is exactly what must not happen. + let size_bytes = match kstat.size(&driver, instance) { + Some(s) if s > 0 => s, + _ => continue, + }; + let path = match whole_disk_node(&disk) { + Some(p) => p, + None => continue, + }; + + let vendor = kstat.text(&driver, instance, "Vendor"); + let product = kstat.text(&driver, instance, "Product"); + let media_name = format!("{vendor} {product}").trim().to_string(); + + let partitions: Vec = mounted + .iter() + .filter(|(special, _, _)| split_node(special).map(|(d, _)| d) == Some(&disk[..])) + .map(|(special, mount_point, fstype)| MountedPartition { + name: special.clone(), + mount_point: mount_point.clone(), + filesystem: fstype.clone(), + total_space: 0, + available_space: 0, + }) + .collect(); + + // A disk carrying the root filesystem is the system disk; writing it is refused. + let is_system = partitions.iter().any(|p| p.mount_point == Path::new("/")); + let removable = is_removable(&path); + + devices.push(DiskDevice { + name: disk, + path, + size_bytes, + is_removable: removable, + is_read_only: false, + is_system, + bus_protocol: driver, + media_name, + partitions, + }); + } + devices +} + +#[cfg(test)] +mod tests { + use super::split_node; + + #[test] + fn a_sparc_slice_node_splits_into_disk_and_slice() { + assert_eq!(split_node("c0t0d0s2"), Some(("c0t0d0", "s2"))); + assert_eq!(split_node("c1t3d0s0"), Some(("c1t3d0", "s0"))); + } + + /// x86 wraps the slices in an fdisk table and calls the whole disk p0. + #[test] + fn an_x86_fdisk_node_splits_the_same_way() { + assert_eq!(split_node("c3t0d0p0"), Some(("c3t0d0", "p0"))); + assert_eq!(split_node("c3t0d0p1"), Some(("c3t0d0", "p1"))); + } + + #[test] + fn a_name_that_is_not_a_node_is_rejected() { + assert_eq!(split_node("c0t0d0"), None); + assert_eq!(split_node(""), None); + assert_eq!(split_node("c0t0d0sx"), None); + } +} From 25bec4ba93a37a0d83ca4b3c578b3a7dfd09bebf Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 9 Sep 2026 11:11:38 -0400 Subject: [PATCH 09/14] docs(solaris): track what is still open for the Solaris 9 port Collects the known-incomplete and known-wrong parts in one place rather than leaving them spread across commit messages: raw-device writing still bails, free space always reports zero, a CD-ROM's removable flag is wrong because a failed DKIOCREMOVABLE is read as "not removable", USB is unproven beyond one stick that wedges, and Solaris 10 rests on a forward-compatibility assumption nobody has tested. Says outright which items are untested rather than incomplete, so the two are not confused later. Co-Authored-By: Claude Opus 5 --- docs/solaris-raw-devices.md | 5 +++ docs/solaris9-open-items.md | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 docs/solaris9-open-items.md diff --git a/docs/solaris-raw-devices.md b/docs/solaris-raw-devices.md index e7180381..18f4a2ff 100644 --- a/docs/solaris-raw-devices.md +++ b/docs/solaris-raw-devices.md @@ -75,3 +75,8 @@ false. Once wedged, the only reliable recovery is to unplug the device; the stuck processes clear when it goes away. + +## What is still open + +`docs/solaris9-open-items.md` tracks the rest: raw-device writing, free-space reporting, the +CD-ROM removable flag, and whether Solaris 10 needs a build of its own. diff --git a/docs/solaris9-open-items.md b/docs/solaris9-open-items.md new file mode 100644 index 00000000..c7b665f1 --- /dev/null +++ b/docs/solaris9-open-items.md @@ -0,0 +1,65 @@ +# Solaris 9 SPARC: what is still open + +Tracking file for the Solaris 9 port. Everything here is known-incomplete or known-wrong, as +distinct from untested -- where something has not been verified, that is said outright. + +Working today: cross build through mrustc, the release pipeline leg, disk-image operations, +the TUI, and read-only device enumeration. See `docs/build-sol9-mrustc.md` for the build and +`docs/solaris-raw-devices.md` for how devices are handled. + +## Raw devices + +- [ ] **Writing to a raw device is refused.** `open_target_for_writing_inner` in + `src/os/mod.rs` still falls into the catch-all arm for Solaris and bails. Enumeration + and reading are done; writing needs a device that is not the root disk to develop + against, and Solaris raw nodes reject unaligned I/O with `EINVAL`, so + `SectorAlignedWriter` has to be exercised properly rather than assumed. +- [ ] **Reading an explicit device path is untested.** `open_source_for_reading` is not + gated for Solaris -- it falls through to a plain `File::open` -- so naming a + `/dev/rdsk/...` path should work, but no read of a real disk has been done through it. +- [ ] **A CD-ROM reports `removable: no`.** `DKIOCREMOVABLE` either fails or returns 0 on the + BlueSCSI emulation in the test machine, and `is_removable` treats a failed ioctl as + "not removable". Whether that is the emulator or the ioctl has not been established. + This is a known-wrong value, not a verified one. +- [ ] **Free space is not reported.** The third catch-all arm in `src/os/mod.rs` returns + `None` for Solaris, so `MountedPartition::total_space` and `available_space` are always + zero. `statvfs(2)` is the obvious fix. +- [ ] **Mounted-partition safety is untested.** Solaris will not let a mounted slice be + written through its raw node; `device_safety.rs` should refuse first, with a clear + message, rather than letting the write fail late. + +## USB mass storage (beta) + +- [ ] **`scsa2usb` wedges on non-compliant devices**, blocking in `open(2)` and ignoring + `O_NONBLOCK`, leaving processes that `kill -9` cannot clear. Enumeration already routes + around this by never opening a device, but any *use* of such a disk will hang. + `scripts/solaris-usb-unblock.sh` diagnoses it and prints Oracle's remedy. +- [ ] **The `reduced-cmd-support` override is untried.** The documented fix for + non-compliant devices has not been applied on the test machine, so we do not know + whether it recovers a stick that is already wedged, or only prevents it. +- [ ] **No USB device has been read or written end to end.** Every USB test so far has been + against one stick that wedges, so the USB path is entirely unproven. + +## Solaris 10 + +- [ ] **Deliberately not built.** Support was implemented and then removed: Solaris + guarantees forward binary compatibility, so the Solaris 9 artifact is expected to run + on 10 and 11. **This has never been tested** -- the test machine was reinstalled to 9. + Copying `rb-cli-sol9` onto a Solaris 10 box settles whether a second target is needed + at all. The mrustc target (`sparcv9-sun-solaris`, GCC 11) exists if it is. + +## Build and pipeline + +- [ ] **The seed pins an mrustc commit implicitly.** `scripts/pack-sol9-seed.sh` packs + whatever `bin/mrustc` and the stdlib outputs happen to be, with nothing recording which + commit built them. Stamping that into the seed would make a stale one obvious. +- [ ] **`docs/cli-reference.md` has no `mv` entry.** Unrelated to Solaris, but found here: + the verb shipped without the reference being updated. + +## Upstream mrustc + +- [ ] **The C-calls-Rust direction of the FFI enum fix is unfixed.** libgcc's unwinder calls + `rust_eh_personality` with a plain int while mrustc declares it as a struct, so on + 64-bit big-endian that boundary still mismatches. Pre-existing upstream behaviour, + documented in the commit rather than silently left; fixing it needs a conversion at + function entry. See thepowersgang/mrustc#428. From 77ea528faf906d165561ef5b142b60e9d7118106 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 9 Sep 2026 18:14:10 -0400 Subject: [PATCH 10/14] fix(sol9): survive minicargo's build-script race, and pack the seed as root The Solaris job failed on one run and passed on a re-run of the same commit and seed. minicargo can schedule a crate's build script twice at once -- its own progress line showed `serde_core (build)` listed twice -- and the second worker then execs the binary while the first is still linking it, before the linker has set the executable bit. The error is "Unable to run process ... Permission denied", which reads like a missing chmod and is really a window where the mode is not set yet. The job now retries the sol9 stage once. That works because the build is incremental, so the retry resumes from the crates already finished, but it is a mitigation: the race is upstream in minicargo and is tracked in docs/solaris9-open-items.md rather than being quietly papered over. Separately, pack-sol9-seed.sh stored the packing user's uid, so unpacking at / handed /opt/mrustc to whoever holds uid 1000 on the target machine. It stores root ownership now, which is what a tarball meant for `tar -C /` should do. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 12 +++++++++++- docs/build-sol9-mrustc.md | 5 ++++- docs/solaris9-open-items.md | 8 ++++++++ scripts/pack-sol9-seed.sh | 5 ++++- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3aa56471..861850d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1588,11 +1588,21 @@ jobs: sudo tar xzf /tmp/sol9-seed.tar.gz -C / "$SOL9_BIN/$SOL9_TARGET-gcc" --version | head -1 + # minicargo can schedule one crate's build script twice at once -- seen as + # `serde_core (build)` listed twice in its own progress line -- and the second worker + # then execs the binary while the first is still linking it, before the linker has + # set the executable bit. That surfaces as "Unable to run process ... Permission + # denied" partway through the graph. The build is incremental, so a retry resumes + # from the completed crates and gets past it. This is a mitigation, not a cure: the + # race is upstream, and docs/solaris9-open-items.md tracks it. - name: Build rb-cli for ${{ matrix.target }} if: steps.gate.outputs.run == 'true' run: | RELEASE_VERSION="$VER" scripts/build-sol9.sh vendor - RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9 + if ! RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9; then + echo "::warning::sol9 stage failed; retrying once (minicargo build-script race)" + RELEASE_VERSION="$VER" scripts/build-sol9.sh sol9 + fi RELEASE_VERSION="$VER" scripts/build-sol9.sh dist ls -lh dist/rb-cli-sol9.tar.gz diff --git a/docs/build-sol9-mrustc.md b/docs/build-sol9-mrustc.md index 1d534ab5..f1b96d5b 100644 --- a/docs/build-sol9-mrustc.md +++ b/docs/build-sol9-mrustc.md @@ -715,7 +715,10 @@ expired `Release` file and then through its security packages 404ing out of the GCC bakes its sysroot path in at configure time, so the seed stages absolute paths at `/opt/sol9` and `/opt/mrustc` and unpacks with `sudo tar xzf sol9-seed.tar.gz -C /`. Build -the toolchain with `--prefix=/opt/sol9` if you intend to pack one. +the toolchain with `--prefix=/opt/sol9` if you intend to pack one. The tarball stores root +ownership rather than whoever packed it -- it unpacks at `/` on another machine, and +preserving a local uid hands `/opt/mrustc` to whichever user happens to hold that number +there. The toolchain is built on the same OS that consumes it -- GitHub's `ubuntu-latest` and a current desktop are both Ubuntu 24.04 with GCC 13.3, and GCC 4.9.4 builds under that given diff --git a/docs/solaris9-open-items.md b/docs/solaris9-open-items.md index c7b665f1..d7104197 100644 --- a/docs/solaris9-open-items.md +++ b/docs/solaris9-open-items.md @@ -50,6 +50,14 @@ the TUI, and read-only device enumeration. See `docs/build-sol9-mrustc.md` for t ## Build and pipeline +- [ ] **minicargo races on build scripts.** It can schedule one crate's build script twice + concurrently -- visible as `serde_core (build)` listed twice in its own progress line + -- and the second worker then execs the binary while the first is still linking it, + before the linker has set the executable bit. It surfaces as `Unable to run process + ... Permission denied` partway through the graph, and is intermittent: the same + commit and seed failed once and passed on re-run. The CI job retries the stage once, + which works because the build is incremental, but the race is upstream in minicargo + and that retry is a mitigation rather than a fix. - [ ] **The seed pins an mrustc commit implicitly.** `scripts/pack-sol9-seed.sh` packs whatever `bin/mrustc` and the stdlib outputs happen to be, with nothing recording which commit built them. Stamping that into the seed would make a stale one obvious. diff --git a/scripts/pack-sol9-seed.sh b/scripts/pack-sol9-seed.sh index 2c0bada0..b45679c3 100755 --- a/scripts/pack-sol9-seed.sh +++ b/scripts/pack-sol9-seed.sh @@ -57,8 +57,11 @@ cp -a "$MRUSTC_DIR/output-$RUSTC_VERSION-$TARGET" "$STAGE/opt/mrustc/" say "Dropping regenerable intermediates" find "$STAGE/opt/mrustc" \( -name '*_dbg.txt' -o -name '*.rlib.c' \) -delete +# Store root ownership rather than whoever packed it: the seed unpacks at / on another +# machine, and preserving a local uid hands /opt/mrustc to whichever user happens to hold +# that number there. say "Writing $OUT" -tar czf "$OUT" -C "$STAGE" opt +tar czf "$OUT" --owner=0 --group=0 --numeric-owner -C "$STAGE" opt say "Seed ready: $(du -h "$OUT" | cut -f1)" cat < Date: Wed, 9 Sep 2026 19:16:51 -0400 Subject: [PATCH 11/14] fix(restore): a raw device that refuses F_FULLFSYNC is not a failed restore Restoring to a device on macOS ended with "syncing the target: Inappropriate ioctl for device (os error 25)" after the data was already written. macOS implements File::sync_all as fcntl(F_FULLFSYNC), and a raw /dev/rdiskN answers ENOTTY -- so a restore that had completely succeeded reported failure at the last step. SectorAlignedWriter::sync_all now goes through sync_committed, which falls back to fsync on ENOTTY and only reports success if that is refused the same way. Writes to a raw device are unbuffered, so there is nothing left to push. A regular file always accepts fsync, so this cannot silently skip flushing one -- covered by a test, because that is the property worth protecting. Both writer types share the seam, so all four device-target call sites (restore, restore::single, physical_write_runner, provision_runner) are fixed at once. Co-Authored-By: Claude Opus 5 --- src/os/mod.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/os/mod.rs b/src/os/mod.rs index 693807c7..577ac167 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -517,6 +517,48 @@ mod aligned_buffer { /// /// `Read` and `Seek` flush the write buffer before delegating to the inner file. #[cfg(not(target_os = "windows"))] +/// Commit a target to stable storage, tolerating descriptors that cannot be told to. +/// +/// macOS implements `File::sync_all` as `fcntl(F_FULLFSYNC)`, which a raw `/dev/rdiskN` +/// answers with ENOTTY -- surfacing as "Inappropriate ioctl for device (os error 25)" at the +/// end of an otherwise complete restore. Writes to a raw device are already unbuffered, so +/// there is nothing left to push; fall back to `fsync`, and only treat the sync as done if +/// that is refused the same way. A regular file always accepts `fsync`, so this cannot +/// silently skip flushing one. +fn sync_committed(file: &File) -> io::Result<()> { + const ENOTTY: i32 = 25; + + match file.sync_all() { + Ok(()) => Ok(()), + Err(e) if e.raw_os_error() == Some(ENOTTY) => sync_fallback(file, e), + Err(e) => Err(e), + } +} + +#[cfg(unix)] +fn sync_fallback(file: &File, original: io::Error) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + const ENOTTY: i32 = 25; + + // SAFETY: the descriptor is owned by `file` and stays open for the call. + let rc = unsafe { libc::fsync(file.as_raw_fd()) }; + if rc == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + // A device that refuses both has nothing buffered to lose. + if err.raw_os_error() == Some(ENOTTY) { + return Ok(()); + } + let _ = original; + Err(err) +} + +#[cfg(not(unix))] +fn sync_fallback(_file: &File, original: io::Error) -> io::Result<()> { + Err(original) +} + pub struct SectorAlignedWriter { inner: File, buf: Vec, @@ -544,7 +586,7 @@ impl SectorAlignedWriter { /// Flush, pad, and push everything to the medium before "complete" is said. pub fn sync_all(&mut self) -> io::Result<()> { self.flush_padded()?; - self.inner.sync_all() + sync_committed(&self.inner) } /// Flush everything, padding the final partial sector with zeros. @@ -654,7 +696,7 @@ impl SectorAlignedWriter { /// Flush, pad, and push everything to the medium before "complete" is said. pub fn sync_all(&mut self) -> io::Result<()> { self.flush_padded()?; - self.inner.sync_all() + sync_committed(&self.inner) } /// Flush everything, padding the final partial sector with zeros. @@ -1469,3 +1511,21 @@ mod tests { ); } } + +#[cfg(test)] +mod sync_tests { + use super::sync_committed; + use std::io::Write; + + /// A regular file must still be flushed for real -- the ENOTTY tolerance is only meant + /// to cover raw devices, and must never quietly skip a sync that would have worked. + #[test] + fn a_regular_file_syncs_normally() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("f"); + let mut f = std::fs::File::create(&path).expect("create"); + f.write_all(b"payload").expect("write"); + sync_committed(&f).expect("a regular file must sync"); + assert_eq!(std::fs::read(&path).expect("read"), b"payload"); + } +} From 90bd6ca5b27934dfcc6e13610905041b167cd528 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 9 Sep 2026 22:49:05 -0400 Subject: [PATCH 12/14] fix(os): keep the sync helper out of the writer's cfg boundary The previous commit inserted sync_committed between `#[cfg(not(target_os = "windows"))]` and the struct that attribute guarded. The cfg then applied to the helper -- so it vanished on Windows -- and left the non-Windows SectorAlignedWriter ungated, colliding with the Windows one: "the name `SectorAlignedWriter` is defined multiple times". Linux, macOS and Solaris all built; every Windows leg failed. Moving the helper above the doc comment that introduces the pair fixes it. Verified with `cargo check --target {x86_64,i686}-pc-windows-msvc`, which is what should have been run before pushing the previous commit. Co-Authored-By: Claude Opus 5 --- src/os/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/os/mod.rs b/src/os/mod.rs index 577ac167..57b0575a 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -515,8 +515,6 @@ mod aligned_buffer { /// buffer addresses and sizes must be sector-aligned. This wrapper accumulates /// writes and only flushes complete sectors to the device. /// -/// `Read` and `Seek` flush the write buffer before delegating to the inner file. -#[cfg(not(target_os = "windows"))] /// Commit a target to stable storage, tolerating descriptors that cannot be told to. /// /// macOS implements `File::sync_all` as `fcntl(F_FULLFSYNC)`, which a raw `/dev/rdiskN` @@ -559,6 +557,8 @@ fn sync_fallback(_file: &File, original: io::Error) -> io::Result<()> { Err(original) } +/// `Read` and `Seek` flush the write buffer before delegating to the inner file. +#[cfg(not(target_os = "windows"))] pub struct SectorAlignedWriter { inner: File, buf: Vec, From eb5a3f461ceca641e79ef6b451b691930242c031 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Thu, 10 Sep 2026 04:59:59 -0400 Subject: [PATCH 13/14] ci(sol9): let a broken Solaris build fail the release The job was continue-on-error from when it built GCC 4.9.4 from source, where a mirror hiccup could redden a release for reasons unrelated to the code. It now downloads a prebuilt seed, and a missing secret already skips the job -- so the flag no longer guarded against anything except telling us the truth. A failure here means the Solaris build is broken, and the release job waits on it. One consequence worth naming: minicargo's build-script race can now block a release rather than warn. The stage already retries once, which has absorbed it so far; if it recurs past that, the lever is lowering JOBS from 4 to remove the concurrency, at a cost in build time. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 861850d1..588ce4c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1521,8 +1521,10 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.sol9 }} needs: generate-version runs-on: ubuntu-latest - # Non-fatal like the other vintage-media jobs: it depends on a self-hosted download. - continue-on-error: true + # Deliberately NOT continue-on-error. It was, back when this job built GCC from source + # and a mirror hiccup could redden a release for reasons unrelated to the code. It now + # downloads a prebuilt seed, and a missing secret already skips the job -- so a failure + # here means the Solaris build is genuinely broken, and saying so is the point. strategy: # One entry today. Solaris also ran on x86, and the secret is already named per-arch, # so a second target is a matrix row rather than a second job. From 60ca0dba3a6ce1906d93523ba89f2b634adbf053 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Thu, 10 Sep 2026 06:04:03 -0400 Subject: [PATCH 14/14] fix(sol9): write build output to the workspace, not into the seed SOL9_OUT defaults to $MRUSTC_DIR/output-rb-sol9, which in CI is inside the seed unpacked at /opt/mrustc. The seed is extracted as root and carries its packer's uid, so whether the job user can write there is an accident of which uid the runner happens to use. Reproduced in a container as uid 1001: mkdir: cannot create directory '/opt/mrustc/output-rb-sol9': Permission denied and with SOL9_OUT pointing at a writable directory the same check passes. The seed's tree is input and should be read-only; only these two directories are ever written, so they move to the workspace. Whether this is also what failed run #668 is not established. That job died executing a build script under the same tree with EACCES, which fits, but the runs that succeeded fit a runner uid matching the seed's. The earlier "minicargo build-script race" reading was inferred from a single passing re-run and a duplicate progress line, which is thin evidence; the retry that appeared to fix it more likely just resumed from state that already existed. The retry stays for now, but the race is no longer claimed as diagnosed. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 588ce4c0..e23d97b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1543,6 +1543,11 @@ jobs: SOL9_SYSROOT: /opt/sol9/sysroot SOL9_LIBGCC: /opt/sol9/${{ matrix.target }}/lib/sparcv9/libgcc_s.so.1 MRUSTC_DIR: /opt/mrustc + # The seed unpacks at / as root, so /opt/mrustc is not writable by the job user. + # Only the two output directories are ever written; the stdlibs there are read-only. + # Send them to the workspace instead of trying to write inside the seed's tree. + SOL9_OUT: ${{ github.workspace }}/.sol9-out + HOSTC_OUT: ${{ github.workspace }}/.sol9-hostc steps: - uses: actions/checkout@v6