BPF unwind pads race to free Rust panics from the verifier's grip
Multi-revision series for exception cleanup landing pads aim to let Rust Drop run on panic, while maintainers push back on verifier complexity and edge cases with tracing.
Rust code in the BPF arena still cannot panic the way ordinary Rust expects. The language wants Drop glue to run so an RCU read lock, a preemption-disabled region, or a referenced kptr is released; the BPF model has long refused that path. The pressure is concrete now because LLVM 23 already emits the compiler half, and Yonghong Song has been iterating a large kernel series (through v6, v7, and v8) that teaches the verifier and JITs to run cleanup landing pads when a new bpf_unwind() kfunc walks the stack. The work is landing in public review at the same moment indirect subprogram calls (callx) and tracing interactions are exposing how much state the verifier must track to keep the safety model intact.
Song's cover letters state the problem without soft edges. bpf_throw() still means what it has always meant: walk to the exception boundary and discard every frame in between. "A frame that owns something" never gets to give it back, "so the verifier refuses to let such a frame throw at all." That refusal is exactly why Rust cannot treat bpf_throw() as its panic path: "Rust's Drop glue is that give-back, and there is nowhere to run it." LLVM lowers a function that owns a guard across a may-unwind call into an invoke whose cleanup landing pad holds the Drop, and the BPF backend records each region as a flat triple of byte offsets in a .bpf_cleanup section. The contract is simple on paper: a frame suspended inside a covered call resumes at its landing pad when an unwind passes through; the pad finishes with bpf_unwind_resume(). The series is the kernel half of that contract. It ingests the table at program load, teaches the verifier that a covered call can transfer to its pad, and has the JIT rewrite return addresses so the running stack actually reaches those pads.
That design deliberately does not overload bpf_throw(). Song keeps throw as the discard path and introduces bpf_unwind() for the path that must run cleanups. The distinction matters for Rust ergonomics: panic becomes an unwind that still obeys ownership, not an abrupt exit that the verifier must treat as poisoning every held resource. Selftests under the series hammer the shapes (shared callees, pads that themselves unwind, frames with and without coverage, light and failure cases) so the new edges are not left to theory.
Alexei Starovoitov has been reviewing the same patches as an argument about how much new machinery the verifier is allowed to grow. Several of his notes land as direct requests to delete state rather than special-case it. On the kfunc path he asked whether both unwind helpers can simply go through check_kfunc_call() the way bpf_throw() already does, "and follow the unwind after it," so open-coded allow lists disappear. On frame flags he questioned the pair of instruction-aux bits that mark paths reached inside versus outside a cleanup pad, suggesting instead "compare in_pad in func_states_equal(), like no_stack_arg_load," and drop the dual-bits rule plus the speculative-path special case. Entry-time lock and reference counters drew a sharper line: the verifier already reports unreleased references by allocation instruction at exit, so the extra fields are unnecessary and the patch that adds them should go. A helper that attached cleanup info only for the main program was likewise called out once bpf-ci showed that a program without subprograms never reads the table.
The ownership debate is more than bookkeeping. Song's rules refuse some programs that look locally safe when a callee releases a caller's reference and a caller's pad still assumes the old shape. Starovoitov countered that compiled Rust does perform exactly that transfer. "Compiled code does exactly that. It's a move." A consume() that takes a Record by value owns the drop; the caller emits a plain call with no cleanup record; the verifier then rejects the call or the resume for leaving references inconsistent. The pad_drops_caller_ref shape in the series is what that pattern lowers to, and the same logic applies to an RcuReadGuard passed by value. The tension is whether the verifier should track the move the compiler already performed or continue to enforce a stricter frame-local story that rejects valid Rust.
Runtime dispatch adds its own unresolved edges. The x86 JIT walks with the ORC unwinder and rewrites return-address slots toward landing pads or a recorded epilogue. Traced frames break the story: when a return slot holds a kretprobe or function-graph trampoline, the address cannot be rewritten and the walk stops. An fexit attached mid-chain produces the same cut. In a main-to-A-to-trampoline-to-B-to-C sequence, an unwind from C may fix B and then lose A and main because bpf_prog_ksym_find() returns nothing for the trampoline; B then returns zero into a callsite the verifier never kept. CI and review also flagged whether every program truly receives a usable epilogue address when its only exits sit behind subprograms that always unwind, whether ENDBR is required at pad heads that are reached only by rewritten returns (it is not; IBT does not check RET targets), and whether extending bpf_prog_load_opts with cleanup_info fields risks reading uninitialized padding from older libbpf callers.
callx sits beside the unwind work rather than inside it. Starovoitov's indirect-call series landed on bpf-next, expanding the call graph, liveness, and JIT requirements for function pointers to subprograms. Cleanup shape tests already exercise shared callees and covered versus uncovered sites; indirect calls make more of those shapes realistic and also surface toolchain friction (GCC BPF rejecting indirect calls in selftests, LLVM 22 mishandling certain pointer arrays that LLVM 23 fixed). The two efforts reinforce the same underlying bet: BPF programs will look more like ordinary compiled code, with richer control flow and real unwinding, and the verifier must scale without turning every new edge into permanent special cases.
As of the v8 posting the series is still under active revision. Song continues to carry the landing-pad, verifier, JIT, libbpf, and selftest surface; Starovoitov continues to strip state, collapse kfunc handling, and demand that move semantics and tracing not create silent holes. What remains open is not whether Rust panics deserve a path (the LLVM side and the existence of bpf_unwind() already answer that), but how small the verifier model can stay while still proving that every pad restores locks and references exactly as the frame found them, even when trampolines, subprograms that only unwind, and ownership transfers intervene. Until those rules settle, Rust-in-BPF can compile the Drop story, yet the kernel still treats each new shape as a negotiation between ergonomics and the safety invariant that made BPF viable in the first place.