Skip to content

Give and/or Python value semantics and deref map lookups (#89) - #100

Open
douglasmun wants to merge 3 commits into
pythonbpf:masterfrom
douglasmun:fix/bool-op-map-lookup-deref
Open

Give and/or Python value semantics and deref map lookups (#89)#100
douglasmun wants to merge 3 commits into
pythonbpf:masterfrom
douglasmun:fix/bool-op-map-lookup-deref

Conversation

@douglasmun

Copy link
Copy Markdown

Fixes #89.

The bug

prev = counts.lookup(process_id)
counts.update(process_id, (prev or 0) + 1)

compiles and loads cleanly, and the counter sticks at 1 instead of accumulating. The emitted IR on master:

%".13" = load i64*, i64** %"prev"     ; loads the pointer, not the pointee
%".14" = icmp ne i64* %".13", null    ; truthiness test on the pointer
%"or.result" = phi i1 ...
%".19" = sext i1 %"or.result" to i64  ; the bool becomes the addend
%".20" = add i64 %".19", 1            ; 1 + 1, forever

Two separate things go wrong:

  1. The lookup is never dereferenced. map.lookup() returns a pointer into the map (NULL when absent), and convert_to_bool() emits icmp != null on it, so the stored count never enters the expression.
  2. or returns i1 regardless of its operands. Even with the operand dereferenced, phi i1 + sext means the addition operates on a 0/1 flag. Python's or evaluates to an operand, not to True/False.

Fixing only (1) is not enough — I tried that first, and the sext i1 was still there.

The fix

  • _prepare_bool_operand() auto-dereferences pointer operands, mirroring what get_operand_value() already does on the binary-operator path. deref_to_depth() emits a null-checked load, so an absent key yields a zero-valued pointee rather than faulting — exactly the fallback prev or 0 asks for.
  • The phi in _handle_and_op / _handle_or_op now carries operand values rather than a bool.
  • Operands that cannot yield an integer keep their previous truth-value behaviour (_widen_to).

After:

%"or.result" = phi i64 [%"deref_0_result", %"or.value_0"], [0, %"or.check_1"]
%".23" = add i64 %"or.result", 1

Truthiness contexts are unaffected

if prev: goes through functions_pass.py, which calls convert_to_bool() directly, so pointer NULL-ness still means "is the key present?" there. The existing conditionals/or.py and conditionals/map.py tests continue to pass.

Note for reviewers

and/or now return i64 instead of i1. Branch contexts re-convert, so if x or y: is unchanged, but this is a visible type change if anything downstream assumes an i1 result — worth a look from someone who knows the codebase better than I do.

Tests

The existing IR and llc tiers only assert that compilation succeeds, so a wrong-value miscompile passes them both — which is how this survived. tests/test_boolop_semantics.py asserts on the emitted IR instead: that or.result is not i1, and that the lookup is actually loaded. Both tests fail on master and pass with this change.

tests/passing_tests/conditionals/map_or_default.py adds the lookup-in-arithmetic pattern; the existing or.py only covers if x or y: nullness.

Full suite before and after this change is identical (49 failed / 50 passed / 24 skipped / 5 xfailed on the IR + llc tiers in my container). The verifier tier needs a real kernel and is unaffected by this change. Note that llc in my test container rejects llvmlite's captures(none) attribute on every test file including master's, so the llc tier was not a useful signal here.

How I verified

aarch64, Docker, Python 3.12, pythonbpf at 926ce3f, pylibbpf stubbed (unused by compile_to_ir).

Douglas Mun added 3 commits September 7, 2026 02:01
`prev = map.lookup(k)` returns a pointer into the map (NULL when the key
is absent), and the boolean-operand path converted that pointer straight
to i1 via convert_to_bool(). Two things went wrong in
`map.update(k, (prev or 0) + 1)`:

  1. the pointer was tested for NULL rather than dereferenced, so the
     stored count never took part in the expression; and
  2. `or` returned i1 regardless of its operands, so the result was
     sign-extended and the addition operated on a 0/1 flag.

The counter therefore stuck at 1 instead of accumulating -- silently, as
the program compiles and loads cleanly.

Fix both halves. `_prepare_bool_operand()` auto-dereferences pointer
operands, mirroring what `get_operand_value()` already does on the binary
-operator path; `deref_to_depth()` emits a null-checked load, so an absent
key yields a zero-valued pointee, which is the fallback `prev or 0` asks
for. The phi in `_handle_and_op`/`_handle_or_op` now carries the operand
values instead of a bool, matching Python, where `a or b` evaluates to an
operand and not to True/False. Operands that cannot yield an integer keep
their previous truth-value behaviour.

Truthiness contexts are unaffected: `if prev:` goes through
functions_pass.py, which calls convert_to_bool() directly, so pointer
NULL-ness still means "is the key present?" there.

Regression tests: the existing IR and llc tiers only assert that
compilation succeeds, so a wrong-value miscompile passes them both, which
is how this survived. tests/test_boolop_semantics.py asserts on the
emitted IR instead -- that or.result is not i1 and that the lookup is
actually loaded. Both fail before this change and pass after.

Refs pythonbpf#89
_prepare_bool_operand() only needs the pointer depth, not the base type.
Keeps expr_pass.py at the same ruff error count as master.
The sibling tests in conditionals/ use an unsorted import block, but the
repo's own pre-commit config runs ruff, so follow the linter here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Map lookup result used as truthiness in arithmetic — (prev or 0) + 1 never dereferences the pointer, counter never accumulates

1 participant