Why do non-sleepable BPF fentry and fexit programs silently skip calls from another task on the same CPU, and why do the skipped calls show up as recursion misses?
Because the trampoline recursion guard is per-CPU and is taken with preemption still enabled. A non-sleepable fentry/fexit program runs through the BPF trampoline, whose entry path increments prog->active, a per-CPU counter, under rcu_read_lock_dont_migrate(). That lock only disables CPU migration; it does not disable preemption. If the task that entered the program is preempted mid-program, its counter stays at 1 on that CPU. When an unrelated task is then scheduled onto that CPU and hits the same hook, the trampoline sees the counter go to 2 instead of 1, concludes that the program is recursing, increments recursion_misses, and silently skips the program. No recursion happened; the miss is a false positive.
Before kernel 7.0 most distribution kernels ran PREEMPT_VOLUNTARY by default, and non-sleepable BPF programs contain no voluntary preemption points, so the window was rarely hit. Commit 7dadeaa6e851 ("sched: Further restrict the preemption modes") removed PREEMPT_NONE and PREEMPT_VOLUNTARY on x86 and arm64, leaving lazy and full preemption. Every distribution kernel since then preempts inside non-sleepable fentry/fexit programs during normal multi-tasking, so the drops are now routine, and they are invisible in the trace output: the event simply never runs.
The mechanism
-
The guard.
__bpf_prog_enter_recur()inkernel/bpf/trampoline.cacquiresprog->active— a per-CPU counter declaredu8 __percpu *activeininclude/linux/bpf.h— underrcu_read_lock_dont_migrate().bpf_prog_get_recursion_context()then requires the per-CPU value to be exactly 1; any other value means "already inside", the trampoline callsbpf_prog_inc_misses_counter(), optionally fires therecursion_detectedcallback, and returns without running the BPF program. -
The preemption hole.
rcu_read_lock_dont_migrate()keeps the task pinned to its CPU but leaves it preemptible. Task A entersfentry/foo,prog->activeon CPU N becomes 1, Task A is preempted mid-program with the counter still 1. Task B runs on CPU N and callsfoo(); the counter increments to 2, the checkval != 1fails, and Task B's invocation is counted as a recursion miss and skipped. When Task A resumes and exits, the counter drops back to 0. The miss is pure preemption interference, not re-entry. -
Why per-CPU, not per-task. Since commit
a76ab5731e32("bpf: Find eligible subprogs for private stack support"), recursion-checked trampoline programs also useprog->activeto guarantee exclusive ownership of the single per-CPU private stack (priv_stack_ptr) and per-CPU scratch maps. Switching the counter to per-task storage would remove the false misses but break that ownership guarantee, which is why the fix has to be a preemption barrier, not a different counter. -
The contrast with kprobes and tracepoints.
trace_call_bpf()inkernel/trace/bpf_trace.cassertscant_sleep()and commit16718274ee75("tracing: perf: Have perf tracepoint callbacks always disable preemption") kept preemption disabled for perf tracepoint callbacks. So the same BPF program logic gets different completeness guarantees depending only on whether it attaches via fentry/fexit or via kprobe/classic tracepoint.
How to verify and debug
-
Count the misses. Enable
BPF_STATS_RUN_TIMEwithbpf_enable_stats(), run the workload, then readprog_info.run_cntagainstprog_info.recursion_missesviabpf_prog_get_info_by_fd(). A program that never re-enters itself but reports non-zerorecursion_missesunder a preemptible kernel is the signature of this bug; the counter has existed since commitca06f55b9002("bpf: Add per-program recursion prevention mechanism"), which the RFC cites as theFixes:target. -
Reproduce deterministically. Pin a producer task to one CPU, attach a paired fentry/fexit probe to a hot function, and make the probe body long enough to be preemptible (the RFC's 47-line reproducer loops a
bpf_loopof a million iterations inside bothfentry/__set_task_commandfexit/__set_task_commwhile forking short-lived tasks). The RFC measured 0 skips per 1000 on a stock 6.8 kernel in voluntary-preemption mode, 25-26 percent on the same kernel in full-preemption mode, and 28 percent or more on 7.3 mainline in lazy mode. -
Check the preemption mode.
uname -vand the kernel config tell you whetherCONFIG_PREEMPT_LAZYorCONFIG_PREEMPTis set. A kernel built with the old voluntary mode does not reproduce the drops; that is why the bug is new to 7.0-era kernels even though the code path is much older. -
Cross-check attach types. Re-attach the same logic as a kprobe and as a classic tracepoint program on the same hook. Because those paths run with preemption disabled, the kprobe/tracepoint variants keep firing for every task; the fentry/fexit variant keeps the false recursion misses. That differential is the fastest way to confirm the cause rather than suspect a logic bug in the program.
The limitation
- The RFC's fix applies to
!CONFIG_PREEMPT_RTkernels only: on PREEMPT_RT the trampoline keepsrcu_read_lock_dont_migrate(), since sleepable and preemptible execution is the design goal there. Non-RT kernels getpreempt_disable_notrace()around the enter/exit pair instead. - The miss itself is silent by design:
recursion_missesis a polled stat and therecursion_detectedcallback is optional, so a monitoring system that only watches successful events will not notice the loss of completeness on its own. - Private-stack and per-CPU scratch-map ownership is the reason the counter cannot simply become per-task; any alternative fix still has to serialize the CPU, which is why the series chose a preemption barrier over a counter redesign.
- The kprobe/tracepoint workaround is not free: on hot socket paths the RFC reports fentry roughly 2x faster than kprobe, and kretprobe relies on a bounded
maxactivepool that can exhaust on blocking calls such asinet_recvmsg().
References
- Public bpf mailing-list RFC: do not let preemption cause false recursion drops in trampoline programs — a public bpf-list RFC proposing
preempt_disable_notrace()around__bpf_prog_enter_recur()/__bpf_prog_exit_recur()on non-RT kernels, plus a reproducing selftest. - Kernel
kernel/bpf/trampoline.c—__bpf_prog_enter_recurand the miss-counting path. - Kernel
include/linux/bpf.h— the per-CPUactivecounter andbpf_prog_get_recursion_context. - Kernel
include/uapi/linux/bpf.h—bpf_prog_info.recursion_missesandrun_cnt. - Kernel
kernel/trace/bpf_trace.c—trace_call_bpf, the preemption-disabled kprobe/tracepoint contrast. - Commit
7dadeaa6e851("sched: Further restrict the preemption modes") — removedPREEMPT_NONE/PREEMPT_VOLUNTARYon x86 and arm64, exposing the window.
Community discussion today
A public bpf mailing-list RFC proposes disabling preemption around the trampoline recursion enter/exit on non-RT kernels so that per-CPU recursion tracking and per-CPU private stacks are not shared across preempted tasks. The series includes a 47-line reproducer and measured false recursion drop rates of 0 percent on voluntary-preemption kernels against 25-28 percent on full- and lazy-preemption kernels. An automated mailing-list review flagged only a comment-style nit in the patch. The discussion so far is narrow: the boundary is whether a preemption barrier in the hot trampoline path is acceptable on all non-RT kernels, and whether the private-stack ownership argument fully justifies keeping the per-CPU counter.
This run's archive-backed sources produced 0 messages in the 7-day window (the two opt-in archive sources were empty at snapshot time), so the question above was sourced from the permitted public bpf mailing list, reviewed through its public atom feed, and the RFC thread was used as the primary reference. The Discord channels for eunomia-bpf and sched-ext and r/eBPF were not covered this run (no visible-browser session was available), so they are marked not-covered rather than quiet.
Continue exploring
Back to index
eBPF Q&A
Daily, source-grounded answers to recurring questions about eBPF, Linux observability, profiling, runtime extension, and security.
Previous
When a BPF binfmt-misc handler runs an exec in transparent mode, why do the process exe link, comm, and command line still describe the binary instead of the interpreter that actually runs?
Because the kernel pins "identity" to the binary on purpose. A BPF binfmt-misc handler picks the interpreter through BPF-selected fields on struct linuxbinprm; when the selection carries the transparent flag, the exec
Next
Why does the BPF redirect neighbor helper trip a kernel BUG on a packet whose Ethernet header is short, and how does the fix read the true header length differently on the receive path than on the transmit path?
Because skb-maclen is only populated on the receive path. A bpfredirectneigh() fix that guards the pull with maclen under ETHHLEN is safe on ingress, but on the transmit path — locally generated packets through tc
- Last updated
- Oct 26, 2026
- First published
- Oct 26, 2026
- Contributors
- Yunwei
Was this page helpful?