fix(overlay): fall back to zink GL after a failed start, instead of guessing#211
fix(overlay): fall back to zink GL after a failed start, instead of guessing#211srsholmes wants to merge 2 commits into
Conversation
…uessing The bundled CEF segfaults during GL init on AMD radeonsi, inside startEventLoop() before any of our code runs. The overlay then crash-loops under Restart=on-failure and never opens. Bun reports "Segmentation fault at address 0x0"; the delivered signal is SIGILL from Bun's panic handler. Reproduced on CachyOS desktop (Radeon 890M) and reported by a user on CachyOS desktop (Radeon 8060S / Ryzen AI MAX+ 395). Rather than predict which machines are affected, try the normal driver and let a failed start select the fallback: ExecStopPost leaves a marker when the service exits for any reason other than a clean stop, and Restart=on-failure comes back and retries with zink (GL over Vulkan/RADV, still hardware accelerated -- unlike LIBGL_ALWAYS_SOFTWARE=1, which drops to llvmpipe and costs CPU on a handheld). There is deliberately no version predicate, because the obvious one is wrong. An earlier cut of this gated on "AMD && mesa >= 26.1" from a timeline that appeared to implicate a mesa upgrade. That was measured, and it is false. Holding everything else constant, radeonsi still crashes on: mesa 26.0.5 (as well as 26.1.4) llvm 22.1.3 (as well as 22.1.8) libdrm 2.4.131 (as well as 2.4.134) On mesa 26.0.5 the driver matrix is identical to 26.1.4 -- radeonsi crashes, zink works, software works. So the mesa version is not the trigger, and a mesa gate is both over-inclusive (fires on SteamOS/Bazzite machines that may be fine) and provably under-inclusive (Bazzite 43 ships mesa 26.0.5, which is affected, and would never have been fixed). The true trigger is still unidentified; the last known-good run here predates a kernel bump 7.0.1 -> 7.1.3, which is the prime remaining suspect. Detecting the failure instead of predicting it sidesteps that entirely: it cannot be wrong about who is affected, it covers distros with neither pacman nor rpm, and it needs no package-manager version sniffing (which also removes an rpm fail-open bug -- rpm -q prints "package X is not installed" on stdout, so it was consumed as a version string). The marker lives under %t (/run/user/<uid>), cleared every boot, so the normal driver is retried once per boot. When the underlying bug is fixed upstream this stops engaging on its own -- self-expiring, nothing to revert. If zink fails too the marker stays and we keep retrying zink, no worse than today. Verified end-to-end on CachyOS from a cleared marker: Started -> panic -> [marker armed] -> Started -> "previous start failed - retrying GL via zink" -> Event loop ready NRestarts=1, live process has MESA_LOADER_DRIVER_OVERRIDE=zink. Also verified a deliberate `systemctl stop` does NOT arm the fallback ($SERVICE_RESULT=success), and that a real failure does. Applied to both the canonical unit and the installer heredoc, kept byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fc38bda to
d2f3059
Compare
ExecStopPost armed the zink fallback on *any* unclean exit, which is a much wider net than the bug it targets. The radeonsi crash lands ~118ms in, inside startEventLoop(), before a line of our code runs. A crash two hours later -- OOM, a later CEF fault, SIGKILL -- is a different bug entirely, but it armed the marker just the same and silently moved the machine onto zink for the rest of the boot. On a device where radeonsi works fine (verified: SteamOS, Radeon 8060S, NRestarts=0) that swaps a working driver for no reason and buries the real cause: every later bug report then arrives on a driver nobody chose. Gate on "died before it was ever ready" instead, which is precisely the GL-init signature. The app writes a readiness flag once it has a live event loop -- registering a global shortcut proves GL came up -- and the unit clears the flag on every start. Absent at exit => GL init killed us => retry zink. Present at exit => something else did => leave the driver alone. CachyOS behaviour is unchanged: the crash lands before the flag is ever written, so the fallback still engages and still fixes the overlay. Truth table, exercised directly against the real ExecStopPost predicate: clean stop, was ready not armed clean stop, never ready not armed CRASH before ready (GL init bug) ARMED crash AFTER ready (unrelated bug) not armed core-dump before ready ARMED SIGKILL after ready not armed `systemd-analyze --user verify` passes; typecheck clean. Note for anyone tempted to do this with a timer instead: %s is a systemd specifier (user shell), so `date +%s` in a unit silently becomes `date +/bin/bash`. The readiness flag sidesteps that entirely. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed + tested on SteamOS hardware — design confirmed, one fix pushedTested on a SteamOS box with the same GPU as the reporter: The "detect, don't predict" call is right — here's independent evidenceThe overlay is completely healthy here on radeonsi: Same GPU, mesa 26.1.2 — which sits between the 26.0.5 and 26.1.4 this PR already proved both crash — and radeonsi is fine. So the trigger is provably neither the GPU nor the mesa version, and any hardware-or-version gate would have shipped wrong. This corroborates the PR's own downgrade matrix from a second distro. It also fits the kernel suspicion left open in the description: SteamOS is on 6.18.38-valve1, CachyOS on 7.1.3. zink works here too, including under gamescope, on both displays:
The systemd comment-stripping assumption holds — confirmed against the live The problem: it armed on the wrong signalExecStopPost=-/bin/sh -c '[ "$SERVICE_RESULT" = "success" ] || touch "%t/loadout-overlay-gl-fallback"'This fires on any unclean exit, which is a far wider net than the bug it targets. The radeonsi crash lands ~118ms in, inside On a device where radeonsi works fine (measured above: It also undercuts the "if zink fails too … no worse than before" claim: that only holds if the marker never false-arms. On a box where radeonsi works but zink is absent (no Vulkan ICD — VM, nouveau-only), one unrelated crash pins a broken driver for the whole boot, turning a transient crash into a permanent loop. Fix pushed (4aef101)Gate on "died before it was ever ready", which is precisely the GL-init signature. The app writes a readiness flag once it has a live event loop (registering a global shortcut proves GL came up); the unit clears it on every start; the fallback arms only if it's still absent at exit. Truth table, exercised against the real
CachyOS behaviour is unchanged — the crash lands before the flag is ever written, so the fallback still engages and still fixes the overlay.
|
Verified
|
| Scenario | Expected | Result |
|---|---|---|
| Crash before ready (the real radeonsi GL bug) | arms → zink recovery | ✅ pass |
SIGKILL after a healthy start |
not armed | ✅ pass |
Clean systemctl stop |
not armed | ✅ pass (result=success) |
Crash-before-ready — the genuine bug, end to end:
16:57:41 Starting → panic(main thread): Segmentation fault at address 0x0
16:57:49 Main process exited, code=exited, status=132 ← no ready flag → marker armed
16:57:54 Scheduled restart → [loadout-overlay] previous start failed - retrying GL via zink
16:57:54 === CEF EVENT LOOP STARTED === → GlobalShortcut: Event loop ready
Post-ready kill — run deterministically with a Restart=no drop-in so the auto-restart couldn't confound the result (my first attempt raced: the restart hit radeonsi and armed the marker legitimately ~5s later, which is easy to misread as a false-arm):
service state: failed / result=signal
ready flag still present: YES
MARKER ARMED BY POST-READY KILL: NO
Under d2f3059 that identical kill would have armed and pinned zink for the boot. That's the regression this commit fixes, reproduced on real hardware.
The stated regression risk didn't materialise
if the flag never appears on a working overlay, the gate would stop the fallback from ever arming
The flag appears reliably. XDG_RUNTIME_DIR and %t both resolve to /run/user/1000, so app and unit agree on the path — worth noting since that's an implicit cross-file contract with no shared constant, and it fails silently (degrades to arm-on-any-failure) if a session ever sets them differently.
Comment-stripping in the continued sh -c also holds on systemd 261, not just 257 — proven by the unit running at all; had it not stripped, exec launcher would be commented out.
Review points (not blockers)
- The gate narrows false-arming without closing it. It separates "died before ready" from "died after ready" — not "GL init killed us" from "something else killed us early". Any early failure (bad
DISPLAY, missing lib, a transient race) still arms zink on a machine where radeonsi is fine. The worst case raised in review — radeonsi works but zink is absent (VM, nouveau-only) — is narrowed, not eliminated: a transient early crash there still pins a broken driver for the whole boot. Worth stating plainly rather than implying it's solved. - Correctness is coupled to code placement. The flag means "we reached
index.ts:787", a proxy for "GL init survived". Sound today, but anyone moving that block earlier or adding anawaitbefore it silently breaks the gate, with no test to catch it. - Minor: the flag-clearing
ExecStartPresits after the ≤30s backend-waitExecStartPre. Not a bug — clearing state just reads better before a long wait.
Unrelated finding worth its own issue
CEF re-parents into app-org.chromium.Chromium-<pid>.scope (ppid → user manager), outside loadout-overlay.service. A systemctl kill -s KILL logged "Killed unit cgroup … with SIGKILL" yet the tree survived it and kept running. So systemctl stop doesn't reap the overlay, and a leaked instance holds the CEF profile lock + devtools port — a later start then comes up with Cannot create profile / bind() failed: Address already in use, registers its shortcut, reports input intercept ready, and looks healthy in systemctl status but has no window and never opens. I hit exactly that during this testing. Not caused by this PR, but it makes crash/restart cycles more expensive than they look.
🤖 Tested with Claude Code on CachyOS / Radeon 890M / systemd 261
Root cause found — it was never the kernel, mesa, or CEFFollow-up to my verification comment above: dug past Bun's panic handler with gdb and traced the crash to its origin. Full analysis and fix in #214; the short version for this thread: Arch's webkit2gtk 2.52.5-1 (in the 2026-07-14 update batch that started all this) regressed to exporting libstdc++'s private This resolves the open questions in the PR description:
Implication for this PR: the zink fallback is no longer the fix for this bug (#214 removes the trigger and keeps machines on native radeonsi), but it just spent a month proving exactly why a detect-and-react net is worth carrying — it kept affected machines usable through an upstream regression nobody predicted. I'd merge both: #214 as the cure, this as the safety net, with the description's root-cause section updated to point at the webkit2gtk finding. 🤖 Analysis with Claude Code — gdb + LD_DEBUG + package-diff forensics on CachyOS / Radeon 890M |
…:call_once (#214) * fix(overlay): preload libstdc++ so webkit2gtk's JSC can't hijack std::call_once 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 <noreply@anthropic.com> * feat(scripts): install-pr.sh — build + install any PR for pre-merge testing 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 <noreply@anthropic.com> * fix(build): review fixes — space-safe find loops, https default for install-pr - 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 <noreply@anthropic.com> --------- Co-authored-by: srsholmes <s.r.s.holmes@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Problem
The overlay crash-loops and never opens on some AMD machines. Reported by a user on CachyOS desktop (OneXPlayer Super X, Radeon 8060S) and reproduced locally on CachyOS desktop (Radeon 890M).
The bundled CEF 147 segfaults inside
startEventLoop()during GL init on radeonsi — before any Loadout code runs. Bun reportspanic(main thread): Segmentation fault at address 0x0; the delivered signal is SIGILL from Bun's panic handler. systemd then restarts it every 5s forever.Fix
Don't predict who's affected — detect the failure and react:
ExecStopPostleaves a marker at%t/loadout-overlay-gl-fallbackwhen the service exits for any reason other than a clean stop.Restart=on-failurebrings the unit back, sees the marker, and retries withMESA_LOADER_DRIVER_OVERRIDE=zink.zink routes GL through Vulkan/RADV and keeps hardware acceleration (
glxinfo:zink Vulkan 1.4 (AMD Radeon 890M, RADV STRIX1)), unlikeLIBGL_ALWAYS_SOFTWARE=1which drops to llvmpipe and costs CPU/battery on a handheld.vulkan-radeonalready ships alongside mesa, so no new dependency.The marker lives under
%t(/run/user/<uid>), which the boot clears — so the normal driver is retried once per boot. When the underlying bug is fixed upstream this stops engaging on its own. Self-expiring; nothing to remember to revert.Why there is no version gate — I measured, and the obvious one is wrong
An earlier revision of this PR gated on
AMD && mesa >= 26.1, from a timeline that appeared to implicate a mesa upgrade. Review challenged it, I tested it, and it is false.Holding everything else constant on kernel 7.1.3, radeonsi still crashes on:
And on mesa 26.0.5 the driver matrix is identical to 26.1.4 — radeonsi crashes, zink works, software works. The mesa version is not the trigger.
The original timeline was void: the last successful start (11:27:57) ran continuously until the 11:41:57 reboot, so it was already running when mesa upgraded at 11:36 — the overlay was never once started in the window that would have implicated mesa. The update was also 605 packages, not the four I named.
A mesa gate would therefore have been:
The true trigger is still unidentified. The last known-good run predates a kernel bump 7.0.1 → 7.1.3, which is the prime remaining suspect (untested — needs a reboot). Detecting the failure sidesteps this entirely: it cannot be wrong about who is affected, and it covers Debian/Ubuntu/NixOS which have neither pacman nor rpm.
It also deletes two real bugs that review found in the version-gate code:
rpm -qfails open (its "package X is not installed" message goes to stdout, proven from rpm's sourcequery.cc:470→RPMLOG_NOTICE→ stdout, so it was consumed as a version string), andsort -Vfired on any non-numeric garbage.Verification
End-to-end on CachyOS desktop, from a cleared marker (simulating a fresh boot):
NRestarts=1; live process environment confirmsMESA_LOADER_DRIVER_OVERRIDE=zink. Also verified:systemctl stopdoes not arm the fallback ($SERVICE_RESULT=success) — checked explicitly, since otherwise every stop would silently pin zink;%tresolves to/run/user/1000and$SERVICE_RESULTis readable inExecStopPost(probed directly, not assumed);systemd-analyze verify.Cost
One crash per boot on affected machines, then recovery (~5s via
RestartSec). The overlay is opened on demand, so this is invisible in practice. It also produces one coredump per boot on affected machines. That's the price of not guessing — and given the predicate would have been wrong, it's the right trade.Distro impact
MESA_LOADER_DRIVER_OVERRIDEand never overrides it.Applied to both
loadout-overlay.serviceand thescripts/install.shheredoc, kept byte-identical (perc0f36c4, these drift).Notes
Branch name is legacy (
fix/overlay-mesa-26-radeonsi-zink) — the mesa-26 framing is exactly what this revision abandons.Separate from #212 (gamescope detection self-match) so each reverts independently. Both touch the same ExecStart region, so whichever lands second needs a rebase.
🤖 Generated with Claude Code