From 6220180df81f9104325e7fcc1be439554dad3dc6 Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:11:35 +0530 Subject: [PATCH 1/8] Core: Lower annotated assignment (x: c_int32 = 0) The annotation types the slot, so `x: c_int32 = 0` is an i32 where the literal alone would give an i64; the store goes through the same handle_variable_assignment plain assignment uses. A bare `x: T` makes the slot and binds nothing. The already-bound and global-shadowing bookkeeping that Assign allocation did inline moves into _bind_name, which both statements now call. --- pythonbpf/allocation_pass.py | 156 ++++++++++++------ pythonbpf/functions/functions_pass.py | 25 +++ .../assign/annotated_assign.py | 0 3 files changed, 134 insertions(+), 47 deletions(-) rename tests/{failing_tests => passing_tests}/assign/annotated_assign.py (100%) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 10ff7b2f..464164c5 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -49,57 +49,119 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab): ) continue - var_name = target.id + _bind_name( + compilation_context, + stmt, + target, + local_sym_tab, + lambda var_name: _allocate_for_value( + builder, var_name, rval, local_sym_tab, compilation_context + ), + ) - # Already bound in this scope: a parameter, an earlier assignment, or a - # `global` declaration (whose slot is the GlobalVariable). No slot needed. - if var_name in local_sym_tab: - logger.debug(f"'{var_name}' already bound, no allocation needed") - continue - # Not declared `global`, yet named like one: Python creates a local - # that shadows the global for the whole function body, and leaves the - # global untouched. Do the same. - shadows_global = var_name in compilation_context.bpf_globals - if shadows_global: - logger.info( - f"'{var_name}' is assigned without a 'global' declaration, so it " - f"is a local shadowing the @bpfglobal of the same name" - ) +def handle_ann_assign_allocation(compilation_context, builder, stmt, local_sym_tab): + """Handle memory allocation for annotated assignment (`x: c_int32 = 0`). - # Determine type and allocate based on rval - if isinstance(rval, ast.Call): - _allocate_for_call( - builder, var_name, rval, local_sym_tab, compilation_context - ) - elif isinstance(rval, ast.Constant): - _allocate_for_constant(builder, var_name, rval, local_sym_tab) - elif isinstance(rval, ast.BinOp): - _allocate_for_binop( - builder, var_name, rval, local_sym_tab, compilation_context - ) - elif isinstance(rval, ast.Name): - # Variable-to-variable assignment (b = a) - _allocate_for_name( - builder, var_name, rval, local_sym_tab, compilation_context - ) - elif isinstance(rval, ast.Attribute): - # Struct field-to-variable assignment (a = dat.fld) - _allocate_for_attribute( - builder, var_name, rval, local_sym_tab, compilation_context - ) - else: - logger.warning( - f"Unsupported assignment value type for {var_name}: {type(rval).__name__}" - ) + The annotation, not the value, types the slot: `x: c_int32 = 0` is an i32 + even though the literal alone would give an i64. With no value + (`x: c_int64`) the slot is still made, and the name stays unbound until + something assigns it, as in Python. + """ + logger.info(f"Handling annotated assignment for allocation: {ast.dump(stmt)}") - if shadows_global and var_name in local_sym_tab: - # Where the binding ends, so that a read above it is reported the - # way Python reports it. end_lineno, not lineno, so a read on a - # continuation line of a multi-line binding counts as above it too. - local_sym_tab[var_name].shadows_global_from = ( - getattr(stmt, "end_lineno", None) or target.lineno - ) + if not isinstance(stmt.target, ast.Name): + raise SyntaxError( + f"annotated assignment on line {stmt.lineno} must target a plain " + f"name, got {type(stmt.target).__name__}" + ) + + ir_type = annotation_to_ir(stmt.annotation, stmt.lineno) + _bind_name( + compilation_context, + stmt, + stmt.target, + local_sym_tab, + lambda var_name: local_sym_tab.__setitem__( + var_name, + LocalSymbol(_allocate_with_type(builder, var_name, ir_type), ir_type), + ), + ) + + +def annotation_to_ir(annotation, lineno): + """IR type for a ctypes annotation, written `c_int32` or `ctypes.c_int32`.""" + if isinstance(annotation, ast.Name): + name = annotation.id + elif isinstance(annotation, ast.Attribute): + name = annotation.attr + else: + name = None + if name is None or not is_ctypes(name): + raise SyntaxError( + f"unsupported annotation on line {lineno}: {ast.unparse(annotation)} " + f"(annotate locals with a ctypes integer type such as c_int64)" + ) + return ctypes_to_ir(name) + + +def _bind_name(compilation_context, stmt, target, local_sym_tab, allocate): + """What every statement that binds a bare name shares, around the + statement-specific `allocate(var_name)` that makes the slot. + + A name already bound needs no new slot. A name that is also a @bpfglobal + but was not declared `global` becomes a local shadowing it, and records + where its binding ends so a read above it is reported as Python would. + """ + var_name = target.id + + # Already bound in this scope: a parameter, an earlier assignment, or a + # `global` declaration (whose slot is the GlobalVariable). No slot needed. + if var_name in local_sym_tab: + logger.debug(f"'{var_name}' already bound, no allocation needed") + return + + # Not declared `global`, yet named like one: Python creates a local + # that shadows the global for the whole function body, and leaves the + # global untouched. Do the same. + shadows_global = var_name in compilation_context.bpf_globals + if shadows_global: + logger.info( + f"'{var_name}' is assigned without a 'global' declaration, so it " + f"is a local shadowing the @bpfglobal of the same name" + ) + + allocate(var_name) + + if shadows_global and var_name in local_sym_tab: + # Where the binding ends, so that a read above it is reported the + # way Python reports it. end_lineno, not lineno, so a read on a + # continuation line of a multi-line binding counts as above it too. + local_sym_tab[var_name].shadows_global_from = ( + getattr(stmt, "end_lineno", None) or target.lineno + ) + + +def _allocate_for_value(builder, var_name, rval, local_sym_tab, compilation_context): + """Allocate a slot for `var_name = rval`, typed from the value.""" + if isinstance(rval, ast.Call): + _allocate_for_call(builder, var_name, rval, local_sym_tab, compilation_context) + elif isinstance(rval, ast.Constant): + _allocate_for_constant(builder, var_name, rval, local_sym_tab) + elif isinstance(rval, ast.BinOp): + _allocate_for_binop(builder, var_name, rval, local_sym_tab, compilation_context) + elif isinstance(rval, ast.Name): + # Variable-to-variable assignment (b = a) + _allocate_for_name(builder, var_name, rval, local_sym_tab, compilation_context) + elif isinstance(rval, ast.Attribute): + # Struct field-to-variable assignment (a = dat.fld) + _allocate_for_attribute( + builder, var_name, rval, local_sym_tab, compilation_context + ) + else: + logger.warning( + f"Unsupported assignment value type for {var_name}: {type(rval).__name__}" + ) def _allocate_for_call(builder, var_name, rval, local_sym_tab, compilation_context): diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 46fb71c3..2c78d721 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -25,6 +25,7 @@ ) from pythonbpf.allocation_pass import ( handle_assign_allocation, + handle_ann_assign_allocation, allocate_temp_pool, create_targets_and_rvals, LocalSymbol, @@ -146,6 +147,10 @@ def update_max_temps_for_stmt(stmt): ) elif isinstance(stmt, ast.Assign): handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab) + elif isinstance(stmt, ast.AnnAssign): + handle_ann_assign_allocation( + compilation_context, builder, stmt, local_sym_tab + ) allocate_temp_pool(builder, max_temps_needed, local_sym_tab) @@ -195,6 +200,24 @@ def handle_assign(func, compilation_context, builder, stmt, local_sym_tab): logger.error(f"Unsupported assignment target: {ast.dump(target)}") +def handle_ann_assign(func, compilation_context, builder, stmt, local_sym_tab): + """Handle `x: T = v`. The allocation pass already made x's slot with the + annotated type, so what is left is an ordinary store of v into it, through + the same helper plain assignment uses (which converts v to the slot's type). + A bare `x: T` binds nothing and emits nothing.""" + if stmt.value is None: + return + if not handle_variable_assignment( + func, + compilation_context, + builder, + stmt.target.id, + stmt.value, + local_sym_tab, + ): + logger.error(f"Failed to handle annotated assignment to {stmt.target.id}") + + def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): """Handle `x += v` and friends by direct lowering: resolve the target's slot, load it, apply the operator, store back. @@ -383,6 +406,8 @@ def process_stmt( ) elif isinstance(stmt, ast.Assign): handle_assign(func, compilation_context, builder, stmt, local_sym_tab) + elif isinstance(stmt, ast.AnnAssign): + handle_ann_assign(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.AugAssign): handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.Global): diff --git a/tests/failing_tests/assign/annotated_assign.py b/tests/passing_tests/assign/annotated_assign.py similarity index 100% rename from tests/failing_tests/assign/annotated_assign.py rename to tests/passing_tests/assign/annotated_assign.py From cd3cb1ba14ece2a759ddae7bdb53d72223c50d47 Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:11:35 +0530 Subject: [PATCH 2/8] Tests: Annotated assignment passes now --- tests/test_config.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_config.toml b/tests/test_config.toml index ada07a15..85dabc32 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -38,8 +38,6 @@ "failing_tests/globals_parameter_and_global.py" = {reason = "A parameter may not be declared global (Python: name is parameter and global)", level = "ir"} -"failing_tests/assign/annotated_assign.py" = {reason = "Annotated assignment (ast.AnnAssign, e.g. `x: c_int64 = 0`) is not handled by process_stmt", level = "ir"} - # Loop constructs: ast.For/ast.While are not handled by the statement dispatcher # (pythonbpf/functions/functions_pass.py process_stmt only matches Expr, Assign, # AugAssign, If, Return) and the pre-pass variable scan does not descend into From 5ab62816497eeb796330f02cf471cdec3fbae13f Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:19:40 +0530 Subject: [PATCH 3/8] Core: Lower if-branches in the function's return type handle_if processed its branches without passing ret_type down, so a return inside an if fell back to process_stmt's i64 default: a -> c_int32 function got `ret i64 7`, which llc rejects. The branches now go through process_block, which threads ret_type and stops at a statement that ends the block instead of emitting after its terminator. --- pythonbpf/functions/functions_pass.py | 34 +++++++++++++++---------- tests/passing_tests/return/if_narrow.py | 22 ++++++++++++++++ 2 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 tests/passing_tests/return/if_narrow.py diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 2c78d721..91f49421 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -318,7 +318,7 @@ def handle_cond(func, compilation_context, builder, cond, local_sym_tab): return convert_to_bool(builder, val) -def handle_if(func, compilation_context, builder, stmt, local_sym_tab): +def handle_if(func, compilation_context, builder, stmt, local_sym_tab, ret_type): """Handle if statements in the function body.""" logger.info("Handling if statement") # start = builder.block.parent @@ -336,22 +336,17 @@ def handle_if(func, compilation_context, builder, stmt, local_sym_tab): builder.cbranch(cond, then_block, merge_block) builder.position_at_end(then_block) - for s in stmt.body: - process_stmt(func, compilation_context, builder, s, local_sym_tab, False) + process_block( + func, compilation_context, builder, stmt.body, local_sym_tab, ret_type + ) if not builder.block.is_terminated: builder.branch(merge_block) if else_block: builder.position_at_end(else_block) - for s in stmt.orelse: - process_stmt( - func, - compilation_context, - builder, - s, - local_sym_tab, - False, - ) + process_block( + func, compilation_context, builder, stmt.orelse, local_sym_tab, ret_type + ) if not builder.block.is_terminated: builder.branch(merge_block) @@ -413,7 +408,7 @@ def process_stmt( elif isinstance(stmt, ast.Global): logger.debug(f"global declaration of {', '.join(stmt.names)} already bound") elif isinstance(stmt, ast.If): - handle_if(func, compilation_context, builder, stmt, local_sym_tab) + handle_if(func, compilation_context, builder, stmt, local_sym_tab, ret_type) elif isinstance(stmt, ast.Return): did_return = handle_return( func, builder, stmt, local_sym_tab, ret_type, compilation_context @@ -428,6 +423,19 @@ def process_stmt( return did_return +def process_block(func, compilation_context, builder, stmts, local_sym_tab, ret_type): + """Process a nested statement list, such as an if-branch, in the + enclosing function's return type. Stops at the first statement that ends + the block (a return), because whatever follows it in the same list can + never run, and would otherwise be emitted after a terminator.""" + for s in stmts: + if builder.block.is_terminated: + break + process_stmt( + func, compilation_context, builder, s, local_sym_tab, False, ret_type + ) + + # ============================================================================ # SECTION 3: Function Body Processing # ============================================================================ diff --git a/tests/passing_tests/return/if_narrow.py b/tests/passing_tests/return/if_narrow.py new file mode 100644 index 00000000..c8fdfa72 --- /dev/null +++ b/tests/passing_tests/return/if_narrow.py @@ -0,0 +1,22 @@ +# A return inside an if-branch is lowered in the function's declared return +# type (i32 here), not a default i64 that llc rejects. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int32 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int32: + x = 1 + if x == 1: + return 7 + return 0 + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() From f1555a63a698279cf2a9cb1f320062ace0c1f081 Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:19:52 +0530 Subject: [PATCH 4/8] Core: Lower for-over-range and while loops, with break and continue `for i in range(...)` steps a hidden induction counter, allocated in the entry block next to the loop variable, and copies it into the variable each iteration, so rebinding the variable in the body leaves the trip count alone as in Python (and the loop stays visibly bounded for the verifier). The counter is 64-bit, signed unless the bounds make C's arithmetic unsigned. Bounds are evaluated once, before the loop; the step must be a nonzero integer literal, because its sign picks the loop test. `while` re-evaluates its test at the top of each iteration. `break` and `continue` branch through a loop-target stack on the compilation context; outside a loop they are a SyntaxError, as in Python. A loop's else-branch runs on normal exit only. Anything but range() as the iterable is rejected with NotImplementedError. The allocation pass now descends into loop bodies and counts helper temps in loop headers. Checked against clang -O2 on tests/c-form/loops.bpf.c: the constant-bound cases fold to the same ret, and a helper-bounded loop gets the same rotated loop. --- pythonbpf/allocation_pass.py | 105 ++++++++++++++ pythonbpf/context.py | 5 + pythonbpf/functions/functions_pass.py | 192 ++++++++++++++++++++++++-- 3 files changed, 292 insertions(+), 10 deletions(-) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 464164c5..beaf2d08 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -8,6 +8,7 @@ from .expr import VmlinuxHandlerRegistry from pythonbpf.type_deducer import ctypes_to_ir, is_ctypes, IntTy, signedness, byte_size from pythonbpf.expr.type_inference import infer_int_type +from pythonbpf.expr.operators import usual_arithmetic_conversions from pythonbpf.maps import BPFMapType logger = logging.getLogger(__name__) @@ -89,6 +90,110 @@ def handle_ann_assign_allocation(compilation_context, builder, stmt, local_sym_t ) +def handle_for_allocation(compilation_context, builder, stmt, local_sym_tab): + """Handle memory allocation for `for in range(...)`. + + Two slots, both in the entry block so a loop never grows the stack: the + loop variable, and a hidden induction counter the loop actually steps. + Keeping them apart is what makes rebinding the loop variable in the body + leave the trip count alone, as in Python -- and a counter the body cannot + touch is what keeps the loop visibly bounded for the verifier. + """ + start, stop, step = parse_range(stmt) + + # range() yields Python ints; like any undeclared local they are 64-bit, + # signed unless the bounds make C's arithmetic unsigned. + bound_types = [ + infer_int_type(bound, local_sym_tab, compilation_context) + for bound in (start, stop) + if bound is not None + ] + signed = True + if all(ty is not None for ty in bound_types): + common = bound_types[0] + for ty in bound_types[1:]: + common = usual_arithmetic_conversions(common, ty) + signed = signedness(common) + if not signed and step < 0: + raise SyntaxError( + f"range() on line {stmt.lineno} counts down over unsigned bounds; " + f"the counter would wrap instead of stopping" + ) + loop_ty = IntTy(64, signed) + + counter = range_counter_name(stmt) + local_sym_tab[counter] = LocalSymbol( + _allocate_with_type(builder, counter, loop_ty), loop_ty + ) + _bind_name( + compilation_context, + stmt, + stmt.target, + local_sym_tab, + lambda var_name: local_sym_tab.__setitem__( + var_name, + LocalSymbol(_allocate_with_type(builder, var_name, loop_ty), loop_ty), + ), + ) + + +def range_counter_name(stmt): + """Symbol-table name of a range loop's hidden induction counter, unique per + loop so nested loops each get their own.""" + return f"__range_idx_{stmt.lineno}_{stmt.col_offset}" + + +def parse_range(stmt): + """Split `for in range(...)` into (start, stop, step): start is an + expression or None (meaning 0), stop an expression, step a nonzero int. + + step has to be known at compile time, because its sign decides whether the + loop runs while the counter is below stop or above it. + """ + it = stmt.iter + if not ( + isinstance(it, ast.Call) + and isinstance(it.func, ast.Name) + and it.func.id == "range" + ): + raise NotImplementedError( + f"for loop on line {stmt.lineno}: only range(...) can be iterated " + f"so far, got {ast.unparse(it)}" + ) + if not isinstance(stmt.target, ast.Name): + raise NotImplementedError( + f"for loop on line {stmt.lineno}: only a plain name can be the loop " + f"variable, got {ast.unparse(stmt.target)}" + ) + if it.keywords or not 1 <= len(it.args) <= 3: + raise SyntaxError( + f"range() on line {stmt.lineno} takes 1 to 3 positional arguments" + ) + + if len(it.args) == 1: + return None, it.args[0], 1 + start, stop = it.args[0], it.args[1] + if len(it.args) == 2: + return start, stop, 1 + + step_node = it.args[2] + negate = isinstance(step_node, ast.UnaryOp) and isinstance(step_node.op, ast.USub) + literal = step_node.operand if negate else step_node + if not ( + isinstance(literal, ast.Constant) + and isinstance(literal.value, int) + and not isinstance(literal.value, bool) + ): + raise SyntaxError( + f"range() step on line {stmt.lineno} must be an integer literal, " + f"got {ast.unparse(step_node)}" + ) + step = -literal.value if negate else literal.value + if step == 0: + raise ValueError(f"range() arg 3 must not be zero (line {stmt.lineno})") + return start, stop, step + + def annotation_to_ir(annotation, lineno): """IR type for a ctypes annotation, written `c_int32` or `ctypes.c_int32`.""" if isinstance(annotation, ast.Name): diff --git a/pythonbpf/context.py b/pythonbpf/context.py index 9675d7c4..76b80812 100644 --- a/pythonbpf/context.py +++ b/pythonbpf/context.py @@ -82,7 +82,12 @@ def __init__(self, module: ir.Module): # Current function context (optional, if needed globally during function processing) self.current_func = None + # Enclosing loops of the statement being lowered, innermost last, as + # (continue target, break target) blocks. + self.loop_stack: list[tuple[ir.Block, ir.Block]] = [] + def reset(self): """Reset state between functions if necessary, though new context per compile is preferred.""" self.scratch_pool.reset() self.current_func = None + self.loop_stack = [] diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 91f49421..e9b456c7 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -26,6 +26,9 @@ from pythonbpf.allocation_pass import ( handle_assign_allocation, handle_ann_assign_allocation, + handle_for_allocation, + parse_range, + range_counter_name, allocate_temp_pool, create_targets_and_rvals, LocalSymbol, @@ -82,10 +85,11 @@ def count_temps_in_call(call_node, local_sym_tab): return count -def handle_if_allocation( +def handle_block_allocation( compilation_context, builder, stmt, func, ret_type, local_sym_tab ): - """Recursively handle allocations in if/else branches.""" + """Recursively handle allocations in the body and else-branch of an + if, for or while statement.""" if stmt.body: allocate_mem( compilation_context, @@ -117,15 +121,23 @@ def merge_type_counts(count_dict): def update_max_temps_for_stmt(stmt): nonlocal max_temps_needed - if isinstance(stmt, ast.If): + if isinstance(stmt, (ast.If, ast.For, ast.While)): + # A loop header is evaluated like a statement of its own: range() + # bounds once before the loop, a while test on every iteration. + header = {ast.For: "iter", ast.While: "test"}.get(type(stmt)) + if header is not None: + count_temps_in_tree(getattr(stmt, header)) for s in stmt.body: update_max_temps_for_stmt(s) for s in stmt.orelse: update_max_temps_for_stmt(s) return + count_temps_in_tree(stmt) + + def count_temps_in_tree(tree): stmt_temps = {} - for node in ast.walk(stmt): + for node in ast.walk(tree): if isinstance(node, ast.Call): call_temps = count_temps_in_call(node, local_sym_tab) for typ, cnt in call_temps.items(): @@ -136,8 +148,10 @@ def update_max_temps_for_stmt(stmt): update_max_temps_for_stmt(stmt) # Handle allocations - if isinstance(stmt, ast.If): - handle_if_allocation( + if isinstance(stmt, ast.For): + handle_for_allocation(compilation_context, builder, stmt, local_sym_tab) + if isinstance(stmt, (ast.If, ast.For, ast.While)): + handle_block_allocation( compilation_context, builder, stmt, @@ -353,6 +367,158 @@ def handle_if(func, compilation_context, builder, stmt, local_sym_tab, ret_type) builder.position_at_end(merge_block) +def _lower_loop( + func, + compilation_context, + builder, + stmt, + local_sym_tab, + ret_type, + body_block, + continue_block, + end_block, + else_block, +): + """What for and while share once their header is emitted: the body, with + `continue` and `break` bound to this loop, falling through to + continue_block; then the else-branch, which runs only when the loop ends + without a break, so it sits between the exit test and end_block.""" + builder.position_at_end(body_block) + compilation_context.loop_stack.append((continue_block, end_block)) + try: + process_block( + func, compilation_context, builder, stmt.body, local_sym_tab, ret_type + ) + finally: + compilation_context.loop_stack.pop() + if not builder.block.is_terminated: + builder.branch(continue_block) + + if else_block is not None: + # Outside this loop's scope: a break here leaves the enclosing loop. + builder.position_at_end(else_block) + process_block( + func, compilation_context, builder, stmt.orelse, local_sym_tab, ret_type + ) + if not builder.block.is_terminated: + builder.branch(end_block) + + builder.position_at_end(end_block) + + +def handle_while(func, compilation_context, builder, stmt, local_sym_tab, ret_type): + """Handle `while test: body [else: orelse]`. The test is re-evaluated at + the top of every iteration, and is where `continue` goes.""" + cond_block = func.append_basic_block(name="while.cond") + body_block = func.append_basic_block(name="while.body") + else_block = func.append_basic_block(name="while.else") if stmt.orelse else None + end_block = func.append_basic_block(name="while.end") + + builder.branch(cond_block) + builder.position_at_end(cond_block) + cond = handle_cond(func, compilation_context, builder, stmt.test, local_sym_tab) + builder.cbranch(cond, body_block, else_block or end_block) + + _lower_loop( + func, + compilation_context, + builder, + stmt, + local_sym_tab, + ret_type, + body_block, + cond_block, + end_block, + else_block, + ) + + +def handle_for(func, compilation_context, builder, stmt, local_sym_tab, ret_type): + """Handle `for name in range(...): body [else: orelse]`. + + The allocation pass made a hidden induction counter (typed from the + bounds) next to the loop variable. The bounds are evaluated once, before + the loop, as Python does; each iteration copies the counter into the loop + variable, and `continue` goes to the step, not straight back to the test. + """ + start, stop, step = parse_range(stmt) + counter = local_sym_tab[range_counter_name(stmt)] + loop_ty = counter.ir_type + + def bound(expr): + val, ty = get_typed_operand( + func, compilation_context, expr, builder, local_sym_tab + ) + if val is None or not isinstance(ty, ir.IntType): + raise SyntaxError( + f"range() bound on line {stmt.lineno} must be an integer: " + f"{ast.unparse(expr)}" + ) + return convert(builder, val, ty, loop_ty) + + start_val = ir.Constant(loop_ty, 0) if start is None else bound(start) + stop_val = bound(stop) + builder.store(start_val, counter.var) + + target = local_sym_tab[stmt.target.id] + if target.var is None: + raise SyntaxError( + f"cannot use '{stmt.target.id}' as a loop variable: it is the " + f"context parameter" + ) + + cond_block = func.append_basic_block(name="for.cond") + body_block = func.append_basic_block(name="for.body") + inc_block = func.append_basic_block(name="for.inc") + else_block = func.append_basic_block(name="for.else") if stmt.orelse else None + end_block = func.append_basic_block(name="for.end") + + builder.branch(cond_block) + builder.position_at_end(cond_block) + idx = builder.load(counter.var) + # Counting up runs while below stop, counting down while above it. + predicate = "<" if step > 0 else ">" + compare = builder.icmp_signed if signedness(loop_ty) else builder.icmp_unsigned + builder.cbranch( + compare(predicate, idx, stop_val), body_block, else_block or end_block + ) + + # The loop variable is bound to the counter's value, per iteration. + builder.position_at_end(body_block) + builder.store( + convert(builder, builder.load(counter.var), loop_ty, target.ir_type), + target.var, + ) + + builder.position_at_end(inc_block) + next_idx = builder.add(builder.load(counter.var), ir.Constant(loop_ty, step)) + builder.store(next_idx, counter.var) + builder.branch(cond_block) + + _lower_loop( + func, + compilation_context, + builder, + stmt, + local_sym_tab, + ret_type, + body_block, + inc_block, + end_block, + else_block, + ) + + +def handle_loop_jump(compilation_context, builder, stmt): + """Handle `break` and `continue`: branch to the innermost loop's exit or + next-iteration block.""" + keyword = "break" if isinstance(stmt, ast.Break) else "continue" + if not compilation_context.loop_stack: + raise SyntaxError(f"'{keyword}' outside loop (line {stmt.lineno})") + continue_block, break_block = compilation_context.loop_stack[-1] + builder.branch(break_block if keyword == "break" else continue_block) + + def handle_return( func, builder, stmt, local_sym_tab, ret_type, compilation_context=None ): @@ -409,6 +575,12 @@ def process_stmt( logger.debug(f"global declaration of {', '.join(stmt.names)} already bound") elif isinstance(stmt, ast.If): handle_if(func, compilation_context, builder, stmt, local_sym_tab, ret_type) + elif isinstance(stmt, ast.While): + handle_while(func, compilation_context, builder, stmt, local_sym_tab, ret_type) + elif isinstance(stmt, ast.For): + handle_for(func, compilation_context, builder, stmt, local_sym_tab, ret_type) + elif isinstance(stmt, (ast.Break, ast.Continue)): + handle_loop_jump(compilation_context, builder, stmt) elif isinstance(stmt, ast.Return): did_return = handle_return( func, builder, stmt, local_sym_tab, ret_type, compilation_context @@ -424,10 +596,10 @@ def process_stmt( def process_block(func, compilation_context, builder, stmts, local_sym_tab, ret_type): - """Process a nested statement list, such as an if-branch, in the - enclosing function's return type. Stops at the first statement that ends - the block (a return), because whatever follows it in the same list can - never run, and would otherwise be emitted after a terminator.""" + """Process a nested statement list (an if-branch or loop body). Stops at + the first statement that ends the block -- break, continue or return -- + because whatever follows it in the same list can never run, and would + otherwise be emitted after a terminator.""" for s in stmts: if builder.block.is_terminated: break From bae742b45b02dacb6b1c90abb4a0a25a8a1fae3d Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:19:52 +0530 Subject: [PATCH 5/8] Tests: Loops pass now, plus the cases the lowering promises - passing_tests/loops/: the seven range/while/break/continue/nested tests move over from failing_tests/, joined by rebinding the loop variable, a negative step, loop else-branches, and a helper-bounded loop over a @bpfglobal that survives -O2 as a real loop for the verifier. - failing_tests/loops/: break outside a loop, and a non-literal range step. - for_map_items stays xfail: map iteration needs its own design. - c-form/loops.bpf.c: the clang reference the lowering was checked against. --- tests/c-form/loops.bpf.c | 97 +++++++++++++++++++ .../failing_tests/loops/break_outside_loop.py | 20 ++++ .../loops/range_variable_step.py | 21 ++++ .../loops/for_break.py | 0 .../loops/for_continue.py | 0 .../loops/for_range_start_stop_step.py | 0 .../loops/for_range_sum.py | 0 tests/passing_tests/loops/loop_else.py | 32 ++++++ .../loops/nested_for.py | 0 .../passing_tests/loops/range_global_bound.py | 37 +++++++ .../loops/range_negative_step.py | 21 ++++ tests/passing_tests/loops/rebind_loop_var.py | 23 +++++ .../loops/while_basic.py | 0 .../loops/while_true_break.py | 0 tests/test_config.toml | 24 +---- 15 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 tests/c-form/loops.bpf.c create mode 100644 tests/failing_tests/loops/break_outside_loop.py create mode 100644 tests/failing_tests/loops/range_variable_step.py rename tests/{failing_tests => passing_tests}/loops/for_break.py (100%) rename tests/{failing_tests => passing_tests}/loops/for_continue.py (100%) rename tests/{failing_tests => passing_tests}/loops/for_range_start_stop_step.py (100%) rename tests/{failing_tests => passing_tests}/loops/for_range_sum.py (100%) create mode 100644 tests/passing_tests/loops/loop_else.py rename tests/{failing_tests => passing_tests}/loops/nested_for.py (100%) create mode 100644 tests/passing_tests/loops/range_global_bound.py create mode 100644 tests/passing_tests/loops/range_negative_step.py create mode 100644 tests/passing_tests/loops/rebind_loop_var.py rename tests/{failing_tests => passing_tests}/loops/while_basic.py (100%) rename tests/{failing_tests => passing_tests}/loops/while_true_break.py (100%) diff --git a/tests/c-form/loops.bpf.c b/tests/c-form/loops.bpf.c new file mode 100644 index 00000000..4d0eed4e --- /dev/null +++ b/tests/c-form/loops.bpf.c @@ -0,0 +1,97 @@ +/* Reference for loops. One program per shape the Python tests use, written + * the way PythonBPF lowers them: a `for i in range(...)` keeps a hidden + * induction counter separate from `i`, so rebinding `i` in the body cannot + * change the trip count. The constant-bound cases fold to a `ret` at -O2; + * `dyn_helper` reads its bound from a .data global and calls a helper per + * iteration, so a real loop (and the verifier's bounded-loop check) survives. + * (A helper-free sum over a runtime bound folds to a closed form that needs + * the __multi3 libcall, which BPF lacks -- so it is not a useful reference.) */ +#define SEC(name) __attribute__((section(name), used)) +typedef unsigned int __u32; +typedef unsigned long long __u64; +typedef long long __s64; + +char LICENSE[] SEC("license") = "GPL"; + +__s64 n = 10; + +/* for i in range(10): total = total + 1 */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 range_sum(void *ctx) +{ + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 1) { + __s64 i = idx; + total = total + 1; + } + return total; +} + +/* for i in range(0, 10, 2): total = total + i */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 range_step(void *ctx) +{ + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 2) { + __s64 i = idx; + total = total + i; + } + return total; +} + +/* while i < 10: i = i + 1 */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 while_basic(void *ctx) +{ + __s64 i = 0; + while (i < 10) + i = i + 1; + return i; +} + +/* while True: total += 1; if total == 10: break */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 while_true_break(void *ctx) +{ + __s64 total = 0; + while (1) { + total = total + 1; + if (total == 10) + break; + } + return total; +} + +/* for i in range(10): if i % 2 == 0: continue; total = total + i */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 for_continue(void *ctx) +{ + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 1) { + __s64 i = idx; + if (i % 2 == 0) + continue; + total = total + i; + } + return total; +} + +static __u32 (*bpf_get_prandom_u32)(void) = (void *)7; + +/* for i in range(stop): total = total + random() -- a helper call per + * iteration keeps the loop from folding, so the verifier sees a real loop. + * The clamp is what makes it bounded: n is writable from userspace, so + * unclamped the verifier walks iterations until E2BIG. */ +SEC("tracepoint/syscalls/sys_enter_execve") +__s64 dyn_helper(void *ctx) +{ + __s64 total = 0; + __s64 stop = n; + if (stop > 64) + stop = 64; + for (__s64 idx = 0; idx < stop; idx += 1) { + __s64 i = idx; + total = total + bpf_get_prandom_u32(); + } + return total; +} diff --git a/tests/failing_tests/loops/break_outside_loop.py b/tests/failing_tests/loops/break_outside_loop.py new file mode 100644 index 00000000..3208f915 --- /dev/null +++ b/tests/failing_tests/loops/break_outside_loop.py @@ -0,0 +1,20 @@ +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + if total == 0: + break # noqa: F701 -- the point of the test + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/loops/range_variable_step.py b/tests/failing_tests/loops/range_variable_step.py new file mode 100644 index 00000000..1e94f8f9 --- /dev/null +++ b/tests/failing_tests/loops/range_variable_step.py @@ -0,0 +1,21 @@ +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + step: c_int64 = 2 + for i in range(0, 10, step): + total = total + i + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/loops/for_break.py b/tests/passing_tests/loops/for_break.py similarity index 100% rename from tests/failing_tests/loops/for_break.py rename to tests/passing_tests/loops/for_break.py diff --git a/tests/failing_tests/loops/for_continue.py b/tests/passing_tests/loops/for_continue.py similarity index 100% rename from tests/failing_tests/loops/for_continue.py rename to tests/passing_tests/loops/for_continue.py diff --git a/tests/failing_tests/loops/for_range_start_stop_step.py b/tests/passing_tests/loops/for_range_start_stop_step.py similarity index 100% rename from tests/failing_tests/loops/for_range_start_stop_step.py rename to tests/passing_tests/loops/for_range_start_stop_step.py diff --git a/tests/failing_tests/loops/for_range_sum.py b/tests/passing_tests/loops/for_range_sum.py similarity index 100% rename from tests/failing_tests/loops/for_range_sum.py rename to tests/passing_tests/loops/for_range_sum.py diff --git a/tests/passing_tests/loops/loop_else.py b/tests/passing_tests/loops/loop_else.py new file mode 100644 index 00000000..80b00619 --- /dev/null +++ b/tests/passing_tests/loops/loop_else.py @@ -0,0 +1,32 @@ +# A loop's else-branch runs when the loop ends without a break: the for loop +# never breaks, so its else adds 100; the while loop breaks, so its else is +# skipped. Returns 3 + 100 + 1 = 104. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(3): + if i == 7: + break + total = total + 1 + else: + total = total + 100 + while total < 1000: + total = total + 1 + break + else: + total = total + 1000 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/loops/nested_for.py b/tests/passing_tests/loops/nested_for.py similarity index 100% rename from tests/failing_tests/loops/nested_for.py rename to tests/passing_tests/loops/nested_for.py diff --git a/tests/passing_tests/loops/range_global_bound.py b/tests/passing_tests/loops/range_global_bound.py new file mode 100644 index 00000000..0b395479 --- /dev/null +++ b/tests/passing_tests/loops/range_global_bound.py @@ -0,0 +1,37 @@ +# The bound is a @bpfglobal and the body calls a helper, so opt cannot fold +# the loop away: this is the case that puts a real bounded loop in front of +# the verifier. Reference: dyn_helper in tests/c-form/loops.bpf.c. +# +# The clamp is required, in C as here: userspace can write any value to a +# .data global, so without it the verifier assumes a bound up to 2**63 and +# walks iterations until it hits its 1M-instruction limit (E2BIG). +from pythonbpf import bpf, section, bpfglobal, compile +from pythonbpf.helper import random +from ctypes import c_void_p, c_int64 + + +@bpf +@bpfglobal +def n() -> c_int64: + return c_int64(10) + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + stop = n + if stop > 64: + stop = 64 + for i in range(stop): + total = total + random() + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/range_negative_step.py b/tests/passing_tests/loops/range_negative_step.py new file mode 100644 index 00000000..fa89739c --- /dev/null +++ b/tests/passing_tests/loops/range_negative_step.py @@ -0,0 +1,21 @@ +# Counting down: 10 + 7 + 4 + 1 = 22. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(10, 0, -3): + total = total + i + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/rebind_loop_var.py b/tests/passing_tests/loops/rebind_loop_var.py new file mode 100644 index 00000000..b994f213 --- /dev/null +++ b/tests/passing_tests/loops/rebind_loop_var.py @@ -0,0 +1,23 @@ +# Rebinding the loop variable does not change the trip count: range() steps a +# counter of its own, as in Python. Returns 10. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(10): + total = total + 1 + i = 100 # noqa: F841 -- rebinding is the point of the test + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/loops/while_basic.py b/tests/passing_tests/loops/while_basic.py similarity index 100% rename from tests/failing_tests/loops/while_basic.py rename to tests/passing_tests/loops/while_basic.py diff --git a/tests/failing_tests/loops/while_true_break.py b/tests/passing_tests/loops/while_true_break.py similarity index 100% rename from tests/failing_tests/loops/while_true_break.py rename to tests/passing_tests/loops/while_true_break.py diff --git a/tests/test_config.toml b/tests/test_config.toml index 85dabc32..309caff0 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -38,29 +38,11 @@ "failing_tests/globals_parameter_and_global.py" = {reason = "A parameter may not be declared global (Python: name is parameter and global)", level = "ir"} -# Loop constructs: ast.For/ast.While are not handled by the statement dispatcher -# (pythonbpf/functions/functions_pass.py process_stmt only matches Expr, Assign, -# AugAssign, If, Return) and the pre-pass variable scan does not descend into -# loop bodies. A loop is silently skipped rather than erroring, so anything -# that reads a variable assigned in/around the loop afterward blows up later -# with "Undefined variable ..." at the `return` statement. These document the -# intended surface for when loops are implemented. -"failing_tests/loops/for_range_sum.py" = {reason = "for/while loops not implemented: ast.For is silently skipped by the statement dispatcher", level = "ir"} +"failing_tests/loops/for_map_items.py" = {reason = "Loops iterate range() only: no sugar over bpf_for_each_map_elem() (a callback subprogram plus a way to capture outer locals) exists yet", level = "ir"} -"failing_tests/loops/for_range_start_stop_step.py" = {reason = "for/while loops not implemented: range() with start/stop/step is not supported", level = "ir"} - -"failing_tests/loops/while_basic.py" = {reason = "for/while loops not implemented: ast.While is silently skipped by the statement dispatcher", level = "ir"} - -"failing_tests/loops/while_true_break.py" = {reason = "for/while loops not implemented: while True with a break-based exit is not supported", level = "ir"} - -"failing_tests/loops/nested_for.py" = {reason = "for/while loops not implemented: nested for loops are not supported", level = "ir"} - -"failing_tests/loops/for_break.py" = {reason = "for/while loops not implemented: break is not supported", level = "ir"} - -"failing_tests/loops/for_continue.py" = {reason = "for/while loops not implemented: continue is not supported", level = "ir"} - -"failing_tests/loops/for_map_items.py" = {reason = "for/while loops not implemented: no sugar over bpf_for_each_map_elem()-style map iteration exists yet", level = "ir"} +"failing_tests/loops/break_outside_loop.py" = {reason = "`break` outside a loop is a SyntaxError in Python, and a compile error here", level = "ir"} +"failing_tests/loops/range_variable_step.py" = {reason = "range() step must be an integer literal: its sign decides the loop test", level = "ir"} "kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py" = {reason = "RingBuffer reserve/typed record/discard workflow is planned but not implemented yet", level = "ir"} From 3a6277e0bfab2f3b30347a1bd219c25bc2635c0b Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 00:37:05 +0530 Subject: [PATCH 6/8] Tests: Indent the loop C reference with spaces The remove-tabs pre-commit hook rejects tab indentation outside docs/ and Makefiles, which failed the Format job. --- tests/c-form/loops.bpf.c | 80 ++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/c-form/loops.bpf.c b/tests/c-form/loops.bpf.c index 4d0eed4e..e66be7ee 100644 --- a/tests/c-form/loops.bpf.c +++ b/tests/c-form/loops.bpf.c @@ -19,61 +19,61 @@ __s64 n = 10; SEC("tracepoint/syscalls/sys_enter_execve") __s64 range_sum(void *ctx) { - __s64 total = 0; - for (__s64 idx = 0; idx < 10; idx += 1) { - __s64 i = idx; - total = total + 1; - } - return total; + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 1) { + __s64 i = idx; + total = total + 1; + } + return total; } /* for i in range(0, 10, 2): total = total + i */ SEC("tracepoint/syscalls/sys_enter_execve") __s64 range_step(void *ctx) { - __s64 total = 0; - for (__s64 idx = 0; idx < 10; idx += 2) { - __s64 i = idx; - total = total + i; - } - return total; + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 2) { + __s64 i = idx; + total = total + i; + } + return total; } /* while i < 10: i = i + 1 */ SEC("tracepoint/syscalls/sys_enter_execve") __s64 while_basic(void *ctx) { - __s64 i = 0; - while (i < 10) - i = i + 1; - return i; + __s64 i = 0; + while (i < 10) + i = i + 1; + return i; } /* while True: total += 1; if total == 10: break */ SEC("tracepoint/syscalls/sys_enter_execve") __s64 while_true_break(void *ctx) { - __s64 total = 0; - while (1) { - total = total + 1; - if (total == 10) - break; - } - return total; + __s64 total = 0; + while (1) { + total = total + 1; + if (total == 10) + break; + } + return total; } /* for i in range(10): if i % 2 == 0: continue; total = total + i */ SEC("tracepoint/syscalls/sys_enter_execve") __s64 for_continue(void *ctx) { - __s64 total = 0; - for (__s64 idx = 0; idx < 10; idx += 1) { - __s64 i = idx; - if (i % 2 == 0) - continue; - total = total + i; - } - return total; + __s64 total = 0; + for (__s64 idx = 0; idx < 10; idx += 1) { + __s64 i = idx; + if (i % 2 == 0) + continue; + total = total + i; + } + return total; } static __u32 (*bpf_get_prandom_u32)(void) = (void *)7; @@ -85,13 +85,13 @@ static __u32 (*bpf_get_prandom_u32)(void) = (void *)7; SEC("tracepoint/syscalls/sys_enter_execve") __s64 dyn_helper(void *ctx) { - __s64 total = 0; - __s64 stop = n; - if (stop > 64) - stop = 64; - for (__s64 idx = 0; idx < stop; idx += 1) { - __s64 i = idx; - total = total + bpf_get_prandom_u32(); - } - return total; + __s64 total = 0; + __s64 stop = n; + if (stop > 64) + stop = 64; + for (__s64 idx = 0; idx < stop; idx += 1) { + __s64 i = idx; + total = total + bpf_get_prandom_u32(); + } + return total; } From 1225e94c2049d6376561a0259c3d4dcb7163a2f2 Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 23:36:43 +0530 Subject: [PATCH 7/8] Core: Fix loop edge cases the lowering got wrong - `pass` is accepted as a statement, the natural body of a loop run for its side effects. - Falling off the end of a function returns 0 in the function's own return type. After `while True` that block is unreachable, but a c_int32 function still emitted `ret i64 0`, which llc rejected. - A range() step other than 1 could wrap the counter past INT64_MAX or UINT64_MAX back inside the range and loop forever. The step block now ends the loop when the distance left to stop, exact as an unsigned number, is no longer than the step. That is a normal exit, so the else-branch runs. Steps of 1 cannot overshoot and keep the plain loop, so the helper-bounded loop the verifier walks is unchanged. - A step of 2**64 or more no longer truncates to 0: it is a compile error, since it does not fit the counter. Steps from 2**63 up are emitted by their bits. - The counter's signedness folds each bound into a signed 64-bit type rather than joining the bounds alone, so only a c_uint64 bound makes it unsigned: range(-1, n) over a c_uint32 n runs 4 times, as in C with an __s64 counter. --- pythonbpf/allocation_pass.py | 14 +++++++++--- pythonbpf/functions/functions_pass.py | 32 +++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index beaf2d08..0d81d3fa 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -102,7 +102,10 @@ def handle_for_allocation(compilation_context, builder, stmt, local_sym_tab): start, stop, step = parse_range(stmt) # range() yields Python ints; like any undeclared local they are 64-bit, - # signed unless the bounds make C's arithmetic unsigned. + # signed unless the bounds make C's arithmetic unsigned. That arithmetic + # is the counter's against each bound, so it starts from a signed 64-bit + # counter, and only an unsigned 64-bit bound turns it unsigned: every + # narrower unsigned bound fits in the signed counter, as in C. bound_types = [ infer_int_type(bound, local_sym_tab, compilation_context) for bound in (start, stop) @@ -110,8 +113,8 @@ def handle_for_allocation(compilation_context, builder, stmt, local_sym_tab): ] signed = True if all(ty is not None for ty in bound_types): - common = bound_types[0] - for ty in bound_types[1:]: + common = IntTy(64, True) + for ty in bound_types: common = usual_arithmetic_conversions(common, ty) signed = signedness(common) if not signed and step < 0: @@ -191,6 +194,11 @@ def parse_range(stmt): step = -literal.value if negate else literal.value if step == 0: raise ValueError(f"range() arg 3 must not be zero (line {stmt.lineno})") + if abs(step) >= 1 << 64: + raise ValueError( + f"range() step on line {stmt.lineno} does not fit the 64-bit " + f"loop counter: {ast.unparse(step_node)}" + ) return start, stop, step diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index e9b456c7..3dbeed5a 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -491,7 +491,22 @@ def bound(expr): ) builder.position_at_end(inc_block) - next_idx = builder.add(builder.load(counter.var), ir.Constant(loop_ty, step)) + idx = builder.load(counter.var) + if abs(step) > 1: + # The body only runs with the counter strictly inside the range, so + # the distance left to stop is exact as an unsigned number. A step no + # shorter than that distance ends the loop here, before the add can + # wrap the counter back inside the range (a step of 1 cannot + # overshoot stop, so it keeps the plain loop). + left = builder.sub(stop_val, idx) if step > 0 else builder.sub(idx, stop_val) + step_in_range = func.append_basic_block(name="for.step") + builder.cbranch( + builder.icmp_unsigned(">", left, ir.Constant(loop_ty, _wrap64(abs(step)))), + step_in_range, + else_block or end_block, + ) + builder.position_at_end(step_in_range) + next_idx = builder.add(idx, ir.Constant(loop_ty, _wrap64(step))) builder.store(next_idx, counter.var) builder.branch(cond_block) @@ -509,6 +524,13 @@ def bound(expr): ) +def _wrap64(value): + """`value` modulo 2**64, as the signed Python int LLVM parses for an i64: + a step of 2**63 or more (or its negation) is written by its bits.""" + value %= 1 << 64 + return value - (1 << 64) if value >= 1 << 63 else value + + def handle_loop_jump(compilation_context, builder, stmt): """Handle `break` and `continue`: branch to the innermost loop's exit or next-iteration block.""" @@ -581,6 +603,8 @@ def process_stmt( handle_for(func, compilation_context, builder, stmt, local_sym_tab, ret_type) elif isinstance(stmt, (ast.Break, ast.Continue)): handle_loop_jump(compilation_context, builder, stmt) + elif isinstance(stmt, ast.Pass): + pass elif isinstance(stmt, ast.Return): did_return = handle_return( func, builder, stmt, local_sym_tab, ret_type, compilation_context @@ -720,7 +744,11 @@ def process_func_body( ) if not did_return: - builder.ret(ir.Constant(ir.IntType(64), 0)) + # Falling off the end returns 0, in the function's own return type: + # after `while True` this block is unreachable, but it still has to + # type-check. + default_ty = ret_type if isinstance(ret_type, ir.IntType) else ir.IntType(64) + builder.ret(ir.Constant(default_ty, 0)) def process_bpf_chunk(func_node, compilation_context, return_type): From e9e067e8674170ce7a1561e0a98cba9dd780e1e3 Mon Sep 17 00:00:00 2001 From: Varun R Mallya Date: Fri, 25 Sep 2026 23:36:45 +0530 Subject: [PATCH 8/8] Tests: Run loop programs on the host, plus adversarial loop cases Levels 1 and 2 only prove a loop compiles; one can still run the wrong number of times, or forever. test_loops_run.py JIT-compiles each helper-free passing_tests/loops case on the host (framework/host_jit.py, in a subprocess with a timeout) and checks its return value against what CPython returns for the same body. - passing_tests/loops/: pass bodies, `while True` exited by a return in a c_int32 function, step overflow past INT64_MAX, INT64_MIN and UINT64_MAX, a c_uint32 stop with a negative start, and loop variables that must stay one local (a nested same-name loop, a read after the loop). - failing_tests/loops/range_step_too_wide.py: a 2**64 step is a compile error. - reuse_loop_var_signedness.py is xfail in test_loops_run.py: every local gets one slot typed by its first binding, so a second loop's `i` inherits the first loop's unsigned type. `x = n_u64; x = -3` does the same, so the fix is general, not loop-specific. --- .../loops/range_step_too_wide.py | 23 ++++++ tests/framework/host_jit.py | 54 ++++++++++++++ tests/passing_tests/loops/loop_var_shared.py | 29 ++++++++ tests/passing_tests/loops/pass_body.py | 25 +++++++ .../loops/range_step_overflow_signed.py | 31 ++++++++ .../loops/range_step_overflow_unsigned.py | 23 ++++++ .../loops/range_u32_stop_negative_start.py | 25 +++++++ .../loops/reuse_loop_var_signedness.py | 28 ++++++++ .../loops/while_true_return_i32.py | 24 +++++++ tests/test_config.toml | 2 + tests/test_loops_run.py | 71 +++++++++++++++++++ 11 files changed, 335 insertions(+) create mode 100644 tests/failing_tests/loops/range_step_too_wide.py create mode 100644 tests/framework/host_jit.py create mode 100644 tests/passing_tests/loops/loop_var_shared.py create mode 100644 tests/passing_tests/loops/pass_body.py create mode 100644 tests/passing_tests/loops/range_step_overflow_signed.py create mode 100644 tests/passing_tests/loops/range_step_overflow_unsigned.py create mode 100644 tests/passing_tests/loops/range_u32_stop_negative_start.py create mode 100644 tests/passing_tests/loops/reuse_loop_var_signedness.py create mode 100644 tests/passing_tests/loops/while_true_return_i32.py create mode 100644 tests/test_loops_run.py diff --git a/tests/failing_tests/loops/range_step_too_wide.py b/tests/failing_tests/loops/range_step_too_wide.py new file mode 100644 index 00000000..4e1fab6f --- /dev/null +++ b/tests/failing_tests/loops/range_step_too_wide.py @@ -0,0 +1,23 @@ +# A step of 2**64 is a valid Python range() step (it yields just 0), but it +# does not fit the 64-bit counter: it truncates to 0 and the loop never ends. +# It should be a compile error. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(0, 10, 18446744073709551616): + total = total + 1 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/framework/host_jit.py b/tests/framework/host_jit.py new file mode 100644 index 00000000..e865c1da --- /dev/null +++ b/tests/framework/host_jit.py @@ -0,0 +1,54 @@ +"""Run a BPF function's IR on the host, to check what it computes. + +Only for helper-free programs: the IR is retargeted to the host and JIT +compiled, and the function is called with a NULL ctx. A helper call is an +inttoptr to a BPF helper id, which on the host would jump to a bogus address. + +Run as a script (`python -m tests.framework.host_jit file.ll func`), so the +caller can put a timeout on a program that never terminates. +""" + +import ctypes +import re +import subprocess +import sys +from pathlib import Path + +import llvmlite.binding as llvm + + +def run_function(ll_text: str, func_name: str) -> int: + ll_text = re.sub(r'^target (triple|datalayout) = ".*"$', "", ll_text, flags=re.M) + llvm.initialize_native_target() + llvm.initialize_native_asmprinter() + mod = llvm.parse_assembly(ll_text) + mod.verify() + tm = llvm.Target.from_default_triple().create_target_machine() + engine = llvm.create_mcjit_compiler(mod, tm) + engine.finalize_object() + + ret = re.search(rf'define [^\n]*?\bi(\d+) @"?{re.escape(func_name)}"?\(', ll_text) + ret_ty = {64: ctypes.c_int64, 32: ctypes.c_int32}[int(ret.group(1))] + func = ctypes.CFUNCTYPE(ret_ty, ctypes.c_void_p)( + engine.get_function_address(func_name) + ) + return func(None) + + +def run_in_subprocess(ll_path, func_name: str, timeout: float) -> int: + """Return value of func_name(NULL). Raises subprocess.TimeoutExpired if it + does not return within timeout, CalledProcessError if it fails to run.""" + out = subprocess.run( + [sys.executable, "-m", "tests.framework.host_jit", str(ll_path), func_name], + cwd=Path(__file__).parents[2], + capture_output=True, + text=True, + timeout=timeout, + check=True, + ) + return int(out.stdout.strip()) + + +if __name__ == "__main__": + with open(sys.argv[1]) as f: + print(run_function(f.read(), sys.argv[2])) diff --git a/tests/passing_tests/loops/loop_var_shared.py b/tests/passing_tests/loops/loop_var_shared.py new file mode 100644 index 00000000..13eaab63 --- /dev/null +++ b/tests/passing_tests/loops/loop_var_shared.py @@ -0,0 +1,29 @@ +# Where a loop variable is visible outside its loop it stays one local, as in +# Python: the outer body reads the `i` the inner loop left behind, and the +# return reads the last loop's final `i`. Each outer iteration adds 0 + 1 from +# the inner loop and 1 after it, the last loop adds 50, and the return adds 4: +# 6 + 50 + 4 = 60. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(3): + for i in range(2): + total = total + i + total = total + i + for i in range(5): + total = total + 10 + return total + i + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/pass_body.py b/tests/passing_tests/loops/pass_body.py new file mode 100644 index 00000000..35c4ce45 --- /dev/null +++ b/tests/passing_tests/loops/pass_body.py @@ -0,0 +1,25 @@ +# `pass` is the natural body of a loop run only for its side effects, or of an +# else-branch kept for symmetry. Returns 3: the loop variable keeps its last +# value after the loop, as in Python. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + i: c_int64 = 0 + for i in range(4): + pass + else: + pass + return i + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/range_step_overflow_signed.py b/tests/passing_tests/loops/range_step_overflow_signed.py new file mode 100644 index 00000000..9dc603f3 --- /dev/null +++ b/tests/passing_tests/loops/range_step_overflow_signed.py @@ -0,0 +1,31 @@ +# range() stops once the next value would pass stop; it never wraps. Here the +# first step overshoots INT64_MAX, so the counter must not wrap to a negative +# value that is still below stop; the second loop does the same below +# INT64_MIN. Leaving that way is a normal exit, so each else-branch runs. +# Returns 1 + 10 + 1 + 10 = 22. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + total: c_int64 = 0 + for i in range(9223372036854775806, 9223372036854775807, 2): + total = total + 1 + else: + total = total + 10 + for j in range(-9223372036854775807, -9223372036854775808, -3): + total = total + 1 + else: + total = total + 10 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/range_step_overflow_unsigned.py b/tests/passing_tests/loops/range_step_overflow_unsigned.py new file mode 100644 index 00000000..32063efb --- /dev/null +++ b/tests/passing_tests/loops/range_step_overflow_unsigned.py @@ -0,0 +1,23 @@ +# The unsigned version of range_step_overflow_signed: the step goes past +# UINT64_MAX, and the counter must not wrap to 0, which is below stop. Returns 1. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + stop: c_uint64 = 18446744073709551615 + total: c_int64 = 0 + for i in range(18446744073709551614, stop, 2): + total = total + 1 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/range_u32_stop_negative_start.py b/tests/passing_tests/loops/range_u32_stop_negative_start.py new file mode 100644 index 00000000..56b2e22f --- /dev/null +++ b/tests/passing_tests/loops/range_u32_stop_negative_start.py @@ -0,0 +1,25 @@ +# The counter is 64-bit, and a 64-bit signed value holds every u32, so by +# C's own rules a u32 stop does not make the loop unsigned: in +# `for (__s64 idx = -1; idx < n; idx++)` n widens to __s64. Returns 4, as +# range(-1, 3) does in Python. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint32 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + n: c_uint32 = 3 + total: c_int64 = 0 + for i in range(-1, n): + total = total + 1 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/reuse_loop_var_signedness.py b/tests/passing_tests/loops/reuse_loop_var_signedness.py new file mode 100644 index 00000000..1e4a193e --- /dev/null +++ b/tests/passing_tests/loops/reuse_loop_var_signedness.py @@ -0,0 +1,28 @@ +# Two unrelated loops that reuse `i`. The first counts over an unsigned +# bound, the second over a signed one. The second loop's `i < 0` has to see +# -3, -2 and -1, not the unsigned slot the first loop left behind. +# Returns 3 + 3 * 100 = 303. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int64: + n: c_uint64 = 3 + total: c_int64 = 0 + for i in range(n): + total = total + 1 + for i in range(-3, 3): + if i < 0: + total = total + 100 + return total + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/loops/while_true_return_i32.py b/tests/passing_tests/loops/while_true_return_i32.py new file mode 100644 index 00000000..7cb30e93 --- /dev/null +++ b/tests/passing_tests/loops/while_true_return_i32.py @@ -0,0 +1,24 @@ +# The only way out of `while True` is the return in its body, so the block +# after the loop is unreachable. Whatever closes it off has to match the +# function's c_int32 return type. Returns 6. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int32 + + +@bpf +@section("tracepoint/syscalls/sys_enter_execve") +def hello(ctx: c_void_p) -> c_int32: + i: c_int32 = 0 + while True: + i = i + 1 + if i > 5: + return i + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/test_config.toml b/tests/test_config.toml index 309caff0..453650f2 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -44,6 +44,8 @@ "failing_tests/loops/range_variable_step.py" = {reason = "range() step must be an integer literal: its sign decides the loop test", level = "ir"} +"failing_tests/loops/range_step_too_wide.py" = {reason = "range() step must fit the 64-bit counter: 2**64 truncates to 0 and never terminates", level = "ir"} + "kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py" = {reason = "RingBuffer reserve/typed record/discard workflow is planned but not implemented yet", level = "ir"} "kernel_selftest_equivalent/vmlinux/perf_skip.py" = {reason = "Nested struct field access (ctx.regs.ip) not supported; one level such as ctx.sample_period works", level = "ir"} diff --git a/tests/test_loops_run.py b/tests/test_loops_run.py new file mode 100644 index 00000000..72e21d47 --- /dev/null +++ b/tests/test_loops_run.py @@ -0,0 +1,71 @@ +""" +Loops: what they compute. + +Levels 1 and 2 only prove the passing_tests/loops cases compile. A loop that +compiles can still run the wrong number of times, or forever, so this test +JIT-compiles each helper-free case on the host and checks the value it returns +against what CPython returns for the same function body. Every expected value +below was checked that way. +""" + +import subprocess +from pathlib import Path + +import pytest + +from tests.framework.compiler import run_ir_generation +from tests.framework.host_jit import run_in_subprocess + +LOOPS_DIR = Path(__file__).parent / "passing_tests" / "loops" + +# file -> return value of hello(ctx) under CPython +CASES = { + "for_break.py": 5, + "for_continue.py": 25, + "for_range_start_stop_step.py": 20, + "for_range_sum.py": 10, + "loop_else.py": 104, + "nested_for.py": 9, + "range_negative_step.py": 22, + "rebind_loop_var.py": 10, + "while_basic.py": 10, + "while_true_break.py": 10, + "pass_body.py": 3, + "while_true_return_i32.py": 6, + "range_step_overflow_signed.py": 22, + "range_step_overflow_unsigned.py": 1, + "range_u32_stop_negative_start.py": 4, + "reuse_loop_var_signedness.py": 303, + "loop_var_shared.py": 60, +} + +# file -> why it returns something else today (strict: a fix shows as XPASS) +XFAIL = { + # Every local gets one slot typed by its first binding, so the second + # loop's `i` is unsigned and `i < 0` never holds. Not a loop bug: + # `x = n_u64; x = -3` does the same, so the fix is general (one variable + # per web of reaching definitions), not anything loop-specific. + "reuse_loop_var_signedness.py": "one slot per name, typed by its first binding", +} + + +@pytest.mark.parametrize( + "name", + [ + pytest.param(name, marks=pytest.mark.xfail(reason=XFAIL[name], strict=True)) + if name in XFAIL + else name + for name in CASES + ], +) +def test_loop_result(name, tmp_path): + expected = CASES[name] + ll_path = tmp_path / name.replace(".py", ".ll") + run_ir_generation(LOOPS_DIR / name, ll_path) + try: + got = run_in_subprocess(ll_path, "hello", timeout=10) + except subprocess.TimeoutExpired: + pytest.fail(f"{name}: did not terminate (expected to return {expected})") + except subprocess.CalledProcessError as e: + pytest.fail(f"{name}: could not run on the host:\n{e.stderr.strip()}") + assert got == expected, f"{name}: returned {got}, CPython returns {expected}"