Skip to content

fix(relay-deploy): arm64 published binary cannot load, and the deploy runs twice - #1813

Merged
bobleer merged 4 commits into
GCWing:mainfrom
bobleer:bob/relay-deploy-glibc-floor
Jul 28, 2026
Merged

fix(relay-deploy): arm64 published binary cannot load, and the deploy runs twice#1813
bobleer merged 4 commits into
GCWing:mainfrom
bobleer:bob/relay-deploy-glibc-floor

Conversation

@bobleer

@bobleer bobleer commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #1801, which fixed the CRLF and Docker-config half. With those in place a real arm64 host got further and exposed two more defects: the published binary still could not start, and the deploy visibly ran twice.

Verified end to end on Ubuntu 24.04 arm64 hardware, not just reasoned about.

1. The arm64 published binary could never load

The release matrix builds x86_64 on ubuntu-22.04 but arm64 on ubuntu-24.04-arm. Measured against the real published 0.2.14 artifacts:

requires debian:bookworm-slim provides
x86_64 relay GLIBC_2.35 2.36 works
arm64 relay GLIBC_2.38 2.36 cannot load

Reproduced on the reporter's own machine, against the image their failed run had left behind:

$ docker run --rm --entrypoint /app/bitfun-relay-server bitfun-relay:release-v0.2.14
/app/bitfun-relay-server: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found
exit=1

The container exited instantly, so the deploy surfaced only as a failed health check followed by a ~20 minute source rebuild. x86_64 was unaffected, which is why it went unnoticed.

Fixed on both sides, because they have different blast radii:

  • Deploy — runtime base is now debian:trixie-slim (glibc 2.41). This is the only fix that helps clients already in the wild.
  • CI — arm64 now builds on ubuntu-22.04-arm, giving both arches one 2.35 floor. This is the only fix that helps anyone following the README's "Manual Run (without Docker)" path, who never touches Docker and is broken today.

debian:trixie-slim must stay even though CI now emits 2.35 binaries: Desktop pins BITFUN_RELEASE_TAG to its own version, so a v0.2.14 client installs the 2.38 archive forever. The base has to cover the highest floor still in the wild, not the floor CI produces from now on. Both files say so, to stop it being "optimised" back.

Two new guards, since nothing previously declared this contract:

  • scripts/ci/check-glibc-floor.sh reads .gnu.version_r via readelf -V and fails the build above the declared floor. Verified against the real 0.2.14 archives: x86_64 passes at 2.35, arm64 fails at 2.35 reporting 2.38, arm64 passes at 2.38, and a mixed run reports each binary before failing.
  • The runtime image greps ldd output and fails to build on an unloadable binary. Note ldd exits 0 while reporting an unsatisfied symbol version, so its output is the gate, not its status — my first attempt used RUN ldd and would never have fired. The relay binary is also useless as a probe: it has no --version and just starts serving.

2. The failure was invisible

docker logs --tail 40 bitfun-relay 2>/dev/null discarded the container's stderr — where both the relay's tracing output and the loader error above go. That is why the report showed a failed health check with no explanation at all. Both streams are kept now, plus container running/exit/OOM state so a crash is distinguishable from a container that is up but not answering.

3. The deploy ran twice

bitfun_run_deploy_sh is reached only after bitfun_try_release_deploy has failed, but it invoked deploy.sh with no arguments — and deploy.sh's own first step is that same release-binary path. It re-downloaded, rebuilt and restarted the binary that had just failed. It now passes --build-from-source, the flag that already existed for exactly this.

4. The CR strip no longer depends on a raw CR byte

#1801 shipped sed 's/<CR>$//' with a literal CR in the command string — which then travelled through to_unix_script on its way to the host, the CR remover passing through the CR remover. It worked only because the CR happened to be followed by $ rather than a newline. tr -d '\r' expands the escape itself, so the command is pure ASCII and cannot be damaged in transit. The rewrite also cleans up its scratch file when it fails.

Verified on real arm64 hardware

Generated scripts uploaded deliberately CRLF-corrupted (harsher than the original report: it dies at line 2 on set -euo pipefail\r, not line 37):

check result
host-side CR strip 3879 CRs → 0, mode 700, no scratch file, both scripts parse
binary deploy succeeds in ~15s, no source-build fallback
runtime image glibc 2.41, ldd gate passes
container Up (healthy) — docker's own HEALTHCHECK passes
/health {"status":"healthy","account_features":true,...}
relay-admin No accounts found.
compose labels / volumes / restart preserved
re-deploy over a running relay container replaced, backup renamed and cleaned up, new one healthy
--build-from-source reaches deploy.sh in direct and sg modes

Forcing the fixed Dockerfile back to bookworm-slim fails the build with the exact loader error, confirming the gate fires.

An honest correction

#1801 also quoted arguments for sg docker -c. I implied that was related to the unknown shorthand flag: 't' in -t failure. Tested against the real sg binary, it was not — the old unquoted form handles that particular command fine. It does genuinely break on multi-word arguments:

old: docker inspect -f {{.Name}} {{.State.Status}} ...  -> error: no such object: {{.State.Status}}
new: docker inspect -f '{{.Name}} {{.State.Status}}' ... -> /bitfun-relay running

So it is worth having — the new container-state diagnostic in this PR uses exactly such a template and would break in sg mode without it — but it was not the cause of the reported bug. The root cause there was the root-owned config.json, already fixed in #1801 and confirmed by the reporter's follow-up run.

Reviewer note

