fiber context switch under ReleaseSafe: the clobber bugs
fiber context switch under ReleaseSafe: the clobber bugs
ritten against zig 0.16.0 (LLVM < 23). evidence + repros live in zio —
src/fiber/,docs/fiber-gauntlet-2026-07-09.md. root-caused 2026-07-09.
std.Io.Evented's fibers crash under ReleaseSafe (SIGSEGV/GPF at garbage
addresses on the first or an early context switch). this is the crash class
that forced zlay's april 2026 retreat from Evented to Threaded, and it is
upstream's undiagnosed #35712.
the primitive is fine — two registers escape its clobber list.
the mechanism
std/Io/fiber.zig (323 lines, byte-identical 0.16.0 ↔ master 0.17-dev.1282)
switches stacks with inline asm that saves only sp/fp/pc and declares every
other register clobbered, so LLVM must spill anything live across the switch.
the model is sound — if the clobbers are honored. two are not:
~{x30}is silently dropped by LLVM < 23 (llvm/llvm-project #167783): aarch64 clobbers of x29/x30 written by xN name are ignored; only the ABI namesfp/lrregister. clang always emits~{lr}so C never hit this. zig lowers.x30 = trueto~{x30}verbatim → LLVM happily keeps live values in the link register across the switch. the fiber body executesblinstructions, so on resume the caller reads back a return address — which is why every crash address in this class looks like instruction bytes.- x18 is missing from the clobber list entirely. x18 is the reserved platform register on darwin/windows (never allocated, omission harmless) but a general-purpose allocatable register on linux — where LLVM parks values in it across the switch. platform-dependent corruption from a platform-independent-looking list.
debug builds never keep values in registers across statements, which is why the crashes are ReleaseSafe/ReleaseFast-only, and why they appear/disappear across compiler versions and platforms — register-allocation luck, not fixes.
the proof shape (worth remembering as a technique)
-femit-llvm-irshowed~{x30}present in the IR at the exact asm siteobjdump -dshowedmov x30, x1before the switch andadd x8, x30, #1after the resume label — LLVM consumed a value through a declared clobber- IR-says-clobbered vs disasm-says-live-across is a complete, arguable proof of a backend bug; no debugger needed
the fixes (two words + one ABI detail)
// in the aarch64 clobber list:
.x30 = true → .lr = true // spelling LLVM honors on every version
+ .x18 = true // required on linux, harmless on darwin
third, for anyone building their own fibers: x86_64 SysV expects
rsp ≡ 8 (mod 16) at function entry (as-if a call pushed the return
address). entering a fresh fiber stack via jmp with a 16-aligned rsp
GP-faults on the first aligned SSE spill. bias the initial rsp by 8.
with the two clobber fixes, the zio gauntlet (100k switches, 256 interleaved fibers, cross-thread migration) passes 7/7 on macos-aarch64, linux-aarch64, linux-x86_64 — Debug and ReleaseSafe. stackful fibers are fully compatible with ReleaseSafe; the folklore to the contrary was these two clobbers.
broader lessons
- inline-asm clobber lists are trusted, not verified. nothing checks that the backend honors them (zig #18292 is the adjacent invalid-string case). when a crash smells like register corruption across asm, diff IR against disasm.
- register names are not portable spellings.
x30/lrare the same register with different bug surfaces; a clobber list correct on one OS (x18 on darwin) can be incomplete on another. - "works in this build" is weak evidence for fiber code. allocation luck masked the linux bug on master's Uring backend; a targeted stress harness (many switches, register pressure inside fiber frames) exposed it in three tests.
- caveat kept honest: the mechanism is proven on aarch64 (both OSes). the april x86_64 GPF matches the class but was never autopsied; LLVM #167783 is aarch64-specific, so that one may have been the jmp-alignment class instead.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.