diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 10ff7b2f..0d81d3fa 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__) @@ -49,57 +50,231 @@ 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 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. 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) + if bound is not None + ] + signed = True + if all(ty is not None for ty in bound_types): + common = IntTy(64, True) + for ty in bound_types: + 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})") + 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 + + +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/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 46fb71c3..3dbeed5a 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -25,6 +25,10 @@ ) 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, @@ -81,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, @@ -116,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(): @@ -135,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, @@ -146,6 +161,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 +214,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. @@ -295,7 +332,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 @@ -313,28 +350,197 @@ 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) 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) + 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) + + _lower_loop( + func, + compilation_context, + builder, + stmt, + local_sym_tab, + ret_type, + body_block, + inc_block, + end_block, + else_block, + ) + + +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.""" + 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 ): @@ -383,12 +589,22 @@ 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): 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.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.Pass): + pass elif isinstance(stmt, ast.Return): did_return = handle_return( func, builder, stmt, local_sym_tab, ret_type, compilation_context @@ -403,6 +619,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 (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 + process_stmt( + func, compilation_context, builder, s, local_sym_tab, False, ret_type + ) + + # ============================================================================ # SECTION 3: Function Body Processing # ============================================================================ @@ -515,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): diff --git a/tests/c-form/loops.bpf.c b/tests/c-form/loops.bpf.c new file mode 100644 index 00000000..e66be7ee --- /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_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/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/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/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 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/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/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/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_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/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/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/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/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/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/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() diff --git a/tests/test_config.toml b/tests/test_config.toml index ada07a15..453650f2 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -38,31 +38,13 @@ "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"} +"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"} -# 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/break_outside_loop.py" = {reason = "`break` outside a loop is a SyntaxError in Python, and a compile error here", 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/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"} 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}"