ubuntu-22.04-arm is documented as available for public repositories, but the matrix change has not been executed — this workflow only runs via workflow_call from a release. If the label were unavailable the job would fail loudly rather than silently, and the fallback is to keep ubuntu-24.04-arm and build inside a debian:bookworm container instead.

bobleer added 4 commits July 27, 2026 18:18
…top deploying twice

Reported symptom: the published-binary deploy built its image, started the
container, failed the health check, and then ran the entire published-binary
attempt a second time before reaching the source build.

## arm64 relay could never load in the runtime image

The release matrix builds x86_64 on ubuntu-22.04 but arm64 on ubuntu-24.04-arm.
Verified against the actual published 0.2.14 artifacts:

  x86_64 relay  requires up to GLIBC_2.35
  aarch64 relay requires    GLIBC_2.38
  debian:bookworm-slim ships          2.36

So x86_64 worked and arm64 could not start at all, which is why this went
unnoticed. Reproduced on an aarch64 daemon with the real binary:

  /app/bitfun-relay-server: /lib/aarch64-linux-gnu/libc.so.6:
      version `GLIBC_2.38' not found (required by /app/bitfun-relay-server)

Base image is now debian:trixie-slim (glibc 2.41), verified to load and serve
/health on the first probe with the real arm64 binary.

The image build now greps `ldd` output and fails on "not found", so a future
runner bump surfaces in seconds instead of as an opaque health-check timeout.
Note `ldd` exits 0 even while reporting an unsatisfied symbol version, so its
output is the gate rather than its status; the relay binary itself cannot serve
as the probe because it has no --version flag and simply starts serving.

## The failure was invisible

`docker logs --tail 40 bitfun-relay 2>/dev/null` discarded the container's
stderr — which is where the relay's tracing output, and the loader error above,
both go. That is why the log showed a failed health check with no explanation.
Keep both streams and also report container running/exit/OOM state, so a crash
is distinguishable from a container that is up but not answering.

## The deploy ran twice

`bitfun_run_deploy_sh` is reached only after `bitfun_try_release_deploy` failed,
but invoked `deploy.sh` with no arguments — and `deploy.sh`'s own first step is
that same release-binary path. It re-downloaded, rebuilt and restarted the
binary that had just failed. Pass `--build-from-source`, the flag that already
existed for exactly this.

Tests: 31 pass. Adds coverage for the runtime base and its ldd gate, the
preserved diagnostics, and one --build-from-source per docker mode.
Completes the arm64 fix from the other side. The release matrix built x86_64 on
ubuntu-22.04 (glibc 2.35) but arm64 on ubuntu-24.04-arm (2.39), so the arm64
relay and CLI required GLIBC_2.38 — unable to run on Debian 12 / Ubuntu 22.04
arm64 at all. Changing the relay's runtime image fixed the Docker deploy, but
not anyone following the README's "Manual Run (without Docker)" path, who never
touches Docker.

Pin arm64 to ubuntu-22.04-arm so both arches share a 2.35 floor, and assert it
with scripts/ci/check-glibc-floor.sh over every published binary
(bitfun-relay-server, relay-admin, bitfun, bitfun-cli). Nothing previously
declared the supported floor, which is why a runner image difference silently
became a user-visible breakage.

The checker reads `.gnu.version_r` via `readelf -V` — the authoritative list of
versioned symbols a binary needs. Verified against the real published 0.2.14
archives: x86_64 passes at 2.35, arm64 fails at 2.35 reporting 2.38, arm64
passes at 2.38, and a mixed run reports each binary before failing.

The relay runtime base deliberately stays on trixie-slim. Desktop pins
BITFUN_RELEASE_TAG to its own version, so a v0.2.14 client installs the v0.2.14
archive forever and published archives keep the floor they were built with — the
base must cover the highest floor still in the wild (2.38), not the floor CI
produces from now on. Both files now say so, so neither gets "optimized" back.
…R byte

`stage_scripts_command` used `sed 's/<CR>$//'` with a literal CR in the command
string. That CR then travelled through `to_unix_script` on its way to the host —
the CR remover passing through the CR remover — and any text-mode hop that
rewrote line endings would silently degrade the strip to a no-op. Found while
testing on a real arm64 host: a Python text-mode read in the test harness turned
the CR into LF, and the command reached the host as an unterminated `sed`
expression.

`tr -d '\r'` expands the escape itself, so the command is pure ASCII and cannot
be damaged in transit. Removing every CR rather than only trailing ones is safe
for generated bash, which never contains an intentional CR
(`embedded_scripts_are_lf_only` enforces that). The rewrite now also removes its
scratch file when it fails, instead of leaving a partial `.lf` behind.

Verified end to end on Ubuntu 24.04 arm64: scripts uploaded with all 3879 lines
CRLF-corrupted came out with 0 CRs, mode 700, no scratch file, and both parsed.
Review of the new checker found two ways it could report success without
actually checking anything.

`grep` exits non-zero when a binary has no versioned glibc symbols at all, and
under `set -euo pipefail` that aborted the whole run at the command
substitution — before the branch that handles the empty case. Verified: the
pre-fix script exits 1 with no output on a static binary; it now reports
"no versioned glibc requirement" and continues.

Worse, without `readelf` every binary looked requirement-free, so the check
passed everything. Verified in debian:trixie-slim, which ships no binutils: the
script now refuses with exit 2 instead of green-lighting the release.
@bobleer
bobleer merged commit 196170a into GCWing:main Jul 28, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant