diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e5bdb3..6c8d82e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ -# On a v* tag, build both bundles, smoke-test them on macOS 15 and 26 runners, publish the -# GitHub release and update Formula/sys1rust.rb in krishhgg/homebrew-tap. A vX.Y.Z-rcN tag -# publishes a prerelease and leaves the tap alone. packaging/RELEASE.md is the checklist. -# A pull request that changes packaging/ or this file runs the build and smoke jobs only. +# On a v* tag, build both bundles, install them with install.sh and smoke-test them on macOS 15 +# and 26 runners, then publish the GitHub release with SHA256SUMS. A vX.Y.Z-rcN tag publishes a +# prerelease. packaging/RELEASE.md is the checklist. A pull request that changes packaging/, +# install.sh or this file runs the build and smoke jobs only. name: release on: @@ -10,6 +10,7 @@ on: pull_request: paths: - "packaging/**" + - "install.sh" - ".github/workflows/release.yml" permissions: @@ -30,6 +31,9 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@v7 + - name: Test install.sh + # Fake bundles and a fake launchctl, so this needs no build, network or GPU. + run: packaging/test_install.sh /bin/sh && packaging/test_install.sh /bin/dash - name: Check the tag against the workspace version if: startsWith(github.ref, 'refs/tags/v') run: | @@ -62,10 +66,15 @@ jobs: run: packaging/build.sh macos26 dist - name: Build the macOS 14 bundle run: packaging/build.sh macos14 dist + - name: Checksums + # The smoke jobs install through this file and the publish job uploads it. + run: cd dist && shasum -a 256 sys1rust-*.tar.gz > SHA256SUMS && cat SHA256SUMS - uses: actions/upload-artifact@v7 with: name: bundles - path: dist/*.tar.gz + path: | + dist/*.tar.gz + dist/SHA256SUMS if-no-files-found: error # The tarballs are gzip files already. compression-level: 0 @@ -90,14 +99,24 @@ jobs: with: name: bundles path: dist - - name: Unpack and run through a symlink, as Homebrew does + - name: Install with install.sh + # install.sh checks the tarball against SHA256SUMS and links bin/sys1rust, so every step + # below runs the binary through a symlink, as users do. run: | - mkdir -p "$RUNNER_TEMP/pkg" "$RUNNER_TEMP/bin" - tar -xzf dist/sys1rust-*-"$FLAVOR"-arm64.tar.gz -C "$RUNNER_TEMP/pkg" - ln -s "$RUNNER_TEMP"/pkg/sys1rust-*/bin/sys1rust "$RUNNER_TEMP/bin/sys1rust" + ./install.sh --from dist --prefix "$RUNNER_TEMP/sys1rust" --bin-dir "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Version and model list - run: sw_vers && sys1rust --version && sys1rust models + run: | + sw_vers + version=$(sys1rust --version) + echo "$version" + # install.sh picks the bundle from the macOS version, so check that it picked the one + # this runner tests. + if [[ $version != *", $FLAVOR build)" ]]; then + echo "::error::install.sh installed '$version' on this runner, not the $FLAVOR bundle." + exit 1 + fi + sys1rust models - name: Cache the typed-decisions snapshot uses: actions/cache@v6 with: @@ -108,56 +127,31 @@ jobs: run: sys1rust pull typed-decisions - name: Smoke test run: python3 packaging/smoke.py "$(command -v sys1rust)" + - name: Uninstall with install.sh + run: | + ./install.sh --uninstall --prefix "$RUNNER_TEMP/sys1rust" --bin-dir "$RUNNER_TEMP/bin" + if [ -e "$RUNNER_TEMP/sys1rust" ] || [ -L "$RUNNER_TEMP/bin/sys1rust" ]; then + echo "::error::install.sh --uninstall left $RUNNER_TEMP/sys1rust or $RUNNER_TEMP/bin/sys1rust behind." + exit 1 + fi publish: if: startsWith(github.ref, 'refs/tags/v') needs: smoke runs-on: ubuntu-latest timeout-minutes: 15 - # Every tag's publish job joins this one group, so 2 tags never write the tap at the same - # time. GitHub runs 1 job of a group and holds 1 more. A third tag's job cancels the held - # one, and that tag's publish job then needs a rerun. - concurrency: - group: tap-update - cancel-in-progress: false permissions: contents: write env: GH_REPO: ${{ github.repository }} + # gh finds the repository through GH_REPO, so this job needs no checkout. steps: - - uses: actions/checkout@v7 - with: - # gh reads GH_TOKEN and the tap clone has its own helper, so git keeps no token here. - persist-credentials: false - uses: actions/download-artifact@v8 with: name: bundles path: dist - - name: Checksums - run: cd dist && sha256sum sys1rust-*.tar.gz > SHA256SUMS && cat SHA256SUMS - - name: Clone the Homebrew tap and check that TAP_TOKEN can push to it - if: ${{ !contains(github.ref_name, '-rc') }} - env: - TAP_TOKEN: ${{ secrets.TAP_TOKEN }} - run: | - if [ -z "$TAP_TOKEN" ]; then - echo "::error::The TAP_TOKEN secret is empty. Set it to a token that can write to krishhgg/homebrew-tap." - exit 1 - fi - # Git asks this credential helper for the password, and the helper reads TAP_TOKEN from - # the environment. The token stays out of the clone URL, .git/config, every command line - # and the log. The empty helper first drops any helper the runner set up. - # shellcheck disable=SC2016 - git clone --depth 1 -c credential.helper= \ - -c 'credential.helper=!f() { echo username=x-access-token; echo "password=$TAP_TOKEN"; }; f' \ - https://github.com/krishhgg/homebrew-tap.git "$RUNNER_TEMP/tap" - # The tap is public, so the clone runs without the token. A dry-run push asks GitHub for - # git-receive-pack, the service behind every push, and GitHub serves it only to a token - # that it accepts and that has push access to the tap. An expired, revoked or read-only - # token therefore stops the job here, before the release exists. The dry run sends no - # commits, so it does not check branch rules on main. Unlike HEAD, --all also works on - # a tap with no commits. - git -C "$RUNNER_TEMP/tap" push --dry-run --all origin + - name: Check the bundles against SHA256SUMS + run: cd dist && sha256sum --check SHA256SUMS - name: GitHub release env: GH_TOKEN: ${{ github.token }} @@ -175,8 +169,8 @@ jobs: exit 1 fi # This endpoint finds only a published release. A rerun goes on with it only if it has - # both bundles and the SHA256SUMS of this run, so the formula's hashes match what users - # download. + # both bundles and the SHA256SUMS of this run, so install.sh checks downloads against + # the hashes of the bundles that the smoke jobs tested. if gh api "repos/$GH_REPO/releases/tags/$tag" --silent 2> /dev/null; then pub=$RUNNER_TEMP/published if ! gh release download "$tag" --dir "$pub" --pattern 'sys1rust-*.tar.gz' --pattern SHA256SUMS || @@ -192,39 +186,3 @@ jobs: case "$tag" in *-rc*) flags+=(--prerelease) ;; esac gh release create "$tag" dist/sys1rust-*.tar.gz dist/SHA256SUMS --verify-tag \ --title "sys1rust ${tag#v}" --generate-notes "${flags[@]}" - - name: Update the Homebrew formula - if: ${{ !contains(github.ref_name, '-rc') }} - env: - TAP_TOKEN: ${{ secrets.TAP_TOKEN }} - run: | - version=${GITHUB_REF_NAME#v} - formula=$RUNNER_TEMP/tap/Formula/sys1rust.rb - # A rerun of an older tag's publish job must not put its formula over a newer one. The - # tap-update group keeps a second tag's job from changing the tap after the clone. - if [ -f "$formula" ]; then - current=$(sed -n 's/^ *version "\(.*\)"$/\1/p' "$formula") - if ! [[ $current =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::The tap's Formula/sys1rust.rb needs 1 version line with an X.Y.Z version, and it has '$current'." - exit 1 - fi - if [ "$current" != "$version" ] && - [ "$(printf '%s\n' "$current" "$version" | sort -V | tail -n 1)" = "$current" ]; then - echo "::notice::The tap has sys1rust $current, which is newer than $version, so the tap stays as it is. Release $GITHUB_REF_NAME is published." - exit 0 - fi - fi - sha() { awk -v f="sys1rust-$version-$1-arm64.tar.gz" '$2 == f {print $1}' dist/SHA256SUMS; } - mkdir -p "$RUNNER_TEMP/tap/Formula" - packaging/render_formula.sh "$GITHUB_REF_NAME" "$(sha macos26)" "$(sha macos14)" > "$formula" - cd "$RUNNER_TEMP/tap" - git add Formula/sys1rust.rb - # A rerun after a successful push finds the formula unchanged. That is the wanted state, - # so the step succeeds without a commit. - if git diff --cached --quiet; then - echo "Formula/sys1rust.rb already describes $GITHUB_REF_NAME" - exit 0 - fi - git -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit -m "sys1rust $version" - git push origin HEAD diff --git a/README.md b/README.md index 4b580fa..46c7ca5 100644 --- a/README.md +++ b/README.md @@ -21,32 +21,32 @@ ## Install -On an Apple silicon Mac with macOS 14 or later: +To install on an Apple silicon Mac with macOS 14 or later, run: ```sh -brew install krishhgg/tap/sys1rust +curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh sys1rust serve ``` -The first `serve` downloads the typed-decisions model (846 MB) into the Hugging Face cache, then prints `listening on http://127.0.0.1:8000` when it's ready. `sys1rust pull [model]` downloads a model ahead of time and `sys1rust models` lists what is downloaded. To run it in the background now and at every login, first stop the foreground server with Ctrl-C to free port 8000, then run: +The script installs the latest release into `~/.local/share/sys1rust` and links `~/.local/bin/sys1rust`, without `sudo`. If `~/.local/bin` is not on your `PATH`, it prints the line to add to `~/.zshrc`. Until you add it, run `~/.local/bin/sys1rust serve` instead. -```sh -brew services start sys1rust -``` +On macOS 26.2 or later the script installs the bundle with MLX's build for that version, which is about 3x faster on the M5 than the bundle for macOS 14 to 26.1. The speed numbers in this README use the macOS 26 bundle. The [Releases](https://github.com/krishhgg/sys1rust/releases) page has both tarballs, and the script checks each download against the release's `SHA256SUMS`. -It logs to `$(brew --prefix)/var/log/sys1rust.log`. On macOS 26.2 or later, Homebrew installs the bundle with MLX's build for that version, which is about 3x faster on the M5 than the bundle for macOS 14 to 26.1. The speed numbers in this README use the macOS 26 build. +The first `serve` downloads the typed-decisions model (846 MB) into the Hugging Face cache, then prints `listening on http://127.0.0.1:8000` when it's ready. `sys1rust pull [model]` downloads a model ahead of time and `sys1rust models` lists what is downloaded. -Without Homebrew, download a tarball from [Releases](https://github.com/krishhgg/sys1rust/releases). `sys1rust--macos26-arm64.tar.gz` needs macOS 26.2 or later, and the `macos14` tarball runs on macOS 14 to 26.1. Unpack it, then clear the quarantine flag the browser set. The release signs the binary ad hoc without notarizing it, so macOS refuses to run it while the flag is set: +To run it in the background now and at every login, first stop the foreground server with Ctrl-C to free port 8000, then install the service: ```sh -xattr -dr com.apple.quarantine sys1rust-* +curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh -s -- --service ``` -Then run `bin/sys1rust serve` from the unpacked folder. The binary loads MLX from the `lib/` folder next to `bin/`, so keep the folder together. To run it as `sys1rust`, add that `bin/` to your `PATH` or symlink `bin/sys1rust` into a directory on it. A symlink still finds `lib/`. +The service is a LaunchAgent that runs `sys1rust serve` at login, restarts it if it crashes and logs to `~/Library/Logs/sys1rust.log`. From a clone of this repository, `./install.sh --service` does the same. + +To update, run the install command again, and it restarts a running service on the new version. To uninstall, run it with `sh -s -- --uninstall` in place of `sh`, which removes the service, the install, the link and the log, keeps the downloaded models and prints how to delete them. ## Try it -With the server from [Install](#install) running, in the foreground or under `brew services`, send it a request from another terminal: +With the server from [Install](#install) running, in the foreground or as the service, send it a request from another terminal: ```sh curl -s localhost:8000/v1/systemone -H 'content-type: application/json' -d '{ @@ -80,7 +80,7 @@ Every answer also has 2 confidence numbers, which measure different things: - `answer_confidence` is the probability of the most likely option. For `choice` that is the chosen label, for `score` the most likely level, which can differ from the expected `score`, and for `noul` the larger of yes and no. It is the one to use for deciding whether to trust an answer. - For `choice` and `score`, `confidence` is 1 minus the normalized entropy of the probabilities. It is 0 for an even split and 1 when everything is on one option. For `noul`, it equals `answer_confidence`, which is 0.5 for an even split. -Don't compare them against the same threshold. `action.act_probability` is the model's probability, from a second output, for acting on the answer rather than escalating. Ctrl-C stops a foreground server, and `brew services stop sys1rust` stops the background one. When the server stops, the model is out of memory. +Don't compare them against the same threshold. `action.act_probability` is the model's probability, from a second output, for acting on the answer rather than escalating. Ctrl-C stops a foreground server, and `launchctl bootout gui/$(id -u)/io.github.krishhgg.sys1rust` stops the service until the next login. When the server stops, the model is out of memory. ## How it works @@ -215,6 +215,7 @@ $CARGO_TARGET_DIR/release/sys1rust serve --model typed-decisions --port 8000
Repository layout +- `install.sh`: the installer for prebuilt releases. - `runtime/`: the Rust workspace. - `laya-core`: request parsing, tokenization, sequence layout and answer decoding, with no GPU code. - `laya-mlx`: the forward pass on MLX through mlx-rs. @@ -222,6 +223,7 @@ $CARGO_TARGET_DIR/release/sys1rust serve --model typed-decisions --port 8000 - `sys1-bench`: the benchmark adapter and `sys1-probe`. - `vendor/mlx-sys`: mlx-sys 0.6.0 with a build that can link a prebuilt MLX. - `bench/`: the benchmark harness, workloads and upstream reference answers (`bench/PLAN.md`). +- `packaging/`: the release bundle builds, the smoke test and the release checklist (`packaging/RELEASE.md`). - `results/`: measured write-ups, from the bake-off of existing runtimes to the speed round. - `research/`: sourced reports on the models, runtimes and hardware. - `docs/assets/`: the diagrams in this README. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..aa406d7 --- /dev/null +++ b/install.sh @@ -0,0 +1,792 @@ +#!/bin/sh +# Installs, updates or removes sys1rust on an Apple silicon Mac, without sudo. It also runs +# piped, as `curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh`. +# It downloads the release bundle for this macOS version, checks it against the release's +# SHA256SUMS, unpacks it into a unique PREFIX/-./ and points PREFIX/current and +# BIN_DIR/sys1rust at it. A rerun updates to the latest release, keeps the version before it as +# PREFIX/previous and deletes older ones. --service runs `sys1rust serve` as a LaunchAgent. +# install.sh never downloads a model. `sys1rust pull` and the first `sys1rust serve` do that. +# +# curl sets no com.apple.quarantine attribute on its downloads (xattr -l on a file that curl +# downloaded on macOS 26.2 lists only com.apple.provenance), so Gatekeeper never checks the +# unpacked binary and install.sh needs no xattr step. +# +# The whole script is functions until the last line, so a piped copy that arrives cut short +# installs nothing. +set -eu + +REPO_URL=https://github.com/krishhgg/sys1rust +# Tests point this at a local server. +RELEASES_URL=${SYS1RUST_RELEASES_URL:-$REPO_URL/releases} +LABEL=io.github.krishhgg.sys1rust +# How long --service and a service restart wait for /health. +HEALTH_WAIT=60 + +usage() { + cat <<'EOF' +Usage: install.sh [options] + +Installs sys1rust from its GitHub releases, or updates it to the latest release. + + --version vX.Y.Z[-rcN] install this release instead of the latest one + --from DIR install the tarball and SHA256SUMS in DIR instead of downloading + --prefix DIR where versions go (default ~/.local/share/sys1rust) + --bin-dir DIR where the sys1rust link goes (default ~/.local/bin) + --service also run `sys1rust serve` at login, as a LaunchAgent + --uninstall remove sys1rust, its link, its LaunchAgent and its log + --help print this help +EOF +} + +# A reader that closes the output early, such as head, must not stop an install halfway, so +# main ignores SIGPIPE and say ignores the write error. External printf keeps Bash 3.2's +# failed output buffer out of later command substitutions. +say() { /usr/bin/printf '%s\n' "$*" 2>/dev/null || true; } +die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } +usage_error() { printf 'install.sh: %s. install.sh --help lists the options.\n' "$*" >&2; exit 2; } + +# Exits 0 when dotted version $1 is at least $2. Compares 3 numeric parts, so 26.10 is above +# 26.2 and 26 means 26.0.0. +ver_ge() { + printf '%s\n%s\n' "$1" "$2" | awk -F. ' + NR == 1 { for (i = 1; i <= 3; i++) a[i] = $i + 0 } + NR == 2 { for (i = 1; i <= 3; i++) b[i] = $i + 0 } + END { + for (i = 1; i <= 3; i++) { if (a[i] > b[i]) exit 0; if (a[i] < b[i]) exit 1 } + exit 0 + }' +} + +valid_tag() { printf '%s\n' "$1" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$'; } + +# Prints $1 as an absolute path without trailing slashes. It resolves no symlinks, so install +# and uninstall compute the same path even after the directory is gone. +abs_path() { + case $1 in + /*) p=$1 ;; + *) p=$PWD/$1 ;; + esac + while [ "$p" != / ] && [ "${p%/}" != "$p" ]; do p=${p%/}; done + printf '%s\n' "$p" +} + +# The Hugging Face hub cache, found as sys1rust finds it. +hub_dir() { + if [ -n "${HF_HUB_CACHE:-}" ]; then + say "$HF_HUB_CACHE" + elif [ -n "${HF_HOME:-}" ]; then + say "$HF_HOME/hub" + elif [ -n "${XDG_CACHE_HOME:-}" ]; then + say "$XDG_CACHE_HOME/huggingface/hub" + else + say "$HOME/.cache/huggingface/hub" + fi +} + +xml() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g'; } + +shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +# The registry records the paths this installer created. Never infer ownership from a +# filename. A marker inside each version also prevents deletion of a replacement directory. +check_prefix() { + [ ! -L "$prefix" ] || die "$prefix is a symlink. Pick a directory for --prefix" + if [ -e "$owner" ] || [ -L "$owner" ]; then + if [ ! -d "$owner" ] || [ -L "$owner" ] || + [ "$(cat "$owner/format" 2>/dev/null)" != 'sys1rust installer 1' ]; then + die "$prefix has an invalid installer registry" + fi + if [ ! -d "$owner/versions" ] || [ -L "$owner/versions" ]; then die "$owner has no version registry"; fi + [ "$(cat "$owner/bin-link" 2>/dev/null)" = "$link" ] || + die "$prefix belongs to another --bin-dir. Use the bin directory recorded in $owner/bin-link" + elif [ -d "$prefix" ]; then + for entry in "$prefix"/* "$prefix"/.[!.]* "$prefix"/..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + [ "$entry" = "$lock" ] && [ "$locked" = 1 ] && continue + die "$prefix is not empty and has no installer registry. Pick an empty --prefix" + done + elif [ -e "$prefix" ]; then + die "$prefix is not a directory" + fi +} + +acquire_lock() { + if ! mkdir "$1" 2>/dev/null; then + lock_pid=$(cat "$1/pid" 2>/dev/null || true) + case $lock_pid in + '' | *[!0-9]*) die "another installer is acquiring $1. Retry when it finishes" ;; + esac + if kill -0 "$lock_pid" 2>/dev/null; then + die "another installer is running at $1 (PID $lock_pid)" + fi + # Automatic removal could race another process that has already replaced this lock. + die "a stale installer lock remains at $1 (PID $lock_pid). After checking no installer runs, remove it with: rm -f $(shell_quote "$1/pid") && rmdir $(shell_quote "$1")" + fi + case $1 in + "$lock") locked=1 ;; + "$shared_lock") shared_locked=1 ;; + "$bin_lock") bin_locked=1 ;; + esac + printf '%s\n' "$$" >"$1/pid" +} + +release_lock() { + if [ "$2" = 1 ] && [ "$(cat "$1/pid" 2>/dev/null)" = "$$" ]; then + rm -f "$1/pid" + rmdir "$1" 2>/dev/null || true + fi +} + +create_owner() { + [ ! -d "$owner" ] || return 0 + registry=$stage/registry + mkdir "$registry" "$registry/versions" + printf '%s\n' 'sys1rust installer 1' >"$registry/format" + printf '%s\n' "$link" >"$registry/bin-link" + mv "$registry" "$owner" +} + +owned_version() { + printf '%s\n' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?-macos(14|26)\.[[:alnum:]]{6}$' && + [ -f "$owner/versions/$1" ] && [ ! -L "$prefix/$1" ] && + [ "$(cat "$prefix/$1/.sys1rust-owned" 2>/dev/null)" = "$1" ] +} + +check_owned_link() { + if [ -e "$1" ] || [ -L "$1" ]; then + link_matches "$1" "$2" || die "$1 is not this installer's link. Move it away before installing" + fi +} + +check_bin_link() { + if [ -e "$link" ] && [ ! -L "$link" ]; then + die "$link exists and is not a link. Move it away or pick another --bin-dir" + fi + check_owned_link "$link" "$owner/bin-target" +} + +link_matches() { + [ -L "$1" ] && { + [ "$(readlink "$1")" = "$(cat "$2" 2>/dev/null)" ] || + [ "$(readlink "$1")" = "$(cat "$2.next" 2>/dev/null)" ] + } +} + +record_link() { + # Keep both possible targets registered until the atomic switch finishes. A signal + # between the switch and the registry update still leaves a recognized working link. + printf '%s\n' "$1" >"$3.next" + swap_link "$1" "$2" + mv -f "$3.next" "$3" +} + +remove_owned_link() { + if link_matches "$1" "$2"; then + rm -f "$1" + say "Removed $1" + elif [ -e "$1" ] || [ -L "$1" ]; then + say "Left $1, which no longer matches the installer registry" + fi +} + +# Points symlink $2 at $1 in 1 rename, so readers see the old target or the new one and never +# a missing link. mv -h renames over a link to a directory instead of moving into it. +swap_link() { + next_link=$2.new.$$ + if [ -e "$next_link" ] || [ -L "$next_link" ]; then die "$next_link already exists"; fi + pending_link=$next_link + ln -s "$1" "$pending_link" + mv -fh "$pending_link" "$2" + pending_link= +} + +# fetch URL FILE: downloads URL to FILE and sets http_code. curl retries transient failures +# twice and fails on HTTP errors. +fetch() { + http_code=000 + http_code=$(curl -fsL --retry 2 --connect-timeout 20 -o "$2" -w '%{http_code}' "$1") +} + +check_mac() { + os_name=$(uname -s) + [ "$os_name" = Darwin ] || die "sys1rust runs only on macOS, and this system is $os_name" + # sysctl.proc_translated is 1 under Rosetta, where uname -m says x86_64 on an arm64 Mac. + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then + die "this shell runs under Rosetta. Run install.sh from a native arm64 terminal" + fi + arch=$(uname -m) + [ "$arch" = arm64 ] || die "sys1rust needs an Apple silicon (arm64) Mac, and this one is $arch" + macos=$(sw_vers -productVersion) + case $macos in + '' | *[!0-9.]*) die "could not read the macOS version, sw_vers printed '$macos'" ;; + esac + ver_ge "$macos" 14.0 || die "sys1rust needs macOS 14.0 or later, and this Mac runs $macos" + # MLX's build for macOS 26.2 and later is about 3x faster on the M5 than its macOS 14 build. + if ver_ge "$macos" 26.2; then flavor=macos26; else flavor=macos14; fi +} + +# Sets tag to the latest published release that is not a prerelease. GitHub redirects +# /releases/latest to /releases/tag/, or to /releases when there is none. That page is +# not the API, so its rate limit does not apply. +latest_tag() { + rc=0 + out=$(curl -s --retry 2 --connect-timeout 20 -o /dev/null -w '%{http_code} %{redirect_url}' \ + "$RELEASES_URL/latest") || rc=$? + [ "$rc" = 0 ] || die "could not reach $RELEASES_URL/latest (curl exit $rc)" + code=${out%% *} + location=${out#* } + case $location in + */releases/tag/?*) tag=${location##*/releases/tag/} ;; + */releases | */releases/) die "sys1rust has no published release yet. See $REPO_URL for a source build" ;; + *) die "could not find the latest release, $RELEASES_URL/latest answered HTTP $code" ;; + esac + valid_tag "$tag" || die "the latest release has tag '$tag', which is not vX.Y.Z" +} + +# Downloads or copies the bundle for $tag into $stage, checks it and unpacks it. Sets new to +# the unpacked folder. +get_bundle() { + base=${tag#v} + base=${base%%-rc*} + tarball=sys1rust-$base-$flavor-arm64.tar.gz + if [ -n "$from" ]; then + sums=$from/SHA256SUMS + src=$from/$tarball + [ -f "$sums" ] || die "$from has no SHA256SUMS" + else + url=$RELEASES_URL/download/$tag + sums=$stage/SHA256SUMS + src=$stage/$tarball + if ! fetch "$url/SHA256SUMS" "$sums"; then + [ "$http_code" = 404 ] || die "could not download $url/SHA256SUMS (HTTP $http_code)" + if curl -fsL --retry 2 -o /dev/null "$RELEASES_URL/tag/$tag"; then + die "release $tag has no SHA256SUMS" + fi + die "there is no release $tag. The releases are at $REPO_URL/releases" + fi + say "Downloading $url/$tarball" + if ! fetch "$url/$tarball" "$src"; then + [ "$http_code" = 404 ] && die "release $tag has no $tarball" + die "could not download $url/$tarball (HTTP $http_code)" + fi + fi + # sha256sum writes " ", or " *" in binary mode. + want=$(awk -v f="$tarball" '{ n = $2; sub(/^\*/, "", n) } n == f { print $1; exit }' "$sums") + printf '%s\n' "$want" | grep -Eq '^[0-9a-f]{64}$' || die "SHA256SUMS has no sha256 for $tarball" + got=$(shasum -a 256 "$src" | awk '{ print $1 }') + if [ "$got" != "$want" ]; then + [ -n "$from" ] || rm -f "$src" + die "$tarball has sha256 $got, and SHA256SUMS says $want. Nothing changed" + fi + tar -xzf "$src" -C "$stage" || die "could not unpack $tarball" + [ -n "$from" ] || rm -f "$src" + new=$stage/sys1rust-$base-$flavor-arm64 + [ -x "$new/bin/sys1rust" ] || die "$tarball has no sys1rust-$base-$flavor-arm64/bin/sys1rust" + # Runs the new binary before anything points at it. + out=$("$new/bin/sys1rust" --version &1) || die "the new sys1rust does not run: $out" + case $out in + "sys1rust $base ("*) ;; + *) die "the binary in $tarball says '$out', not sys1rust $base" ;; + esac +} + +# Removes the staging dir, and the prefix too when a failed first install leaves it empty. +cleanup() { + if [ -n "$stage" ]; then rm -rf "$stage"; fi + if [ -n "$pending_link" ] && [ -L "$pending_link" ]; then rm -f "$pending_link"; fi + if [ -n "$plist_stage" ]; then rm -f "$plist_stage"; fi + release_lock "$bin_lock" "$bin_locked" + release_lock "$shared_lock" "$shared_locked" + release_lock "$lock" "$locked" + case $bin_dir in "$prefix"/*) rmdir "$bin_dir" 2>/dev/null || true ;; esac + rmdir "$prefix" 2>/dev/null || true +} + +service_loaded() { launchctl print "gui/$uid/$LABEL" >/dev/null 2>&1; } + +# Exits 0 when the LaunchAgent runs this install's link. The label is the same for every +# prefix, so an install or uninstall in a scratch prefix leaves another install's service be. +plist_value() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist" 2>/dev/null; } +plist_runs_link() { + [ -f "$owner/service" ] && [ -f "$plist" ] && [ ! -L "$plist" ] && + [ "$(cat "$owner/service")" = "$plist" ] && [ "$(plist_value Label)" = "$LABEL" ] && + [ "$(plist_value ProgramArguments:0)" = "$link" ] && + [ "$(plist_value Sys1rustInstallPrefix)" = "$prefix" ] +} + +read_service_env() { + for name in HF_HUB_CACHE HF_HOME XDG_CACHE_HOME LAYA_PORT; do + unset "$name" + value=$(plist_value "EnvironmentVariables:$name" || true) + [ -z "$value" ] || export "$name=$value" + done + port=${LAYA_PORT:-8000} +} + +health_ok() { curl -fs --max-time 2 -o /dev/null "http://127.0.0.1:$port/health"; } + +service_pid() { + launchctl print "gui/$uid/$LABEL" 2>/dev/null | + awk '$1 == "pid" && $2 == "=" && $3 ~ /^[0-9]+$/ { print $3; exit }' +} + +# A foreground server can answer /health while this job fails to bind. Require launchd's +# PID to own the listening socket, and check the PID again after the HTTP request. +service_health_ok() { + health_pid=$(service_pid) + [ -n "$health_pid" ] || return 1 + [ "$(lsof -nP -a -p "$health_pid" -iTCP@127.0.0.1:"$port" -sTCP:LISTEN -t 2>/dev/null)" = "$health_pid" ] || return 1 + health_ok && [ "$(service_pid)" = "$health_pid" ] +} + +# Exits 0 when the service is not running and its last run failed. A run that kickstart -k +# stopped exits 0 and stays in launchd's report, so a clean exit does not count. launchd's +# report is not a stable format, so this only speeds up a failure that the wait would +# otherwise find at its end. +service_exited() { + launchctl print "gui/$uid/$LABEL" 2>/dev/null | awk ' + $1 == "state" && $3 == "running" { running = 1 } + /^[[:space:]]*last exit code = -?[1-9]/ || /^[[:space:]]*last terminating signal/ { failed = 1 } + END { exit !(failed && !running) }' +} + +# Waits up to HEALTH_WAIT seconds for the job's /health. Returns 0 when it answers, 2 when the server +# exited first and 1 on timeout. +wait_health() { + t=0 + while [ "$t" -lt "$HEALTH_WAIT" ]; do + service_exited && return 2 + service_health_ok && return 0 + sleep 1 + t=$((t + 1)) + done + return 1 +} + +# Exits 0 when the model `sys1rust serve` loads by default is in the cache. Also sets +# model_mb to its size from `sys1rust models`. +model_ready() { + row=$("$link" models /dev/null | awk '$1 == "typed-decisions"') || true + model_mb=$(printf '%s\n' "$row" | awk '{ print $(NF - 1) }') + case $model_mb in '' | *[!0-9]*) model_mb=846 ;; esac + [ "$(printf '%s\n' "$row" | awk '{ print $4 }')" = "downloaded," ] +} + +# Waits for the service that launchd just started and reports it. A first start that has to +# download the model may take longer than the wait, and that is no error. +check_service() { + downloading=0 + if ! model_ready; then + downloading=1 + say "The first start downloads the typed-decisions model ($model_mb MB) before it answers." + fi + rc=0 + wait_health || rc=$? + if [ "$rc" = 0 ]; then + rm -f "$owner/service-restart" + say "sys1rust serve answers on http://127.0.0.1:$port. Its log is $log" + return 0 + fi + if [ "$rc" = 1 ] && [ "$downloading" = 1 ]; then + say "The service is still starting. Follow the download with: tail -f $(shell_quote "$log")" + say "It answers on http://127.0.0.1:$port/health once the download and load finish." + return 0 + fi + [ -f "$log" ] && tail -n 5 "$log" >&2 + if [ "$rc" = 2 ]; then + die "the service exited before it answered /health. Read $log, and install.sh --uninstall removes the service" + fi + die "the service did not answer http://127.0.0.1:$port/health in $HEALTH_WAIT s. Read $log" +} + +write_plist() { + cat < + + + + Label + $LABEL + Sys1rustInstallPrefix + $(xml "$prefix") + ProgramArguments + + $(xml "$link") + serve + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 30 + ProcessType + Interactive + StandardOutPath + $(xml "$log") + StandardErrorPath + $(xml "$log") +EOF + if [ -n "$service_env" ]; then + say " EnvironmentVariables" + say " " + for name in $service_env; do + say " $name" + say " $(xml "$(printenv "$name")")" + done + say " " + fi + say "" + say "" +} + +install_service() { + if [ -e "$plist" ] || [ -L "$plist" ]; then + if [ -L "$plist" ] || [ -z "$(plist_value Sys1rustInstallPrefix || true)" ]; then + die "$plist is not an installer LaunchAgent. Move it away before using --service" + fi + elif service_loaded; then + die "the LaunchAgent label $LABEL is already loaded without an installer plist" + elif [ -e "$log" ] || [ -L "$log" ]; then + die "$log already exists without an installer LaunchAgent. Move it away before using --service" + fi + if [ -L "$log" ] || { [ -e "$log" ] && [ ! -f "$log" ]; }; then die "$log is not a regular log file"; fi + mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" + if service_loaded; then + launchctl bootout "gui/$uid/$LABEL" 2>/dev/null || true + # bootout sends SIGTERM, and the server finishes its requests before it exits. + t=0 + while service_loaded && [ "$t" -lt 20 ]; do + sleep 1 + t=$((t + 1)) + done + t=0 + while health_ok && [ "$t" -lt 5 ]; do + sleep 1 + t=$((t + 1)) + done + fi + if health_ok; then + die "another server answers on 127.0.0.1:$port, maybe a sys1rust serve in a terminal. Stop it and rerun with --service" + fi + # launchd starts the service without this shell's environment. These variables choose the + # model cache and the port, so the service gets them when they are set here. Then it uses + # the cache that `sys1rust pull` in this shell fills. + service_env= + for name in HF_HUB_CACHE HF_HOME XDG_CACHE_HOME LAYA_PORT; do + value=$(printenv "$name" || true) + if [ -n "$value" ]; then + case $name in HF_HUB_CACHE | HF_HOME | XDG_CACHE_HOME) value=$(abs_path "$value") ;; esac + export "$name=$value" + service_env="$service_env $name" + fi + done + # KeepAlive restarts the server when it exits with an error or crashes, but not after a + # clean stop. ThrottleInterval keeps a server that fails at once to 1 start per 30 s. + # ProcessType Interactive takes away the CPU and I/O throttling that launchd puts on + # background jobs, which would slow every request. + touch "$log" + stat -f '%d:%i' "$log" >"$owner/log-id" + plist_stage=$(mktemp "$plist.XXXXXX") + write_plist >"$plist_stage" + plutil -lint -s "$plist_stage" || die "the LaunchAgent plist is not valid" + mv -f "$plist_stage" "$plist" + plist_stage= + printf '%s\n' "$plist" >"$owner/service" + launchctl bootstrap "gui/$uid" "$plist" || + die "launchctl could not load $plist. A LaunchAgent needs a user logged in to this Mac's desktop" + say "Installed the LaunchAgent $plist" + for name in $service_env; do say " with $name=$(printenv "$name")"; done + check_service +} + +uninstall() { + [ "$(uname -s)" = Darwin ] || die "sys1rust runs only on macOS" + [ -d "$owner" ] || die "$prefix has no installer registry. Nothing removed" + if ! plist_runs_link; then + say "Left the LaunchAgent $plist and its log, since it runs another sys1rust than $link" + else + read_service_env + if service_loaded; then + launchctl bootout "gui/$uid/$LABEL" 2>/dev/null || true + t=0 + while service_loaded && [ "$t" -lt 20 ]; do + sleep 1 + t=$((t + 1)) + done + service_loaded && die "launchctl bootout gui/$uid/$LABEL did not stop the service" + say "Stopped the sys1rust service" + fi + if [ -e "$plist" ]; then + rm -f "$plist" + say "Removed $plist" + fi + if [ -f "$log" ] && [ ! -L "$log" ] && [ "$(stat -f '%d:%i' "$log")" = "$(cat "$owner/log-id" 2>/dev/null)" ]; then + rm -f "$log" + say "Removed $log" + fi + fi + remove_owned_link "$link" "$owner/bin-target" + # A --bin-dir inside the prefix goes with it once it is empty. + case $bin_dir in + "$prefix"/*) rmdir "$bin_dir" 2>/dev/null || true ;; + esac + if [ -d "$prefix" ]; then + remove_owned_link "$prefix/current" "$owner/current" + remove_owned_link "$prefix/previous" "$owner/previous" + for entry in "$owner/versions"/*; do + [ -f "$entry" ] || continue + name=${entry##*/} + if owned_version "$name"; then rm -rf "${prefix:?}/$name"; fi + rm -f "$entry" + done + rm -f "$owner/format" "$owner/bin-link" "$owner/bin-target" "$owner/bin-target.next" \ + "$owner/current" "$owner/current.next" "$owner/previous" "$owner/previous.next" "$owner/service" "$owner/log-id" "$owner/service-restart" + rmdir "$owner/versions" "$owner" 2>/dev/null || true + # cleanup releases the lock and removes the prefix when it has no foreign files. + say "Removed the registered install from $prefix. Any unrelated files stay there" + fi + hub=$(hub_dir) + set -- "$hub"/models--convaiinnovations--laya* + if [ -d "$1" ]; then + # Counts each file once, by inode, since a cache's snapshots link to its blobs and a + # cache can also link blobs to files that several repos share. + mb=$(find -L "$@" -type f -exec stat -L -f '%d:%i %z' {} + 2>/dev/null | + awk '!seen[$1]++ { s += $2 } END { printf "%d", (s + 500000) / 1000000 }') + say "Kept the Laya models in the model cache $hub, $mb MB. To delete them, run:" + say " rm -rf $(shell_quote "$hub")/models--convaiinnovations--laya*" + else + say "The model cache $hub holds no Laya models" + fi +} + +main() { + trap '' PIPE + version= + from= + prefix= + bin_dir= + service=0 + remove=0 + while [ $# -gt 0 ]; do + case $1 in + --version | --from | --prefix | --bin-dir) + if [ $# -lt 2 ] || [ -z "$2" ]; then usage_error "$1 needs a value"; fi + opt=$1 + val=$2 + shift 2 + ;; + --version=* | --from=* | --prefix=* | --bin-dir=*) + opt=${1%%=*} + val=${1#*=} + shift + [ -n "$val" ] || usage_error "$opt needs a value" + ;; + --service) + service=1 + shift + continue + ;; + --uninstall) + remove=1 + shift + continue + ;; + -h | --help) + usage + exit 0 + ;; + *) usage_error "unknown option '$1'" ;; + esac + case $opt in + --version) version=$val ;; + --from) from=$val ;; + --prefix) prefix=$val ;; + --bin-dir) bin_dir=$val ;; + esac + done + if [ "$remove" = 1 ] && { [ -n "$version" ] || [ -n "$from" ] || [ "$service" = 1 ]; }; then + usage_error "--uninstall takes only --prefix and --bin-dir" + fi + [ -z "$version" ] || [ -z "$from" ] || usage_error "--version and --from do not go together" + if [ -n "$version" ]; then + case $version in v*) ;; *) version=v$version ;; esac + valid_tag "$version" || usage_error "--version takes a tag such as v0.1.0 or v0.1.0-rc1" + fi + + prefix=$(abs_path "${prefix:-$HOME/.local/share/sys1rust}") + bin_dir=$(abs_path "${bin_dir:-$HOME/.local/bin}") + [ "$prefix" != / ] || die "--prefix cannot be /" + link=$bin_dir/sys1rust + uid=$(id -u) + plist=$HOME/Library/LaunchAgents/$LABEL.plist + log=$HOME/Library/Logs/sys1rust.log + port=${LAYA_PORT:-8000} + if [ "$remove" != 1 ]; then + case $port in '' | *[!0-9]*) die "LAYA_PORT is '$port', not a port number" ;; esac + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then die "LAYA_PORT must be between 1 and 65535"; fi + fi + + stage= + pending_link= + plist_stage= + locked=0 + shared_locked=0 + bin_locked=0 + lock=$prefix/.lock + shared_lock=$HOME/Library/LaunchAgents/.$LABEL.install.lock + bin_lock=$bin_dir/.sys1rust-install.lock + owner=$prefix/.sys1rust-install + check_prefix + if [ "$remove" = 1 ]; then + [ -d "$owner" ] || die "$prefix has no installer registry. Nothing removed" + fi + mkdir -p "$prefix" + # Canonicalize the prefix so two paths through parent symlinks use the same lock. + prefix=$(cd "$prefix" && pwd -P) + lock=$prefix/.lock + owner=$prefix/.sys1rust-install + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + acquire_lock "$lock" + check_prefix + # Every service uses the same label and plist. Installs take the bin lock after bundle + # validation below, so a failed first download never creates bin parents inside an + # unregistered prefix. + mkdir -p "${shared_lock%/*}" + acquire_lock "$shared_lock" + + if [ "$remove" = 1 ]; then + mkdir -p "$bin_dir" + acquire_lock "$bin_lock" + uninstall + return 0 + fi + + check_mac + # Refuse a foreign link before downloading, then check it again under the bin lock. + check_bin_link + check_owned_link "$prefix/current" "$owner/current" + check_owned_link "$prefix/previous" "$owner/previous" + if [ -n "$from" ]; then + [ -d "$from" ] || die "--from $from is not a directory" + from=$(abs_path "$from") + set -- "$from"/sys1rust-*-"$flavor"-arm64.tar.gz + [ -f "$1" ] || die "$from has no sys1rust--$flavor-arm64.tar.gz" + [ $# = 1 ] || die "$from has $# $flavor tarballs. Keep 1" + name=${1##*/} + name=${name#sys1rust-} + tag=v${name%-"$flavor"-arm64.tar.gz} + valid_tag "$tag" || die "cannot read a version from $1" + elif [ -n "$version" ]; then + tag=$version + else + latest_tag + fi + + dir= + if [ -z "$from" ]; then + for entry in "$(readlink "$prefix/current" 2>/dev/null || true)" \ + "$(readlink "$prefix/previous" 2>/dev/null || true)" "$owner/versions"/*; do + name=${entry##*/} + if owned_version "$name" && [ "$(cat "$prefix/$name/.sys1rust-release")" = "$tag-$flavor" ] && + "$prefix/$name/bin/sys1rust" --version /dev/null 2>&1; then + dir=$name + break + fi + done + fi + if [ -n "$dir" ]; then + target=$prefix/$dir + say "sys1rust $tag for $flavor is already in $target" + else + stage=$(mktemp -d "$prefix/.install.XXXXXX") + get_bundle + create_owner + dir=${tag#v}-$flavor.${stage##*.} + target=$prefix/$dir + if [ -e "$target" ] || [ -L "$target" ]; then die "$target already exists"; fi + printf '%s\n' "$dir" >"$new/.sys1rust-owned" + printf '%s\n' "$tag-$flavor" >"$new/.sys1rust-release" + : >"$owner/versions/$dir" + fi + + # The bundle is ready and the prefix has its registry before any bin parents appear. + # Different HOME values can still share this bin directory, so recheck its link after + # acquiring the lock and before switching current or changing the bin link. + mkdir -p "$bin_dir" + acquire_lock "$bin_lock" + check_bin_link + if [ -n "$stage" ]; then + # The working version stays in place. Only current changes after this move succeeds. + mv "$new" "$target" + fi + + old=$(readlink "$prefix/current" 2>/dev/null || true) + if [ "$old" != "$dir" ]; then + # Persist the restart before switching current. A retry must restart an old process + # even when an interrupted invocation already switched the link to this directory. + if plist_runs_link && service_loaded; then : >"$owner/service-restart"; fi + # Save the previous link first so interruption after the current switch keeps both. + if [ -n "$old" ] && owned_version "$old"; then record_link "$old" "$prefix/previous" "$owner/previous"; fi + record_link "$dir" "$prefix/current" "$owner/current" + fi + if [ "$(readlink "$link" 2>/dev/null || true)" != "$prefix/current/bin/sys1rust" ]; then + record_link "$prefix/current/bin/sys1rust" "$link" "$owner/bin-target" + fi + installed=$("$link" --version &1) || die "$link --version failed: $installed" + say "Installed $installed in $target" + say "Linked $link" + + # Keeps current and previous and deletes older versions. + previous=$(readlink "$prefix/previous" 2>/dev/null || true) + for entry in "$owner/versions"/*; do + [ -f "$entry" ] || continue + name=${entry##*/} + if [ "$name" = "$dir" ] || [ "$name" = "$previous" ]; then continue; fi + if owned_version "$name"; then + rm -rf "${prefix:?}/$name" + say "Removed the old version $name" + fi + rm -f "$entry" + done + + run=$(shell_quote "$link") + case ":$PATH:" in + *":$bin_dir:"*) + found=$(command -v sys1rust || true) + if [ "$found" != "$link" ]; then say "$found comes before $link on PATH, so sys1rust runs that one"; fi + ;; + *) + say "$bin_dir is not on PATH. For zsh, add this line to ~/.zshrc and open a new terminal:" + say " export PATH=$(shell_quote "$bin_dir"):\"\$PATH\"" + ;; + esac + + if [ "$service" = 1 ]; then + install_service + elif plist_runs_link && service_loaded; then + read_service_env + if [ -f "$owner/service-restart" ]; then + # launchd starts a job at most once per ThrottleInterval, so this waits up to 30 s + # when the service started less than 30 s ago. + say "Restarting the sys1rust service on the new version" + launchctl kickstart -k "gui/$uid/$LABEL" || die "launchctl kickstart could not restart the service" + check_service + else + check_service + say "The sys1rust service runs this version. Its log is $log" + fi + else + say "Next, start the server: $run serve" + if ! model_ready; then + say "Its first start downloads the typed-decisions model ($model_mb MB). To download it first, run: $run pull typed-decisions" + fi + fi +} + +main "$@" diff --git a/packaging/RELEASE.md b/packaging/RELEASE.md index a382e85..c9be0b7 100644 --- a/packaging/RELEASE.md +++ b/packaging/RELEASE.md @@ -1,8 +1,10 @@ # Releasing sys1rust -Pushing a `v*` tag runs `.github/workflows/release.yml`. It builds both bundles on a macOS 26 runner, smoke-tests the macOS 14 bundle on a macOS 15 runner and the macOS 26 bundle on a macOS 26 runner, and publishes the GitHub release with `SHA256SUMS`. A final `vX.Y.Z` tag also updates `Formula/sys1rust.rb` in krishhgg/homebrew-tap. A `vX.Y.Z-rcN` tag publishes a prerelease and leaves the tap alone. A pull request that changes `packaging/` or the workflow runs the build and smoke jobs only. +Pushing a `v*` tag runs `.github/workflows/release.yml`. It builds both bundles on a macOS 26 runner, installs the macOS 14 bundle with `install.sh` on a macOS 15 runner and the macOS 26 bundle on a macOS 26 runner, smoke-tests them there, and publishes the GitHub release with both bundles and `SHA256SUMS`. A `vX.Y.Z-rcN` tag publishes a prerelease. `install.sh` installs the latest final release, and a prerelease only through `--version`. A pull request that changes `packaging/`, `install.sh` or the workflow runs the build and smoke jobs only. -Run every command below from the repository root on a Mac with an M5-class GPU (GPU generation 17 or later) and macOS 26.2 or later. The strict suite's laya-mlx `settings` tests need MLX's NAX gemms and fail on the fallback that older GPUs get, while the smoke tests run on any Apple silicon Mac. Use 1 shell for all the commands, because later steps reuse `version`, `RUSTUP_TOOLCHAIN`, `HF_HUB_CACHE` and `rc`. The tests, the smoke runs and the Homebrew service load a model on the GPU. Run each of them alone, with no other GPU work on the Mac. +The build job also runs `packaging/test_install.sh /bin/sh` and `packaging/test_install.sh /bin/dash`. They test `install.sh` against fake bundles, a local release server and a fake `launchctl`, and need no build, network or GPU. Run them after any change to `install.sh`. + +Run every command below from the repository root on a Mac with an M5-class GPU (GPU generation 17 or later) and macOS 26.2 or later. The strict suite's laya-mlx `settings` tests need MLX's NAX gemms and fail on the fallback that older GPUs get, while the smoke tests run on any Apple silicon Mac. Use 1 shell for all the commands, because later steps reuse `version`, `RUSTUP_TOOLCHAIN`, `HF_HUB_CACHE` and `rc`. The tests, the smoke runs and the server in step 5 load a model on the GPU. Run each of them alone, with no other GPU work on the Mac. 1. Install the pinned Rust and select it for this shell. `packaging/build.sh` and `packaging/third_party.py` stop when `rustc` or `cargo` reports another version. ```sh @@ -22,8 +24,11 @@ Run every command below from the repository root on a Mac with an M5-class GPU ( ``` 3. Build both bundles, run the strict suite against the macOS 26 MLX build, and smoke-test both bundles. The laya-mlx tests do not ask for the pinned revisions. They load the snapshot that a model's `refs/main` names, or else the model's only snapshot, and `sys1rust pull` writes no `refs/main`. So this step pulls the 3 pinned models (846 MB, 678 MB and 846 MB) into a fresh cache under `packaging/.work`, which then holds only the pinned snapshots. `HF_HUB_CACHE` points every later command in this shell at that cache. Every command must pass. The laya-mlx `settings` tests take about 14 minutes. ```sh - packaging/build.sh macos26 dist && packaging/build.sh macos14 dist export HF_HUB_CACHE=$PWD/packaging/.work/hf-cache + ( + set -e + packaging/build.sh macos26 dist + packaging/build.sh macos14 dist rm -rf "$HF_HUB_CACHE" for m in typed-decisions multilingual english; do packaging/.work/macos26/sys1rust-$version-macos26-arm64/bin/sys1rust pull $m @@ -39,38 +44,67 @@ Run every command below from the repository root on a Mac with an M5-class GPU ( for fl in macos26 macos14; do python3 packaging/smoke.py packaging/.work/$fl/sys1rust-$version-$fl-arm64/bin/sys1rust done + ) ``` -4. Tag a release candidate and push the tag. `rc` holds the candidate's tag for this step and the next. The workflow builds and smoke-tests the bundles and publishes a prerelease. It leaves the tap alone. Follow the run on the repository's Actions page. If a job fails, merge the fix to main, run `git pull --ff-only`, set `rc` to the next candidate, such as `v$version-rc2`, and run the tag line again. +4. Tag a release candidate and push the tag. `rc` holds the candidate's tag for this step and the next. The workflow builds and smoke-tests the bundles and publishes a prerelease. Follow the run on the repository's Actions page. If a job fails, merge the fix to main, run `git pull --ff-only`, set `rc` to the next candidate, such as `v$version-rc2`, and run the tag line again. ```sh rc=v$version-rc1 git tag "$rc" && git push origin "$rc" ``` -5. Install the candidate through Homebrew from a throwaway local tap. `packaging/render_formula.sh` writes the formula for the prerelease with the hashes from its `SHA256SUMS`. The service does not see this shell's `HF_HUB_CACHE`, so its first start downloads typed-decisions (846 MB) if `~/.cache/huggingface/hub` does not hold it yet. If `curl` never answers, read `$(brew --prefix)/var/log/sys1rust.log`. +5. Install the candidate with `install.sh` into a scratch prefix, as a user would. `install.sh` downloads the bundle for this Mac and checks it against the prerelease's `SHA256SUMS`. Then check that this candidate serves on a free port in this shell and as a LaunchAgent. The Python block reads the candidate's ready line, verifies its process id and checks its own `/health`, with a 60 s deadline. It always stops the child. `--service` passes this shell's `HF_HUB_CACHE` into the LaunchAgent, so the service loads typed-decisions from the cache that step 3 filled. `install.sh --service` waits up to 60 s for `/health`, and if the server fails it prints the end of `~/Library/Logs/sys1rust.log`. Every install uses the same LaunchAgent label, so `--service` here replaces a sys1rust service that this account already runs. Run `install.sh --service` again afterward to bring that one back. `--uninstall` removes the scratch install, the LaunchAgent and its log, and keeps the models. ```sh - sums=$(gh release download "$rc" --repo krishhgg/sys1rust --pattern SHA256SUMS --output -) - sha() { printf '%s\n' "$sums" | awk -v f="sys1rust-$version-$1-arm64.tar.gz" '$2 == f {print $1}'; } - brew tap-new --no-git local/sys1rust-test - packaging/render_formula.sh "$rc" "$(sha macos26)" "$(sha macos14)" \ - > "$(brew --repository local/sys1rust-test)/Formula/sys1rust.rb" - HOMEBREW_NO_AUTO_UPDATE=1 brew install local/sys1rust-test/sys1rust - brew test local/sys1rust-test/sys1rust - brew services start local/sys1rust-test/sys1rust - until curl -fsS 127.0.0.1:8000/health; do sleep 2; done - brew services stop local/sys1rust-test/sys1rust - brew uninstall sys1rust && brew untap local/sys1rust-test + ( + set -e + tmp=$(mktemp -d) + ./install.sh --version "$rc" --prefix "$tmp" --bin-dir "$tmp/bin" + python3 - "$tmp/bin/sys1rust" <<'PY' + import json, select, subprocess, sys, time, urllib.request + + proc = subprocess.Popen([sys.argv[1], "serve", "--port", "0"], + stdout=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 60 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("candidate did not become ready in 60 s") + if proc.poll() is not None: + raise RuntimeError(f"candidate exited with code {proc.returncode}") + if select.select([proc.stdout], [], [], min(remaining, 0.2))[0]: + line = proc.stdout.readline() + if not line: + raise RuntimeError("candidate closed stdout before becoming ready") + ready = json.loads(line) + if ready["event"] != "listening" or ready["pid"] != proc.pid: + raise RuntimeError(f"unexpected candidate ready line: {ready}") + break + with urllib.request.urlopen(f"http://{ready['addr']}/health", timeout=5) as reply: + assert json.load(reply)["status"] == "ok" + print(f"candidate {proc.pid} answers on {ready['addr']}") + finally: + proc.terminate() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + PY + export LAYA_PORT=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') + ./install.sh --version "$rc" --prefix "$tmp" --bin-dir "$tmp/bin" --service + ./install.sh --uninstall --prefix "$tmp" --bin-dir "$tmp/bin" + ) ``` -6. Tag the release and push the tag. The workflow publishes the release and pushes `Formula/sys1rust.rb` to krishhgg/homebrew-tap with the `TAP_TOKEN` secret, a fine-grained token with contents read and write on that repo only. The publish job stops on an empty secret. Before it creates the release, it also runs `git push --dry-run` against the tap with the token. GitHub answers that only for a token that it accepts and that has push access to the tap, so an expired, revoked or read-only token also stops the job before anything goes public. The dry run sends no commits, so it does not check branch rules on the tap's main. +6. Tag the release and push the tag. The workflow publishes the release, and `releases/latest` then points to it, so `install.sh` without `--version` installs it. ```sh git tag "v$version" && git push origin "v$version" ``` -7. On a clean account, or after `brew uninstall sys1rust`, check that `brew install krishhgg/tap/sys1rust`, `sys1rust serve` and `brew services start sys1rust` work. - -## If the tap update fails - -If the publish job fails at the token check or at the push because `TAP_TOKEN` expired or lost access, make a new fine-grained token with contents read and write on krishhgg/homebrew-tap only. Store it with the command below, which prompts for the value. Then open the failed run on the Actions page and choose "Re-run failed jobs". If a cancelled attempt left a draft release of the tag, the rerun stops and names it. Delete it on the Releases page, or with `gh release delete ` after checking that it is still a draft, then rerun the publish job. The rerun keeps a published release only if that release's 2 bundles and `SHA256SUMS` match the run's files, and otherwise stops. Then it pushes the formula. If the formula is already up to date, the job finishes without a commit. If the tap already has a newer version, the job leaves the tap alone and says so in a notice. "Re-run all jobs" builds new bundles, and the publish job then stops rather than replace the published files. +7. On a clean macOS account, check the one-line install from main with the first line below. Add `~/.local/bin` to `PATH` if `install.sh` prints the line for it. Then check that `sys1rust serve` answers `curl 127.0.0.1:8000/health` from another shell. Its first start downloads typed-decisions (846 MB). Stop the server, then run the other 2 lines to check the service and the uninstall. + ```sh + curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh -s -- --service + curl -fsSL https://raw.githubusercontent.com/krishhgg/sys1rust/main/install.sh | sh -s -- --uninstall + ``` -```sh -gh secret set TAP_TOKEN --repo krishhgg/sys1rust -``` +## If the publish job fails -Publish jobs of all tags run one at a time, and GitHub holds at most 1 more waiting. A third tag pushed while 1 publish job runs and another waits cancels the waiting job. Rerun it. +Open the failed run on the Actions page and choose "Re-run failed jobs". If a cancelled attempt left a draft release of the tag, the rerun stops and names it. Delete it on the Releases page, or with `gh release delete ` after checking that it is still a draft, then rerun the publish job. The rerun keeps a published release only if that release's 2 bundles and `SHA256SUMS` match the run's files, and otherwise stops. "Re-run all jobs" builds new bundles, and the publish job then stops rather than replace the published files. diff --git a/packaging/render_formula.sh b/packaging/render_formula.sh deleted file mode 100755 index 013fe88..0000000 --- a/packaging/render_formula.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Print the Homebrew formula for a release: -# packaging/render_formula.sh TAG SHA256_MACOS26 SHA256_MACOS14 [BASE_URL] -# BASE_URL defaults to the GitHub release of TAG. A file:// URL tests a local build. -set -euo pipefail -TAG=$1 -S26=$2 -S14=$3 -BASE=${4:-https://github.com/krishhgg/sys1rust/releases/download/$TAG} -VERSION=${TAG#v} -VERSION=${VERSION%%-rc*} -for s in "$S26" "$S14"; do - [[ $s =~ ^[0-9a-f]{64}$ ]] || { echo "render_formula.sh: not a sha256: '$s'" >&2; exit 1; } -done -# Escapes \, & and the | delimiter so sed puts the value in literally. -lit() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } -sed -e "s|@VERSION@|$(lit "$VERSION")|g" -e "s|@BASE_URL@|$(lit "$BASE")|g" \ - -e "s|@SHA256_MACOS26@|$S26|g" -e "s|@SHA256_MACOS14@|$S14|g" \ - "$(dirname "$0")/sys1rust.rb.in" diff --git a/packaging/sys1rust.rb.in b/packaging/sys1rust.rb.in deleted file mode 100644 index f6f5c5e..0000000 --- a/packaging/sys1rust.rb.in +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by packaging/render_formula.sh in krishhgg/sys1rust for each release. -# The next release overwrites edits here. -class Sys1rust < Formula - desc "Laya System 1 decision server for Apple silicon, in Rust and Metal" - homepage "https://github.com/krishhgg/sys1rust" - version "@VERSION@" - license "Apache-2.0" - - depends_on arch: :arm64 - depends_on macos: :sonoma - - # MLX's build for macOS 26.2 and later is about 3x faster on the M5 than its macOS 14 build. - # on_macos keeps the MacOS call off Linux, where brew style rejects it. - on_macos do - if MacOS.full_version >= "26.2" - url "@BASE_URL@/sys1rust-@VERSION@-macos26-arm64.tar.gz" - sha256 "@SHA256_MACOS26@" - else - url "@BASE_URL@/sys1rust-@VERSION@-macos14-arm64.tar.gz" - sha256 "@SHA256_MACOS14@" - end - end - - def install - libexec.install Dir["*"] - bin.install_symlink libexec/"bin/sys1rust" - end - - def caveats - <<~EOS - The first `sys1rust serve` downloads the typed-decisions model (846 MB) into the - Hugging Face cache (~/.cache/huggingface/hub). `sys1rust pull` downloads it ahead of time. - - The first start after an install or upgrade compiles Metal shaders for about 1.7 s. - - The `brew services start` command below runs the server on 127.0.0.1:8000. - EOS - end - - service do - run [opt_bin/"sys1rust", "serve"] - keep_alive true - log_path var/"log/sys1rust.log" - error_log_path var/"log/sys1rust.log" - end - - test do - assert_match version.to_s, shell_output("#{bin}/sys1rust --version") - assert_match "typed-decisions", shell_output("#{bin}/sys1rust models") - end -end diff --git a/packaging/test_install.sh b/packaging/test_install.sh new file mode 100755 index 0000000..23a20d3 --- /dev/null +++ b/packaging/test_install.sh @@ -0,0 +1,581 @@ +#!/bin/bash +# Tests install.sh without the network, the GPU or launchd: packaging/test_install.sh [SHELL] +# SHELL runs install.sh and defaults to /bin/sh. Fake bundles hold a shell script in place of +# sys1rust. Fake uname, sysctl and sw_vers set the Mac that install.sh sees. A local server +# stands in for GitHub releases. A fake launchctl records its calls and serves /health on +# LAYA_PORT while the service is loaded. Each run of install.sh gets a clean environment with +# HOME in a temp dir, so the test touches nothing outside that dir. +# Most checks are single-quoted strings that check() evals, so their variables expand then. +# shellcheck disable=SC2016,SC2034 +set -euo pipefail + +SH=${1:-/bin/sh} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d "${TMPDIR:-/tmp}/sys1rust-install-test.XXXXXX") +T=$(cd "$T" && pwd -P) +SERVER_PID= +INSTALL_PID= +cleanup() { + if [ -n "$INSTALL_PID" ]; then kill "$INSTALL_PID" && wait "$INSTALL_PID"; fi 2>/dev/null || true + if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" && wait "$SERVER_PID"; fi 2>/dev/null || true + [ ! -f "$T/launchd/pid" ] || kill "$(cat "$T/launchd/pid")" 2>/dev/null || true + rm -rf "$T" +} +trap cleanup EXIT + +passed=0 +failed=0 +ok() { passed=$((passed + 1)); } +bad() { failed=$((failed + 1)); echo "FAIL: $*"; printf '%s\n' "$out" | sed 's/^/ | /'; } +# check WHAT CMD...: counts CMD's result. +check() { + local what=$1 + shift + if "$@"; then ok; else bad "$what"; fi +} +has() { [[ $out == *"$1"* ]]; } +link_is() { [ "$(readlink "$1" 2>/dev/null)" = "$2" ]; } +version_is() { case $(readlink "$1" 2>/dev/null) in "$2".??????) return 0 ;; *) return 1 ;; esac; } + +free_port() { python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])'; } + +# The fake system tools. +mkdir -p "$T/fake" "$T/launchd/www" "$T/home" +touch "$T/launchd/www/health" +cat >"$T/fake/uname" <<'EOF' +#!/bin/sh +case $1 in + -s) echo "${FAKE_UNAME_S:-Darwin}" ;; + -m) echo "${FAKE_UNAME_M:-arm64}" ;; + *) exec /usr/bin/uname "$@" ;; +esac +EOF +cat >"$T/fake/sysctl" <<'EOF' +#!/bin/sh +if [ "$2" = sysctl.proc_translated ]; then echo "${FAKE_TRANSLATED:-0}"; else exec /usr/sbin/sysctl "$@"; fi +EOF +cat >"$T/fake/sw_vers" <<'EOF' +#!/bin/sh +echo "${FAKE_MACOS:-26.2}" +EOF +cat >"$T/fake/launchctl" <<'EOF' +#!/bin/sh +# Records each call. bootstrap starts a /health server on LAYA_PORT, or with fail-next a +# server that exits 1 at once, kickstart restarts it and bootout stops it. After kickstart -k, +# print keeps the stopped run's exit code 0 in its report, as launchd does. +d=$FAKE_LAUNCHD +echo "$*" >>"$d/calls" +case $1 in + print) + [ -f "$d/loaded" ] || exit 113 + if [ -f "$d/failed" ]; then + printf '\tstate = spawn scheduled\n\tlast exit code = 1\n' + elif [ -f "$d/kicked" ]; then + printf '\tstate = running\n\tlast exit code = 0\n' + else + printf '\tstate = running\n\tlast exit code = (never exited)\n' + fi + [ ! -f "$d/pid" ] || printf '\tpid = %s\n' "$(cat "$d/pid")" + ;; + bootstrap) + [ ! -f "$d/loaded" ] || exit 37 + cp "$3" "$d/plist" + if [ -f "$d/fail-next" ]; then + touch "$d/failed" + else + python3 -m http.server --bind 127.0.0.1 --directory "$d/www" "$(plutil -extract EnvironmentVariables.LAYA_PORT raw "$d/plist" 2>/dev/null || echo 8000)" >/dev/null 2>&1 "$d/pid" + fi + touch "$d/loaded" + ;; + kickstart) + # The new run answers 2 s later, as a real server does after loading the model. + [ ! -f "$d/kick-fail-next" ] || exit 5 + [ ! -f "$d/pid" ] || kill "$(cat "$d/pid")" 2>/dev/null || true + if [ -f "$d/fail-next" ]; then + rm -f "$d/pid" + touch "$d/failed" + exit 0 + fi + if [ -f "$d/no-listener-next" ]; then + # The job has a live PID before it exits, while a different process serves /health. + (sleep 2; touch "$d/failed"; sleep 30) >/dev/null 2>&1 "$d/pid" + exit 0 + fi + (sleep 2 && exec python3 -m http.server --bind 127.0.0.1 --directory "$d/www" "$(plutil -extract EnvironmentVariables.LAYA_PORT raw "$d/plist" 2>/dev/null || echo 8000)") \ + >/dev/null 2>&1 "$d/pid" + rm -f "$d/failed" + touch "$d/kicked" + ;; + bootout) + [ -f "$d/loaded" ] || exit 113 + [ ! -f "$d/pid" ] || kill "$(cat "$d/pid")" + rm -f "$d/loaded" "$d/pid" "$d/failed" "$d/kicked" + ;; +esac +EOF +cat >"$T/fake/mv" <<'EOF' +#!/bin/sh +case $1 in + -fh) + if [ "${FAKE_MV_MODE:-}" = after-switch ]; then + case $3 in + */current) /bin/mv "$@"; kill -TERM "$PPID"; exit 1 ;; + esac + fi + ;; + */.install.*/sys1rust-*) + case ${FAKE_MV_MODE:-} in + fail) exit 1 ;; + signal) kill -TERM "$PPID"; exit 1 ;; + pause) + touch "$FAKE_MV_READY" + while [ ! -e "$FAKE_MV_RESUME" ]; do sleep 0.1; done + ;; + esac + ;; +esac +exec /bin/mv "$@" +EOF +chmod +x "$T/fake"/* + +# make_bundle VERSION FLAVOR DIR: writes a fake bundle tarball into DIR and rewrites DIR/SHA256SUMS. +make_bundle() { + local name=sys1rust-$1-$2-arm64 + mkdir -p "$T/build/$name/bin" "$T/build/$name/lib" "$3" + cat >"$T/build/$name/bin/sys1rust" <"\$FAKE_MODELS_ENV" + echo "MODEL REPO REVISION STATUS" + echo "typed-decisions convaiinnovations/laya-typed-decisions 1a793eb \${FAKE_MODEL:-not downloaded, 846 MB}" + ;; + *) exit 1 ;; +esac +EOF + chmod +x "$T/build/$name/bin/sys1rust" + tar -C "$T/build" -czf "$3/$name.tar.gz" "$name" + (cd "$3" && shasum -a 256 sys1rust-*.tar.gz >SHA256SUMS) +} + +# run [VAR=VALUE ...] [--] ARGS...: runs install.sh in a clean environment and sets out and status. +run() { + local envs=() + while [ $# -gt 0 ] && [[ $1 == *=* ]]; do envs+=("$1"); shift; done + [ "${1:-}" != -- ] || shift + status=0 + out=$(env -i HOME="$T/home" PATH="$T/fake:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="$T" \ + FAKE_LAUNCHD="$T/launchd" LAYA_PORT="$PORT" ${envs[@]+"${envs[@]}"} "$SH" "$ROOT/install.sh" "$@" 2>&1) || status=$? +} + +# state DIR...: prints the paths under DIR and the targets of its links, to compare 2 runs. +state() { + find "$@" | sort | while IFS= read -r f; do + if [ -L "$f" ]; then echo "$f -> $(readlink "$f")"; else echo "$f"; fi + done +} + +PORT=$(free_port) +P=$T/p +B=$T/b +make_bundle 0.1.0 macos26 "$T/d1" +make_bundle 0.1.0 macos14 "$T/d1" +make_bundle 0.2.0 macos26 "$T/d2" +make_bundle 0.3.0 macos26 "$T/d3" +make_bundle 0.4.0 macos26 "$T/d4" +make_bundle 0.6.0 macos26 "$T/d6" + +# Help and options, also piped as `curl ... | sh` runs it. +status=0 +out=$(env -i PATH=/usr/bin:/bin HOME="$T/home" "$SH" -s -- --help <"$ROOT/install.sh" 2>&1) || status=$? +check "piped --help" eval '[ $status = 0 ] && has "Usage: install.sh"' +run --bogus +check "unknown option exits 2" eval '[ $status = 2 ] && has "install.sh: unknown option"' +run --version +check "--version without a value" eval '[ $status = 2 ] && has "install.sh: --version needs a value"' +run --version 1.2 +check "a bad tag" eval '[ $status = 2 ] && has "install.sh: --version takes a tag"' +run --uninstall --service +check "--uninstall with --service" eval '[ $status = 2 ] && has "--uninstall takes only"' + +# Platform checks. +run FAKE_UNAME_S=Linux -- --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "Linux" eval '[ $status = 1 ] && has "install.sh: sys1rust runs only on macOS, and this system is Linux"' +run FAKE_TRANSLATED=1 FAKE_UNAME_M=x86_64 -- --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "Rosetta" eval '[ $status = 1 ] && has "install.sh: this shell runs under Rosetta"' +run FAKE_UNAME_M=x86_64 -- --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "Intel" eval '[ $status = 1 ] && has "install.sh: sys1rust needs an Apple silicon (arm64) Mac, and this one is x86_64"' +run FAKE_MACOS=13.6.1 -- --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "macOS 13" eval '[ $status = 1 ] && has "install.sh: sys1rust needs macOS 14.0 or later, and this Mac runs 13.6.1"' +check "failed checks make no prefix" eval '[ ! -e "$P" ]' + +# The macOS version picks the bundle. +for pair in 26.2:macos26 26.10:macos26 26.2.1:macos26 27:macos26 26.1:macos14 26:macos14 \ + 15.7.1:macos14 14.0:macos14 14:macos14; do + rm -rf "$T/f" + run FAKE_MACOS="${pair%%:*}" -- --from "$T/d1" --prefix "$T/f" --bin-dir "$T/f/bin" + check "macOS ${pair%%:*} picks ${pair#*:}" version_is "$T/f/current" "0.1.0-${pair#*:}" +done + +# Install, rerun, update, prune and a bad checksum, all from local dirs. +run --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "install from d1" eval '[ $status = 0 ] && has "Installed sys1rust 0.1.0 (MLX 0.32.2, macos26 build) in $P/0.1.0-macos26"' +first=$(readlink "$P/current") +check "version dir" test -x "$P/$first/bin/sys1rust" +check "current link" version_is "$P/current" 0.1.0-macos26 +check "bin link" link_is "$B/sys1rust" "$P/current/bin/sys1rust" +check "--version through the link" eval '[ "$("$B/sys1rust" --version)" = "sys1rust 0.1.0 (MLX 0.32.2, macos26 build)" ]' +check "PATH line for zsh" has " export PATH='$B':\"\$PATH\"" +check "next step" has "Next, start the server: '$B/sys1rust' serve" +check "model size" has "typed-decisions model (846 MB)" +check "no staging dir left" eval '[ -z "$(find "$P" -maxdepth 1 -name ".install.*")" ]' +run --from "$T/d1" --prefix "$P" --bin-dir "$B" +check "rerun succeeds" test "$status" = 0 +check "rerun switches immutable directories" eval '[ "$(readlink "$P/current")" != "$first" ] && link_is "$P/previous" "$first" && [ -x "$P/$first/bin/sys1rust" ]' +second=$(readlink "$P/current") +run --from "$T/d2" --prefix "$P" --bin-dir "$B" +check "update to 0.2.0" eval '[ $status = 0 ] && version_is "$P/current" 0.2.0-macos26 && version_is "$P/previous" 0.1.0-macos26' +check "0.1.0 kept as previous" test -d "$P/$second" +run --from "$T/d3" --prefix "$P" --bin-dir "$B" +check "update to 0.3.0" eval '[ $status = 0 ] && version_is "$P/current" 0.3.0-macos26 && version_is "$P/previous" 0.2.0-macos26' +check "0.1.0 pruned" eval '[ ! -e "$P/$second" ] && has "Removed the old version 0.1.0-macos26"' +check "2 versions left" eval '[ "$(find "$P" -mindepth 1 -maxdepth 1 -type d ! -name ".*" | wc -l | tr -d " ")" = 2 ]' +# head closes the pipe after 1 line, and each install must still finish, pruning included. +status=0 +for d in d3 d2 d1; do + env -i HOME="$T/home" PATH="$T/fake:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="$T" FAKE_LAUNCHD="$T/launchd" \ + "$SH" "$ROOT/install.sh" --from "$T/$d" --prefix "$T/h" --bin-dir "$T/hb" | head -n 1 >/dev/null || status=$? +done +out=$(ls -a "$T/h") +check "output closed early still finishes" eval '[ $status = 0 ] && version_is "$T/h/current" 0.1.0-macos26 && [ -z "$(find "$T/h" -maxdepth 1 -name "0.3.0-macos26.*")" ] && [ ! -e "$T/h/.lock" ]' +printf 'corrupt' | dd of="$T/d4/sys1rust-0.4.0-macos26-arm64.tar.gz" bs=1 seek=100 conv=notrunc 2>/dev/null +before=$(state "$P" "$B") +run --from "$T/d4" --prefix "$P" --bin-dir "$B" +check "bad checksum fails" eval '[ $status = 1 ] && has "install.sh: sys1rust-0.4.0-macos26-arm64.tar.gz has sha256"' +check "bad checksum changes nothing" eval '[ "$(state "$P" "$B")" = "$before" ]' +check "bad checksum leaves no staging dir" eval '[ -z "$(find "$P" -maxdepth 1 -name ".install.*")" ]' +run --from "$T/d4" --prefix "$T/nested-checksum" --bin-dir "$T/nested-checksum/tools/bin" +check "a bad checksum leaves no nested bin parents" eval '[ $status = 1 ] && has "has sha256" && [ ! -e "$T/nested-checksum" ]' +run --from "$T/d1" --prefix "$T/nested-checksum" --bin-dir "$T/nested-checksum/tools/bin" +check "a nested bin install retries after a bad checksum" eval '[ $status = 0 ] && version_is "$T/nested-checksum/current" 0.1.0-macos26 && "$T/nested-checksum/tools/bin/sys1rust" --version >/dev/null' +mkdir -p "$T/b2" && touch "$T/b2/sys1rust" +run --from "$T/d1" --prefix "$T/p2" --bin-dir "$T/b2" +check "a file at the link path" eval '[ $status = 1 ] && has "install.sh: $T/b2/sys1rust exists and is not a link"' + +# Downloads from a local server laid out like GitHub releases. +W=$T/www +for tag in v0.2.0 v0.3.0-rc1; do + mkdir -p "$W/releases/download/$tag" "$W/releases/tag/$tag" +done +cp "$T/d2"/sys1rust-0.2.0-macos26-arm64.tar.gz "$T/d2/SHA256SUMS" "$W/releases/download/v0.2.0/" +cp "$T/d3"/sys1rust-0.3.0-macos26-arm64.tar.gz "$W/releases/download/v0.3.0-rc1/" +(cd "$W/releases/download/v0.3.0-rc1" && shasum -a 256 sys1rust-*.tar.gz >SHA256SUMS) +mkdir -p "$W/releases/tag/v0.5.0" "$W/releases/download/v0.6.0" +cp "$T/d6"/* "$W/releases/download/v0.6.0/" +printf 'corrupt' | dd of="$W/releases/download/v0.6.0/sys1rust-0.6.0-macos26-arm64.tar.gz" bs=1 seek=100 conv=notrunc 2>/dev/null +echo v0.2.0 >"$W/latest-tag" +cat >"$T/server.py" <<'EOF' +# Serves sys.argv[1] like github.com/: /releases/latest redirects to the tag in +# latest-tag, or to /releases when that file is missing. Writes its port to sys.argv[2]. +import http.server, os, sys +root = sys.argv[1] +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **k): + super().__init__(*a, directory=root, **k) + def do_GET(self): + if self.path == "/releases/latest": + f = os.path.join(root, "latest-tag") + to = "/releases/tag/" + open(f).read().strip() if os.path.exists(f) else "/releases" + self.send_response(302) + self.send_header("Location", "http://127.0.0.1:%d%s" % (self.server.server_port, to)) + self.end_headers() + return + super().do_GET() + def log_message(self, *a): + pass +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +with open(sys.argv[2], "w") as f: + f.write(str(server.server_port)) +server.serve_forever() +EOF +python3 "$T/server.py" "$W" "$T/server-port" >"$T/server.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 600); do + [ -s "$T/server-port" ] && break + kill -0 "$SERVER_PID" 2>/dev/null || break + sleep 0.1 +done +if [ ! -s "$T/server-port" ]; then + echo "The fixture release server did not report its port within 60 s" >&2 + cat "$T/server.log" >&2 + exit 1 +fi +URL=http://127.0.0.1:$(cat "$T/server-port")/releases +run SYS1RUST_RELEASES_URL="$URL" -- --prefix "$T/u" --bin-dir "$T/u/bin" +check "latest release" eval '[ $status = 0 ] && version_is "$T/u/current" 0.2.0-macos26 && has "Downloading $URL/download/v0.2.0/sys1rust-0.2.0-macos26-arm64.tar.gz"' +run SYS1RUST_RELEASES_URL="$URL" -- --prefix "$T/u" --bin-dir "$T/u/bin" +check "rerun on the latest skips the download" eval '[ $status = 0 ] && has "already in" && ! has Downloading' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.3.0-rc1 --prefix "$T/u" --bin-dir "$T/u/bin" +check "--version picks a release candidate" eval '[ $status = 0 ] && version_is "$T/u/current" 0.3.0-rc1-macos26' +run SYS1RUST_RELEASES_URL="$URL" -- --version 0.2.0 --prefix "$T/u" --bin-dir "$T/u/bin" +check "--version without v rolls back to previous" eval '[ $status = 0 ] && version_is "$T/u/current" 0.2.0-macos26 && version_is "$T/u/previous" 0.3.0-rc1-macos26' +run SYS1RUST_RELEASES_URL="$URL" -- --version v9.9.9 --prefix "$T/u" --bin-dir "$T/u/bin" +check "missing tag" eval '[ $status = 1 ] && has "install.sh: there is no release v9.9.9"' +run SYS1RUST_RELEASES_URL="$URL" -- --version v9.9.9 --prefix "$T/u3" --bin-dir "$T/u3/bin" +check "a failed first install leaves no prefix" eval '[ $status = 1 ] && [ ! -e "$T/u3" ]' +run SYS1RUST_RELEASES_URL="$URL" -- --version v9.9.9 --prefix "$T/nested-missing" --bin-dir "$T/nested-missing/tools/bin" +check "a missing release leaves no nested bin parents" eval '[ $status = 1 ] && has "there is no release v9.9.9" && [ ! -e "$T/nested-missing" ]' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.2.0 --prefix "$T/nested-missing" --bin-dir "$T/nested-missing/tools/bin" +check "a nested bin install retries after a missing release" eval '[ $status = 0 ] && version_is "$T/nested-missing/current" 0.2.0-macos26 && "$T/nested-missing/tools/bin/sys1rust" --version >/dev/null' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.5.0 --prefix "$T/u" --bin-dir "$T/u/bin" +check "release without SHA256SUMS" eval '[ $status = 1 ] && has "install.sh: release v0.5.0 has no SHA256SUMS"' +run SYS1RUST_RELEASES_URL="$URL" FAKE_MACOS=15.5 -- --version v0.2.0 --prefix "$T/u" --bin-dir "$T/u/bin" +check "release without this flavor" eval '[ $status = 1 ] && has "install.sh: release v0.2.0 has no sys1rust-0.2.0-macos14-arm64.tar.gz"' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.6.0 --prefix "$T/u" --bin-dir "$T/u/bin" +check "a bad download fails" eval '[ $status = 1 ] && has "install.sh: sys1rust-0.6.0-macos26-arm64.tar.gz has sha256" && version_is "$T/u/current" 0.2.0-macos26' +check "a bad download is deleted" eval '[ -z "$(find "$T/u" -name "*.tar.gz")" ] && [ ! -e "$T/u/0.6.0-macos26" ]' +rm "$W/latest-tag" +run SYS1RUST_RELEASES_URL="$URL" -- --prefix "$T/u2" --bin-dir "$T/u2/bin" +check "no release yet" eval '[ $status = 1 ] && has "install.sh: sys1rust has no published release yet"' + +# The LaunchAgent, with the fake launchctl. +AGENT=$T/home/Library/LaunchAgents/io.github.krishhgg.sys1rust.plist +LOG=$T/home/Library/Logs/sys1rust.log +S=$T/s +mkdir -p "$T/home/Library/Logs" +echo foreign >"$LOG" +run --from "$T/d1" --prefix "$T/foreign-log" --bin-dir "$T/foreign-log-bin" --service +check "service refuses an unrelated existing log" eval '[ $status = 1 ] && has "already exists without an installer LaunchAgent" && [ "$(cat "$LOG")" = foreign ]' +run --uninstall --prefix "$T/foreign-log" --bin-dir "$T/foreign-log-bin" +rm "$LOG" +run HF_HUB_CACHE="$T/hub" -- --from "$T/d1" --prefix "$S" --bin-dir "$T/sb" --service +check "--service" eval '[ $status = 0 ] && has "sys1rust serve answers on http://127.0.0.1:$PORT" && has "with HF_HUB_CACHE=$T/hub"' +check "plist is valid" plutil -lint -s "$AGENT" +check "plist runs the link" eval '[ "$(plutil -extract ProgramArguments.0 raw "$AGENT")" = "$T/sb/sys1rust" ] && [ "$(plutil -extract ProgramArguments.1 raw "$AGENT")" = serve ]' +check "plist passes HF_HUB_CACHE and LAYA_PORT" eval '[ "$(plutil -extract EnvironmentVariables.HF_HUB_CACHE raw "$AGENT")" = "$T/hub" ] && [ "$(plutil -extract EnvironmentVariables.LAYA_PORT raw "$AGENT")" = "$PORT" ]' +check "plist restarts on failure only" eval '[ "$(plutil -extract KeepAlive.SuccessfulExit raw "$AGENT")" = false ] && [ "$(plutil -extract RunAtLoad raw "$AGENT")" = true ]' +check "plist logs" eval '[ "$(plutil -extract StandardErrorPath raw "$AGENT")" = "$LOG" ]' +: >"$T/launchd/calls" +run HF_HUB_CACHE=./cache HF_HOME=./hf XDG_CACHE_HOME=./xdg -- --from "$T/d1" --prefix "$S" --bin-dir "$T/sb" --service +check "relative service caches become absolute" eval '[ $status = 0 ] && [ "$(plutil -extract EnvironmentVariables.HF_HUB_CACHE raw "$AGENT")" = "$PWD/./cache" ] && [ "$(plutil -extract EnvironmentVariables.HF_HOME raw "$AGENT")" = "$PWD/./hf" ] && [ "$(plutil -extract EnvironmentVariables.XDG_CACHE_HOME raw "$AGENT")" = "$PWD/./xdg" ]' +run HF_HUB_CACHE="$T/hub" -- --from "$T/d1" --prefix "$S" --bin-dir "$T/sb" --service +check "--service rerun reloads" eval '[ $status = 0 ] && grep -q "^bootout gui/$(id -u)/io.github.krishhgg.sys1rust$" "$T/launchd/calls" && grep -q "^bootstrap gui/$(id -u) $AGENT$" "$T/launchd/calls"' +: >"$T/launchd/calls" +run LAYA_PORT="$(free_port)" HF_HUB_CACHE="$T/wrong-cache" FAKE_MODELS_ENV="$T/model-cache" -- --from "$T/d2" --prefix "$S" --bin-dir "$T/sb" +check "an update restarts the service" eval '[ $status = 0 ] && grep -q "^kickstart -k gui/$(id -u)/io.github.krishhgg.sys1rust$" "$T/launchd/calls" && has "Restarting the sys1rust service"' +check "service update probes its recorded cache" eval '[ "$(cat "$T/model-cache")" = "$T/hub" ]' +: >"$T/launchd/calls" +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.2.0 --prefix "$S" --bin-dir "$T/sb" +check "a rerun leaves the service alone" eval '[ $status = 0 ] && ! grep -q kickstart "$T/launchd/calls" && has "The sys1rust service runs this version"' + +service_before=$(cat "$T/launchd/pid") +run FAKE_MV_MODE=after-switch -- --from "$T/d3" --prefix "$S" --bin-dir "$T/sb" +check "an interrupted service update keeps a pending restart" eval '[ $status != 0 ] && version_is "$S/current" 0.3.0-macos26 && [ "$(cat "$T/launchd/pid")" = "$service_before" ] && [ -f "$S/.sys1rust-install/service-restart" ]' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.3.0 --prefix "$S" --bin-dir "$T/sb" +check "retry restarts after current already switched" eval '[ $status = 0 ] && [ "$(cat "$T/launchd/pid")" != "$service_before" ] && has "Restarting the sys1rust service" && [ ! -e "$S/.sys1rust-install/service-restart" ]' +touch "$T/launchd/kick-fail-next" +run --from "$T/d2" --prefix "$S" --bin-dir "$T/sb" +check "a failed restart keeps its pending marker" eval '[ $status = 1 ] && has "kickstart could not restart" && [ -f "$S/.sys1rust-install/service-restart" ]' +rm "$T/launchd/kick-fail-next" +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.2.0 --prefix "$S" --bin-dir "$T/sb" +check "retry recovers a failed restart" eval '[ $status = 0 ] && has "Restarting the sys1rust service" && [ ! -e "$S/.sys1rust-install/service-restart" ]' + +# A healthy foreground server must not hide a failed or non-listening LaunchAgent. +for mode in fail-next no-listener-next; do + kill "$(cat "$T/launchd/pid")" + rm "$T/launchd/pid" + python3 -m http.server --bind 127.0.0.1 --directory "$T/launchd/www" "$PORT" >/dev/null 2>&1 & + OTHER=$! + for _ in $(seq 600); do + curl -fs -o /dev/null "http://127.0.0.1:$PORT/health" 2>/dev/null && break + kill -0 "$OTHER" 2>/dev/null || break + sleep 0.1 + done + check "the foreground server answers before $mode" curl -fs -o /dev/null "http://127.0.0.1:$PORT/health" + touch "$T/launchd/$mode" + SECONDS=0 + run --from "$T/d2" --prefix "$S" --bin-dir "$T/sb" + check "a foreground listener cannot hide $mode" eval '[ $status = 1 ] && has "the service exited before it answered /health" && [ $SECONDS -lt 10 ] && [ -f "$S/.sys1rust-install/service-restart" ]' + kill "$OTHER" + wait "$OTHER" 2>/dev/null || true + rm "$T/launchd/$mode" + run SYS1RUST_RELEASES_URL="$URL" -- --version v0.2.0 --prefix "$S" --bin-dir "$T/sb" + check "service recovers after $mode" eval '[ $status = 0 ] && [ -s "$T/launchd/pid" ] && [ ! -e "$S/.sys1rust-install/service-restart" ]' +done +# An install and uninstall in another prefix leave this service alone. +: >"$T/launchd/calls" +run --from "$T/d3" --prefix "$T/o" --bin-dir "$T/ob" +check "another prefix does not restart the service" eval '[ $status = 0 ] && ! grep -q kickstart "$T/launchd/calls" && has "Next, start the server"' +run --uninstall --prefix "$T/o" --bin-dir "$T/ob" +check "another prefix keeps the LaunchAgent" eval '[ $status = 0 ] && has "Left the LaunchAgent $AGENT and its log" && [ -f "$AGENT" ] && [ -f "$T/launchd/loaded" ] && [ ! -e "$T/o" ]' + +# Uninstall. +run HF_HUB_CACHE="$T/hub" -- --from "$T/d2" --prefix "$S" --bin-dir "$T/sb" --service +mkdir -p "$T/hub/models--convaiinnovations--laya-typed-decisions/blobs" +dd if=/dev/zero of="$T/hub/models--convaiinnovations--laya-typed-decisions/blobs/x" bs=1000000 count=3 2>/dev/null +echo log >"$LOG" +run HF_HUB_CACHE="$T/wrong-cache" LAYA_PORT=invalid -- --uninstall --prefix "$S" --bin-dir "$T/sb" +check "--uninstall" eval '[ $status = 0 ] && has "Stopped the sys1rust service" && [ ! -e "$T/launchd/loaded" ]' +check "--uninstall removes everything" eval '[ ! -e "$AGENT" ] && [ ! -e "$S" ] && [ ! -L "$T/sb/sys1rust" ] && [ ! -e "$LOG" ]' +check "--uninstall keeps the models" eval 'has "Kept the Laya models in the model cache $T/hub, 3 MB" && [ -d "$T/hub/models--convaiinnovations--laya-typed-decisions" ]' +check "health server stopped" eval '! curl -fs --max-time 2 "http://127.0.0.1:$PORT/health"' +run --from "$T/d1" --prefix "$T/n" --bin-dir "$T/n/bin" +run --uninstall --prefix "$T/n" --bin-dir "$T/n/bin" +check "--uninstall removes a bin dir inside the prefix" eval '[ $status = 0 ] && has "Removed the registered install from $T/n" && [ ! -e "$T/n" ]' +run --from "$T/d1" --prefix "$P" --bin-dir "$B" +touch "$P/notes.txt" +ln -sf /usr/bin/true "$B/sys1rust" +run --uninstall --prefix "$P" --bin-dir "$B" +check "--uninstall keeps foreign files" eval '[ $status = 0 ] && has "Any unrelated files stay there" && [ -f "$P/notes.txt" ] && [ ! -e "$P/current" ]' +check "--uninstall keeps a foreign link" eval 'has "Left $B/sys1rust, which no longer matches the installer registry" && [ -L "$B/sys1rust" ]' +check "--uninstall default cache line" eval 'has "$T/home/.cache/huggingface/hub holds no Laya models"' + +# A server that exits at once fails --service without waiting out HEALTH_WAIT. +touch "$T/launchd/fail-next" +SECONDS=0 +run --from "$T/d1" --prefix "$T/s2" --bin-dir "$T/sb2" --service +check "a failing service is reported" eval '[ $status = 1 ] && has "install.sh: the service exited before it answered /health" && [ $SECONDS -lt 10 ]' +rm "$T/launchd/fail-next" +run --uninstall --prefix "$T/s2" --bin-dir "$T/sb2" + +run --from "$T/d1" --prefix "$T/replaced-log" --bin-dir "$T/replaced-log-bin" --service +mv "$LOG" "$LOG.original" +echo replacement >"$LOG" +run --uninstall --prefix "$T/replaced-log" --bin-dir "$T/replaced-log-bin" +check "uninstall preserves a replaced log" eval '[ $status = 0 ] && [ "$(cat "$LOG")" = replacement ]' +rm "$LOG" "$LOG.original" + +# Another server on the port stops --service before it loads the LaunchAgent. +OTHER_PORT=$(free_port) +python3 -m http.server --bind 127.0.0.1 --directory "$T/launchd/www" "$OTHER_PORT" >/dev/null 2>&1 & +OTHER=$! +for _ in $(seq 600); do + curl -fs -o /dev/null "http://127.0.0.1:$OTHER_PORT/health" 2>/dev/null && break + kill -0 "$OTHER" 2>/dev/null || break + sleep 0.1 +done +run LAYA_PORT="$OTHER_PORT" -- --from "$T/d1" --prefix "$T/s3" --bin-dir "$T/sb3" --service +kill "$OTHER" +wait "$OTHER" 2>/dev/null || true +check "a busy port stops --service" eval '[ $status = 1 ] && has "install.sh: another server answers on 127.0.0.1:$OTHER_PORT" && [ ! -e "$AGENT" ]' + + +# Wrong prefixes and familiar-looking foreign names never establish ownership. +FOREIGN=$T/foreign +mkdir -p "$FOREIGN/backup-macos14" "$FOREIGN/0.0.1-macos26" +touch "$FOREIGN/current" "$FOREIGN/previous" "$FOREIGN/backup-macos14/keep" +foreign_before=$(state "$FOREIGN") +run --uninstall --prefix "$FOREIGN" --bin-dir "$T/fb" +check "uninstall refuses an unregistered prefix" eval '[ $status = 1 ] && [ "$(state "$FOREIGN")" = "$foreign_before" ]' +run --from "$T/d1" --prefix "$FOREIGN" --bin-dir "$T/fb" +check "install refuses foreign prefix contents" eval '[ $status = 1 ] && has "has no installer registry" && [ "$(state "$FOREIGN")" = "$foreign_before" ]' + +run --from "$T/d1" --prefix "$T/owned" --bin-dir "$T/owned-bin" +mkdir -p "$T/owned/backup-macos14" "$T/owned/0.0.1-macos26" +touch "$T/owned/backup-macos14/keep" +run --from "$T/d2" --prefix "$T/owned" --bin-dir "$T/owned-bin" +run --from "$T/d3" --prefix "$T/owned" --bin-dir "$T/owned-bin" +check "pruning preserves foreign version names" eval '[ $status = 0 ] && [ -f "$T/owned/backup-macos14/keep" ] && [ -d "$T/owned/0.0.1-macos26" ]' +rm "$T/owned/current" "$T/owned/previous" +touch "$T/owned/current" "$T/owned/previous" +run --uninstall --prefix "$T/owned" --bin-dir "$T/owned-bin" +check "uninstall preserves replaced links and foreign names" eval '[ $status = 0 ] && [ -f "$T/owned/current" ] && [ -f "$T/owned/previous" ] && [ -f "$T/owned/backup-macos14/keep" ] && [ -d "$T/owned/0.0.1-macos26" ]' +for target in "$T/flink/other" "$T/flink/../outside/bin/sys1rust"; do + rm -rf "$T/flink" "$T/flink-bin" + run --from "$T/d1" --prefix "$T/flink" --bin-dir "$T/flink-bin" + rm "$T/flink-bin/sys1rust" + ln -s "$target" "$T/flink-bin/sys1rust" + run --from "$T/d2" --prefix "$T/flink" --bin-dir "$T/flink-bin" + check "install refuses a foreign link $target" eval '[ $status = 1 ] && link_is "$T/flink-bin/sys1rust" "$target" && version_is "$T/flink/current" 0.1.0-macos26' + run --uninstall --prefix "$T/flink" --bin-dir "$T/flink-bin" + check "uninstall preserves foreign link $target" link_is "$T/flink-bin/sys1rust" "$target" +done + +# Failure at the directory move leaves the old directory and current usable. +run --from "$T/d1" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +active=$(readlink "$T/atomic/current") +for mode in fail signal; do + run FAKE_MV_MODE="$mode" -- --from "$T/d1" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" + check "a $mode at the directory move keeps current" eval '[ $status != 0 ] && link_is "$T/atomic/current" "$active" && [ -x "$T/atomic/$active/bin/sys1rust" ] && "$T/atomic-bin/sys1rust" --version >/dev/null && [ ! -e "$T/atomic/.lock" ]' +done + +# Hold one install at the move, then try both an update and an uninstall. +env -i HOME="$T/home" PATH="$T/fake:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="$T" \ + FAKE_LAUNCHD="$T/launchd" FAKE_MV_MODE=pause FAKE_MV_READY="$T/ready" FAKE_MV_RESUME="$T/resume" \ + "$SH" "$ROOT/install.sh" --from "$T/d2" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" >"$T/overlap.log" 2>&1 & +INSTALL_PID=$! +for _ in $(seq 600); do + [ -e "$T/ready" ] && break + kill -0 "$INSTALL_PID" 2>/dev/null || break + sleep 0.1 +done +check "the first install holds the lock" test -f "$T/ready" +run --from "$T/d3" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +check "an overlapping update refuses the lock" eval '[ $status = 1 ] && has "another installer is running" && link_is "$T/atomic/current" "$active"' +run --uninstall --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +check "an overlapping uninstall refuses the lock" eval '[ $status = 1 ] && has "another installer is running" && link_is "$T/atomic/current" "$active"' +touch "$T/resume" +status=0 +wait "$INSTALL_PID" || status=$? +INSTALL_PID= +check "the serialized update leaves both versions usable" eval '[ $status = 0 ] && version_is "$T/atomic/current" 0.2.0-macos26 && link_is "$T/atomic/previous" "$active" && "$T/atomic-bin/sys1rust" --version >/dev/null' +mkdir "$T/atomic/.lock" +echo 99999999 >"$T/atomic/.lock/pid" +run --from "$T/d3" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +check "a stale lock has a recovery command" eval '[ $status = 1 ] && has "stale installer lock" && has "rmdir" && version_is "$T/atomic/current" 0.2.0-macos26' +rm "$T/atomic/.lock/pid" +rmdir "$T/atomic/.lock" +run --from "$T/d3" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +check "install resumes after stale lock recovery" eval '[ $status = 0 ] && version_is "$T/atomic/current" 0.3.0-macos26' + +run FAKE_MV_MODE=after-switch -- --from "$T/d2" --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +switched=$(readlink "$T/atomic/current") +check "an interruption after current switches leaves a working binary" eval '[ $status != 0 ] && version_is "$T/atomic/current" 0.2.0-macos26 && "$T/atomic-bin/sys1rust" --version >/dev/null && [ ! -e "$T/atomic/.lock" ]' +run SYS1RUST_RELEASES_URL="$URL" -- --version v0.2.0 --prefix "$T/atomic" --bin-dir "$T/atomic-bin" +check "a rerun recognizes an interrupted link registry update" eval '[ $status = 0 ] && link_is "$T/atomic/current" "$switched"' + +# Two first installs have different prefixes but share the absent default bin link. +rm "$T/ready" "$T/resume" +env -i HOME="$T/home" PATH="$T/fake:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="$T" LAYA_PORT="$PORT" \ + FAKE_LAUNCHD="$T/launchd" FAKE_MV_MODE=pause FAKE_MV_READY="$T/ready" FAKE_MV_RESUME="$T/resume" \ + "$SH" "$ROOT/install.sh" --from "$T/d1" --prefix "$T/shared-a" --service >"$T/overlap.log" 2>&1 & +INSTALL_PID=$! +for _ in $(seq 600); do + [ -e "$T/ready" ] && break + kill -0 "$INSTALL_PID" 2>/dev/null || break + sleep 0.1 +done +check "a first install holds shared locks" test -f "$T/ready" +run --from "$T/d2" --prefix "$T/shared-b" +check "different prefixes cannot race an absent bin link" eval '[ $status = 1 ] && has "another installer is running" && [ ! -e "$T/home/.local/bin/sys1rust" ] && [ ! -e "$T/shared-b" ]' +run --from "$T/d2" --prefix "$T/shared-service" --bin-dir "$T/separate-bin" --service +check "different bin directories cannot race the service" eval '[ $status = 1 ] && has "another installer is running" && [ ! -e "$AGENT" ] && [ ! -e "$T/shared-service" ]' +run HOME="$T/another-home" -- --from "$T/d2" --prefix "$T/shared-home" --bin-dir "$T/home/.local/bin" +check "the bin lock also protects callers with another HOME" eval '[ $status = 1 ] && has "another installer is running" && [ ! -e "$T/home/.local/bin/sys1rust" ]' +touch "$T/resume" +status=0 +wait "$INSTALL_PID" || status=$? +INSTALL_PID= +check "the first shared install finishes without replacement" eval '[ $status = 0 ] && link_is "$T/home/.local/bin/sys1rust" "$T/shared-a/current/bin/sys1rust" && [ "$(plutil -extract Sys1rustInstallPrefix raw "$AGENT")" = "$T/shared-a" ]' +run --from "$T/d2" --prefix "$T/shared-b" +check "a later install refuses the other prefix bin link" eval '[ $status = 1 ] && has "not this installer" && link_is "$T/home/.local/bin/sys1rust" "$T/shared-a/current/bin/sys1rust"' +run --uninstall --prefix "$T/shared-a" +check "shared locks are removed after completion" eval '[ $status = 0 ] && [ ! -e "$T/home/.local/bin/.sys1rust-install.lock" ] && [ ! -e "$T/home/Library/LaunchAgents/.io.github.krishhgg.sys1rust.install.lock" ]' + +# Evaluate the printed PATH assignment in a fresh shell. Directory text stays literal. +WEIRD="$T/bin space' dollar\$ backtick\` quote\" backslash\\" +run --from "$T/d1" --prefix "$T/quoted" --bin-dir "$WEIRD" +path_line=$(printf '%s\n' "$out" | sed -n 's/^ export PATH=/export PATH=/p') +quoted_path=$(env -i PATH=/usr/bin:/bin /bin/sh -c "$path_line"'; printf "%s" "$PATH"') +check "printed PATH preserves shell metacharacters" test "$quoted_path" = "$WEIRD:/usr/bin:/bin" +serve_line=$(printf '%s\n' "$out" | sed -n 's/^Next, start the server: //p') +version_line=${serve_line% serve}' --version' +quoted_version=$(env -i PATH=/usr/bin:/bin /bin/sh -c "$version_line") +check "printed binary command preserves shell metacharacters" test "$quoted_version" = 'sys1rust 0.1.0 (MLX 0.32.2, macos26 build)' + +echo "$SH: $passed passed, $failed failed" +[ "$failed" = 0 ] diff --git a/runtime/crates/sys1rust/src/download.rs b/runtime/crates/sys1rust/src/download.rs index bc33642..898d1db 100644 --- a/runtime/crates/sys1rust/src/download.rs +++ b/runtime/crates/sys1rust/src/download.rs @@ -447,7 +447,7 @@ fn hex(bytes: &[u8]) -> String { } /// Download progress. On a terminal, `Progress` rewrites one line per file in place at most -/// every 200 ms. Elsewhere, such as the `brew services` log, it prints a line at every 10%. +/// every 200 ms. Elsewhere, such as the `install.sh --service` log, it prints a line at every 10%. pub struct Progress { out: W, tty: bool,