Fix terminal on Windows, pin cross images, add per-platform CI #2

open colechristensen cole.christensen@gmail.com wants to merge fix/windows-pty-and-build-refresh into main

Repo refresh: verify the build on Windows and Linux, add a CLAUDE.md, fix what that turned up, and add per-platform CI. Several of these are real user-facing bugs, not housekeeping.

Huorn was broken on Windows

PlainShellSession::read treated a zero-byte read as EOF. That’s correct on Unix — a PTY read of 0 means the child is gone — but on Windows the conout reader is alacritty’s UnblockedReader, whose read returns Ok(0) both for “no output yet” and for “pipe closed”.

So every terminal died on its first poll_pty(), before the shell emitted a byte. The block went blank the instant you activated it. Liveness now comes from the ConPTY child watcher, and read_pty_output asks the session whether the child is gone rather than inferring it from a byte count — which is what the TerminalSession contract already said (Ok(0) means “nothing available right now”).

No behaviour change on Unix: a zero-byte read still sets alive = false there, so the same branch is taken.

Cross-compilation was quietly raising the Linux glibc floor

Cross.toml pinned both Linux images to the floating :main tag. Rebuilding today against it took the natives from glibc 2.30 to 2.39 — silently dropping every player on anything older than Ubuntu 24.04. Pinned to 0.2.5, which lands at glibc 2.18, and rebuilt both Linux natives on that baseline. Strictly better than what was committed.

runClient couldn’t be used to play

--server localhost was hardcoded on both loaders, so runClient went straight into a connection attempt instead of the title screen — there was no way to reach singleplayer. Split into a separate runClientJoinServer, which run_server_client.sh now calls.

That same inherited argument had also broken runVisualTest, which drives the game from the title screen and so never returned. Two more things kept it from running: a fresh run directory opens on the accessibility onboarding screen rather than the title screen, and its screenshot regions were swapped (the camera faces south, so +X renders to the left).

CI — the repo had never run any

.anvil-ci.yml was inert on two counts: the server reads .anvil.yml, and the file was written in GitHub Actions’ schema rather than Anvil’s. anvil ci list returned nothing — the release pipeline JOURNAL.md describes has never executed once. Replaced rather than repaired.

Four verification steps — linux-amd64, linux-arm64, macos-arm64, windows-amd64 — each running the same three checks via ci/verify.sh (or ci/verify.ps1, since Anvil runs bare Windows steps through PowerShell):

  1. cargo test — the Rust suite, including per-platform PTY behaviour
  2. gradlew build — compiles and remaps both loaders
  3. runGametest — boots a dedicated server and binds that platform’s committed native over JNI

The third is why these run per platform rather than once on Linux. A stale or broken native passes the other two, and only a real server boot catches it — which is how the post-rename native with the wrong JNI class path got through. It’s headless and takes ~40s.

No step declares an image:, so all run bare on the runner host; containerising would verify Linux three times. ci/check-natives.sh adds the guard neither build performs: every platform has a native, and each carries the current JNI class path.

Writing this found three things no amount of reading would have, each from an actual run:

  • The Windows runner’s JAVA_HOME was JDK 25, which Loom 1.6 cannot use.
  • cargo was absent from the runner service’s PATH (rustup installs under ~/.cargo/bin, which only login shells pick up).
  • The Linux runner’s $HOME is read-only, so the Gradle wrapper couldn’t unpack its distribution.

Rather than require every self-hosted runner to be configured just so, the scripts now discover a usable JDK 17/21 and cargo, and fall back to a workspace-local GRADLE_USER_HOME when the default isn’t writable. Deriving JAVA_HOME needs care — java on PATH is usually a symlink on Unix and Oracle’s javapath shim on Windows, and the naive parent-of-parent isn’t a JDK home — so symlinks are resolved and a candidate is only accepted if it contains a release file.

The release step no longer rebuilds natives. They’re committed artifacts already verified by check-natives and the gametests; rebuilding at release time would ship binaries nothing tested. It’s main-only via ANVIL_BRANCH.

Smaller things

  • Jar naming. archives_base_name was declared but never wired up, so artifacts shipped as fabric-<version>.jar while INSTALL.md told players to look for huorn-fabric-<version>.jar. Wired up; CI release attachments updated.
  • build_natives.sh only ran on macOS. It invoked aarch64-apple-darwin unconditionally, so on a Linux runner it died at the first target under set -e. Now host-aware, and prints what it skipped.
  • Rust integration tests were Unix-only, and partly vacuous: they terminated commands with \n, which ConPTY echoes without executing, and the assertions only grepped for the echoed text. They now use a shared tests/common helper that sends \r.
  • A flaky test. test_terminal_send_key slept 100ms then sent Ctrl+C and asserted the terminal was alive. On Windows a Ctrl+C landing mid-startup kills PowerShell outright, so it passed or failed depending on machine load. It now waits for a readiness marker.
  • CLAUDE.md, README refresh, .gitattributes, and untracking a stale build artifact carrying a pre-rename path.

Verification

Windows Linux macOS
cargo test (103) not run
./gradlew build ✅ JDK 17 ✅ JDK 21 not run
runGametest (50) not run
build_natives.sh ✅ via cross not run
In-game ✅ visual test 2/2 + a ~19 min play session not run
In CI queued (runner contention) ✅ passed, 343s ✗ no runner

⚠️ Needs verification on macOS before merge

The macOS natives here are unchanged — I had no Mac to build them on. That’s deliberate and safe (the Rust change is a no-op on Unix, so they’re functionally current), but:

  1. Nothing in this PR has been compiled or run on macOS. At minimum please run ci/verify.sh, which is exactly what CI runs.
  2. build_natives.sh was rewritten and its macOS branch is the one path never executed. It should build both macOS targets natively and both Linux targets via cross. Highest-risk untested code here.
  3. Rebuild the macOS natives on a Mac before the next release so they match current source.

verify-macos-arm64 is intentionally left in the pipeline even though no macOS runner is registered. It will stay queued — so runs won’t show a final green — which keeps the gap visible rather than forgotten, and it starts working the moment a runner appears. Don’t delete it for a checkmark.

Also note: no ANVIL_TOKEN CI secret is set on this repo, so releases will fail loudly on main until one is added.

I deliberately skipped cargo update: bumping the lockfile would leave the macOS natives out of sync with dependencies I can’t rebuild them against.

🤖 Generated with Claude Code

Created Aug 07, 2026 at 03:43 UTC