From 36324fcf2b1482a26c4a34befffca260c8beafb2 Mon Sep 17 00:00:00 2001 From: srsholmes Date: Tue, 14 Jul 2026 20:36:18 +0100 Subject: [PATCH 1/3] fix(overlay): preload libstdc++ so webkit2gtk's JSC can't hijack std::call_once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arch's webkit2gtk 2.52.5-1 regressed to exporting libstdc++'s private std::call_once machinery (__once_proxy, __once_callable, __once_call) from libjavascriptcoregtk; 2.52.3 imported them correctly. Electrobun's libNativeWrapper.so links webkit2gtk even in CEF mode, so JSC lands in the overlay process ahead of libstdc++ and wins interposition for libstdc++'s own internal references. That splits std::call_once's TLS state in two: mesa's libLLVM (deepbind scope) writes the callback into libstdc++'s __once_callable, then libstdc++'s __once_proxy reads JSC's copy — NULL — and the process jumps to address 0x0. Trigger: radeonsi's LLVM shader-compiler init during CEF GL bring-up, ~200ms after start, on AMD hardware. zink escaped it only because ACO never touches LLVM. Fix: wrap the bun binary in a shim that prepends libstdc++.so.6 to LD_PRELOAD, restoring the real libstdc++ to the front of the lookup scope so every object binds the same copy. Injected post-build from inject-patched-wrapper.sh (runs from every build entry point). Idempotent; bare soname so the loader picks the right per-distro path; harmless on unaffected systems. The shim wraps bun itself because the electrobun launcher REPLACES LD_PRELOAD for its child, so unit-level env can never reach the process that matters. patchelf --remove-needed on the wrapper does not work as an alternative: Zig emits eager GLOB_DAT relocations for address-taken webkit symbols, so the load fails on undefined symbols. The real fix — rebuilding the wrapper without the webkit link in CEF mode — belongs upstream in electrobun. Verified on CachyOS / Radeon 890M (the machine that reproduces the crash 100%): with the shim the overlay service comes up on native radeonsi with NRestarts=0 and the #211 zink fallback never arms; the identical build without the shim segfaults on first start. Co-Authored-By: Claude Opus 4.8 --- scripts/inject-patched-wrapper.sh | 63 ++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/scripts/inject-patched-wrapper.sh b/scripts/inject-patched-wrapper.sh index f4954fa5..d351bacd 100755 --- a/scripts/inject-patched-wrapper.sh +++ b/scripts/inject-patched-wrapper.sh @@ -1,12 +1,15 @@ #!/bin/sh -# Inject the patched libNativeWrapper.so over the stock one that -# `electrobun build` downloads. The patched wrapper fixes a 100%-CPU busy-loop -# in CEF's browser process — see apps/loadout-overlay/vendor/README.md for the -# root cause and how the binary is produced. +# Post-build fixes injected over the artifacts `electrobun build` downloads. # -# This is the single source of truth for the swap, called from every build +# 1. Patched libNativeWrapper.so — fixes a 100%-CPU busy-loop in CEF's +# browser process; see apps/loadout-overlay/vendor/README.md for the +# root cause and how the binary is produced. +# 2. libstdc++ preload shim around the bun binary — see the comment at +# that step below. +# +# This is the single source of truth for both, called from every build # entry point (scripts/build.sh and apps/loadout-overlay's package.json build -# scripts) so no path can silently ship the stock (spinning) wrapper. Run it +# scripts) so no path can silently ship the stock artifacts. Run it # AFTER `electrobun build`. POSIX-sh, guarded, and idempotent: a missing vendor # file just warns and leaves the stock wrapper in place. # @@ -22,6 +25,54 @@ else ELECTROBUN_DIR="$SCRIPT_DIR/../apps/loadout-overlay" fi +# --- libstdc++ preload shim ------------------------------------------------- +# libNativeWrapper.so links webkit2gtk even in CEF mode, which pulls +# libjavascriptcoregtk into the overlay process ahead of libstdc++. Arch's +# webkit2gtk 2.52.5-1 regressed to EXPORTING libstdc++'s std::call_once +# internals (__once_proxy / __once_callable / __once_call) from JSC, so +# libstdc++'s own __once_proxy reads JSC's TLS slot while a deepbind-scoped +# writer (mesa's libLLVM, during radeonsi shader-compiler init) writes +# libstdc++'s — the callback reads back NULL and the process jumps to 0x0. +# Symptom: the overlay segfaults ~200ms in during CEF GL init on AMD. +# +# Preloading the real libstdc++ puts it first in the global lookup scope, so +# every object binds the once-machinery to the same copy and the split brain +# is gone. Bare soname so the loader resolves the right path per distro +# (/usr/lib on Arch, /usr/lib64 on Fedora/Bazzite). Harmless where the bug +# is absent: libstdc++ is in the process either way. +# +# The launcher REPLACES LD_PRELOAD for its bun child (upstream electrobun +# issue), so a unit-level Environment= can't reach the process that matters — +# hence this shim wrapping the bun binary itself. The real fix is rebuilding +# the wrapper without the webkit link; patchelf --remove-needed does NOT work +# (Zig emits eager GLOB_DAT relocs for address-taken webkit symbols). +# This step runs before the wrapper guard below so it applies even when the +# vendored wrapper is absent. +shimmed=0 +for bun in $(find "$ELECTROBUN_DIR/build" -type f -name bun -path '*/bin/*' 2>/dev/null); do + # Idempotent: skip if this bun is already our shim script. + if head -c2 "$bun" 2>/dev/null | grep -q '#!'; then + continue + fi + mv "$bun" "$bun.real" + cat > "$bun" <<'SHIM' +#!/bin/sh +# libstdc++ must win symbol interposition over webkit2gtk's JSC, which +# (as of Arch webkit2gtk 2.52.5-1) leaks libstdc++'s std::call_once +# internals and splits its TLS state — crashing mesa's LLVM init on +# radeonsi. Preloading the real libstdc++ restores consistent bindings. +# See scripts/inject-patched-wrapper.sh for the full story. +export LD_PRELOAD="libstdc++.so.6${LD_PRELOAD:+:$LD_PRELOAD}" +exec "$(dirname "$0")/bun.real" "$@" +SHIM + chmod +x "$bun" + shimmed=$((shimmed + 1)) +done + +if [ "$shimmed" -gt 0 ]; then + echo "[inject-wrapper] installed libstdc++ preload shim over $shimmed bun binary(ies) (webkit2gtk JSC __once_proxy interposition fix)." +fi + PATCHED="$ELECTROBUN_DIR/vendor/libNativeWrapper.so" if [ ! -f "$PATCHED" ]; then echo "[inject-wrapper] WARNING: no vendored wrapper at $PATCHED — using stock wrapper (overlay will spin at 100% CPU)." >&2 From f7a5249bac28c0288d9b78aa03023bf9ed76f23c Mon Sep 17 00:00:00 2001 From: srsholmes Date: Tue, 14 Jul 2026 20:46:40 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat(scripts):=20install-pr.sh=20=E2=80=94?= =?UTF-8?q?=20build=20+=20install=20any=20PR=20for=20pre-merge=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command takes a tester from an open PR to a running install: sh scripts/install-pr.sh 214 Fetches GitHub's refs/pull/N/head (works for fork PRs without knowing the contributor's branch), shallow-clones into ~/.cache/loadout-pr-test/ so an existing checkout is never touched, then runs the standard bun install --frozen-lockfile + build-and-install. Accepts a branch name as well as a PR number; re-running re-fetches the PR's current head. Reverting is the README install one-liner. Deliberately not under ~/.cache/loadout: the backend runs as root and owns that directory, so a user-run script cannot create anything there (found the hard way — first test run failed on mkdir). Verified end-to-end with PR #214 itself on CachyOS: fetched 36324fc, built (inject step shimmed the bun binary as designed), installed; overlay active on native radeonsi with zero panics. Co-Authored-By: Claude Opus 4.8 --- scripts/install-pr.sh | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100755 scripts/install-pr.sh diff --git a/scripts/install-pr.sh b/scripts/install-pr.sh new file mode 100755 index 00000000..c69b93e2 --- /dev/null +++ b/scripts/install-pr.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# Build and install Loadout from a pull request, for testing a fix before +# it merges. Fetches the PR head via GitHub's refs/pull/N/head (works for +# same-repo and fork PRs alike), builds from source, and installs exactly +# like `bun run build-and-install`. +# +# Usage: +# sh scripts/install-pr.sh # e.g. sh scripts/install-pr.sh 214 +# sh scripts/install-pr.sh # e.g. sh scripts/install-pr.sh fix/my-branch +# +# Environment: +# LOADOUT_REPO git URL to fetch from (default: git@github.com:srsholmes/loadout.git; +# use an https URL + your token if you don't have SSH access) +# +# The build happens in a fresh shallow clone under ~/.cache/loadout-pr-test/, +# so an existing checkout (and any local changes in it) is never touched. +# (Deliberately NOT inside ~/.cache/loadout — the backend runs as root and +# owns that directory, so a user-run script can't create anything there.) +# Reverting to the official build afterwards is one command — the README's +# install one-liner reinstalls the current release over this. +# +# Requires: git, bun (https://bun.sh), and the build deps a normal +# `bun run build-and-install` needs. Prompts for sudo once, at install time, +# same as the regular installer. +set -eu + +REPO="${LOADOUT_REPO:-git@github.com:srsholmes/loadout.git}" +CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/loadout-pr-test" + +if [ -t 1 ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; NC='\033[0m' +else + RED=''; GREEN=''; BLUE=''; NC='' +fi +info() { printf "${BLUE}[INFO]${NC} %s\n" "$1"; } +ok() { printf "${GREEN}[OK]${NC} %s\n" "$1"; } +fail() { printf "${RED}[ERROR]${NC} %s\n" "$1" >&2; exit 1; } + +REF_ARG="${1:-}" +[ -n "$REF_ARG" ] || fail "usage: install-pr.sh " + +# A numeric argument is a PR number; anything else is a branch name. +# refs/pull/N/head is GitHub's read-only ref for a PR's current head — it +# exists for every open PR, including ones from forks, without needing to +# know the contributor's branch name. +case "$REF_ARG" in + *[!0-9]*) REF="refs/heads/$REF_ARG"; NAME="$REF_ARG" ;; + *) REF="refs/pull/$REF_ARG/head"; NAME="pr-$REF_ARG" ;; +esac + +command -v git >/dev/null 2>&1 || fail "git is required" + +# bun might not be on PATH in a fresh shell even when installed (its +# installer puts it in ~/.bun/bin and only edits interactive-shell rc files). +if ! command -v bun >/dev/null 2>&1; then + if [ -x "$HOME/.bun/bin/bun" ]; then + PATH="$HOME/.bun/bin:$PATH" + export PATH + else + fail "bun is required to build from source — install it first: curl -fsSL https://bun.sh/install | bash" + fi +fi + +WORKDIR="$CACHE_ROOT/$(printf '%s' "$NAME" | tr '/' '-')" +info "Fetching $REF into $WORKDIR..." +mkdir -p "$WORKDIR" +cd "$WORKDIR" +if [ ! -d .git ]; then + git init -q + git remote add origin "$REPO" +fi +# --depth 1 keeps the clone small; re-running the script re-fetches the +# PR's current head, so an updated PR just needs the same command again. +git fetch --depth 1 origin "$REF" || fail "could not fetch $REF from $REPO — check the PR number / branch name and your access to the repo" +git checkout -q --detach FETCH_HEAD +ok "Checked out $(git rev-parse --short HEAD) ($REF)" + +info "Installing dependencies..." +bun install --frozen-lockfile + +info "Building + installing (this replaces your current Loadout install; sudo will prompt once)..." +bun run build-and-install + +echo "" +ok "Installed Loadout from $NAME ($(git rev-parse --short HEAD))." +info "Verify: systemctl --user is-active loadout-overlay && journalctl --user -u loadout-overlay --since '-2min' | grep -c panic" +info "Revert to the official release at any time with the README install one-liner." From 73eafa0f41d7f528b05dddf9159fac2f45083920 Mon Sep 17 00:00:00 2001 From: Simon Holmes Date: Wed, 15 Jul 2026 11:36:20 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(build):=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?space-safe=20find=20loops,=20https=20default=20for=20install-pr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inject-patched-wrapper.sh: iterate find output line-by-line in both loops instead of word-splitting; the CEF tree already contains space-laden names and a space in any parent dir would have silently broken the loop. Verified against a 'dir with space' build tree, including idempotent rerun and empty-tree cases. - install-pr.sh: default to the public https remote (no SSH keys needed by testers); keep origin in sync with LOADOUT_REPO when the cached workdir is reused. Co-Authored-By: Claude Fable 5 --- scripts/inject-patched-wrapper.sh | 18 +++++++++++++----- scripts/install-pr.sh | 11 ++++++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/scripts/inject-patched-wrapper.sh b/scripts/inject-patched-wrapper.sh index d351bacd..c1f3c754 100755 --- a/scripts/inject-patched-wrapper.sh +++ b/scripts/inject-patched-wrapper.sh @@ -48,8 +48,11 @@ fi # (Zig emits eager GLOB_DAT relocs for address-taken webkit symbols). # This step runs before the wrapper guard below so it applies even when the # vendored wrapper is absent. +# The CEF tree contains space-laden names ("bun Helper (Renderer)", …), so +# iterate the find output line-by-line rather than word-splitting it. shimmed=0 -for bun in $(find "$ELECTROBUN_DIR/build" -type f -name bun -path '*/bin/*' 2>/dev/null); do +while IFS= read -r bun; do + [ -n "$bun" ] || continue # Idempotent: skip if this bun is already our shim script. if head -c2 "$bun" 2>/dev/null | grep -q '#!'; then continue @@ -67,7 +70,9 @@ exec "$(dirname "$0")/bun.real" "$@" SHIM chmod +x "$bun" shimmed=$((shimmed + 1)) -done +done </dev/null) +EOF if [ "$shimmed" -gt 0 ]; then echo "[inject-wrapper] installed libstdc++ preload shim over $shimmed bun binary(ies) (webkit2gtk JSC __once_proxy interposition fix)." @@ -82,12 +87,15 @@ fi # The build emits the wrapper under build//loadout-overlay-*/bin/. # Replace every libNativeWrapper*.so the build produced (the runtime dlopen's # libNativeWrapper.so; the _cef.so variant, if present, is harmless to match). -# Build paths contain no spaces, so an unquoted find expansion is safe here. +# Line-by-line for the same space-safety reason as the shim loop above. injected=0 -for so in $(find "$ELECTROBUN_DIR/build" -type f -name 'libNativeWrapper*.so' 2>/dev/null); do +while IFS= read -r so; do + [ -n "$so" ] || continue cp -f "$PATCHED" "$so" injected=$((injected + 1)) -done +done </dev/null) +EOF if [ "$injected" -gt 0 ]; then echo "[inject-wrapper] injected patched libNativeWrapper.so into $injected build artifact(s) (CEF CPU-spin fix)." diff --git a/scripts/install-pr.sh b/scripts/install-pr.sh index c69b93e2..8314d580 100755 --- a/scripts/install-pr.sh +++ b/scripts/install-pr.sh @@ -9,8 +9,9 @@ # sh scripts/install-pr.sh # e.g. sh scripts/install-pr.sh fix/my-branch # # Environment: -# LOADOUT_REPO git URL to fetch from (default: git@github.com:srsholmes/loadout.git; -# use an https URL + your token if you don't have SSH access) +# LOADOUT_REPO git URL to fetch from (default: the public https URL, which +# needs no SSH keys; set to git@github.com:srsholmes/loadout.git +# or an https URL with a token if you need authenticated access) # # The build happens in a fresh shallow clone under ~/.cache/loadout-pr-test/, # so an existing checkout (and any local changes in it) is never touched. @@ -24,7 +25,7 @@ # same as the regular installer. set -eu -REPO="${LOADOUT_REPO:-git@github.com:srsholmes/loadout.git}" +REPO="${LOADOUT_REPO:-https://github.com/srsholmes/loadout.git}" CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/loadout-pr-test" if [ -t 1 ]; then @@ -68,6 +69,10 @@ cd "$WORKDIR" if [ ! -d .git ]; then git init -q git remote add origin "$REPO" +else + # A cached workdir may carry an origin from an earlier run with a + # different LOADOUT_REPO — keep it in sync with this run's choice. + git remote set-url origin "$REPO" fi # --depth 1 keeps the clone small; re-running the script re-fetches the # PR's current head, so an updated PR just needs the same command again.