Summary
When the result of a map .lookup() is used in a boolean/arithmetic expression like (prev or 0) + 1, Python-BPF compiles prev or 0 as a truthiness test on the lookup pointer rather than dereferencing it to read the stored value. The result is a counter that silently never accumulates: it stores 1 on first insert and 2 forever after (or 0, depending on how the boolean is coerced), instead of value + 1.
This is easy to hit because (prev or 0) + 1 is the natural Python way to write "increment, defaulting to 0" — and it produces a program that loads and runs cleanly but is silently wrong.
Versions
pythonbpf==0.1.9
pylibbpf==0.0.7
llvmlite==0.47.0
- Python 3.12.3, Ubuntu 24.04
- kernel 6.12 (aarch64); reproduced architecture-independently (see note)
Minimal reproduction
from ctypes import c_int32, c_uint64, c_int64, c_void_p
from pythonbpf import bpf, bpfglobal, map, section, BPF
from pythonbpf.maps import HashMap
from pythonbpf.helper import pid
import time, subprocess
@bpf
@bpfglobal
def LICENSE() -> str:
return "GPL"
@bpf
@map
def counts() -> HashMap:
return HashMap(key=c_int32, value=c_uint64, max_entries=4096)
@bpf
@section("tracepoint/syscalls/sys_enter_execve")
def count_exec(ctx: c_void_p) -> c_int64:
key = 0 # fixed key so every exec hits one bucket
prev = counts.lookup(key)
counts.update(key, (prev or 0) + 1) # <-- the trap
return 0
b = BPF(); b.load(); b.attach_all()
m = b["counts"]
for _ in range(20):
subprocess.run(["/bin/true"])
time.sleep(1)
print("bucket[0] =", m.items().get(0)) # expected >= 20
Observed: bucket[0] = 0 after 20 execs.
Expected: bucket[0] >= 20.
(A per-PID key hides the bug, because fresh execs get fresh PIDs and always take the first-insert path — the broken accumulation never shows. Use a fixed key to see it.)
Generated IR (the smoking gun)
For (prev or 0) + 1, the emitted IR treats the lookup pointer as a bool and adds 1 to that bool — it never loads *prev:
%".8" = call i64* @lookup(... @counts, i64* %process_id) ; pointer result
store i64* %".8", i64** %"prev"
%".10" = load i64*, i64** %"prev"
%".11" = icmp ne i64* %".10", null ; <-- pointer truthiness, not a load
...
%"or.result" = phi i1 [ ... ]
%".16" = sext i1 %"or.result" to i64 ; bool -> 0/1
%".17" = add i64 %".16", 1 ; (0 or 1) + 1 -- value never read
store i64 %".17", i64* %"__helper_temp_i64_0"
call i64 @update(... @counts, %process_id, %"__helper_temp_i64_0", i64 0)
So the stored value is (prev != null) + 1, not *prev + 1.
Workaround (what I'm using)
Explicitly dereference with deref() and branch on existence:
from pythonbpf.helper import pid, deref
prev = counts.lookup(key)
if prev:
counts.update(key, deref(prev) + 1)
else:
counts.update(key, 1)
With this, the same fixed-key test reads 20. Verified working.
Suggestion
Either (a) make prev or 0 / arithmetic on a lookup result auto-dereference (matching the Python intuition), or (b) reject/warn at compile time when a map-lookup pointer is used directly in arithmetic/boolean context, pointing users to deref(). A note in the docs/README about lookup() returning a pointer (and (prev or 0) being a footgun) would also help — the C-equivalent semantics aren't obvious from the Python.
Happy to provide the full container setup or test more cases.
Summary
When the result of a map
.lookup()is used in a boolean/arithmetic expression like(prev or 0) + 1, Python-BPF compilesprev or 0as a truthiness test on the lookup pointer rather than dereferencing it to read the stored value. The result is a counter that silently never accumulates: it stores 1 on first insert and 2 forever after (or 0, depending on how the boolean is coerced), instead ofvalue + 1.This is easy to hit because
(prev or 0) + 1is the natural Python way to write "increment, defaulting to 0" — and it produces a program that loads and runs cleanly but is silently wrong.Versions
pythonbpf==0.1.9pylibbpf==0.0.7llvmlite==0.47.0Minimal reproduction
Observed:
bucket[0] = 0after 20 execs.Expected:
bucket[0] >= 20.(A per-PID key hides the bug, because fresh execs get fresh PIDs and always take the first-insert path — the broken accumulation never shows. Use a fixed key to see it.)
Generated IR (the smoking gun)
For
(prev or 0) + 1, the emitted IR treats the lookup pointer as a bool and adds 1 to that bool — it never loads*prev:So the stored value is
(prev != null) + 1, not*prev + 1.Workaround (what I'm using)
Explicitly dereference with
deref()and branch on existence:With this, the same fixed-key test reads
20. Verified working.Suggestion
Either (a) make
prev or 0/ arithmetic on a lookup result auto-dereference (matching the Python intuition), or (b) reject/warn at compile time when a map-lookup pointer is used directly in arithmetic/boolean context, pointing users toderef(). A note in the docs/README aboutlookup()returning a pointer (and(prev or 0)being a footgun) would also help — the C-equivalent semantics aren't obvious from the Python.Happy to provide the full container setup or test more cases.