Skip to content

Commit 36f708e

Browse files
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.
1 parent 8d46f83 commit 36f708e

11 files changed

Lines changed: 335 additions & 0 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# A step of 2**64 is a valid Python range() step (it yields just 0), but it
2+
# does not fit the 64-bit counter: it truncates to 0 and the loop never ends.
3+
# It should be a compile error.
4+
from pythonbpf import bpf, section, bpfglobal, compile
5+
from ctypes import c_void_p, c_int64
6+
7+
8+
@bpf
9+
@section("tracepoint/syscalls/sys_enter_execve")
10+
def hello(ctx: c_void_p) -> c_int64:
11+
total: c_int64 = 0
12+
for i in range(0, 10, 18446744073709551616):
13+
total = total + 1
14+
return total
15+
16+
17+
@bpf
18+
@bpfglobal
19+
def LICENSE() -> str:
20+
return "GPL"
21+
22+
23+
compile()

‎tests/framework/host_jit.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Run a BPF function's IR on the host, to check what it computes.
2+
3+
Only for helper-free programs: the IR is retargeted to the host and JIT
4+
compiled, and the function is called with a NULL ctx. A helper call is an
5+
inttoptr to a BPF helper id, which on the host would jump to a bogus address.
6+
7+
Run as a script (`python -m tests.framework.host_jit file.ll func`), so the
8+
caller can put a timeout on a program that never terminates.
9+
"""
10+
11+
import ctypes
12+
import re
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
17+
import llvmlite.binding as llvm
18+
19+
20+
def run_function(ll_text: str, func_name: str) -> int:
21+
ll_text = re.sub(r'^target (triple|datalayout) = ".*"$', "", ll_text, flags=re.M)
22+
llvm.initialize_native_target()
23+
llvm.initialize_native_asmprinter()
24+
mod = llvm.parse_assembly(ll_text)
25+
mod.verify()
26+
tm = llvm.Target.from_default_triple().create_target_machine()
27+
engine = llvm.create_mcjit_compiler(mod, tm)
28+
engine.finalize_object()
29+
30+
ret = re.search(rf'define [^\n]*?\bi(\d+) @"?{re.escape(func_name)}"?\(', ll_text)
31+
ret_ty = {64: ctypes.c_int64, 32: ctypes.c_int32}[int(ret.group(1))]
32+
func = ctypes.CFUNCTYPE(ret_ty, ctypes.c_void_p)(
33+
engine.get_function_address(func_name)
34+
)
35+
return func(None)
36+
37+
38+
def run_in_subprocess(ll_path, func_name: str, timeout: float) -> int:
39+
"""Return value of func_name(NULL). Raises subprocess.TimeoutExpired if it
40+
does not return within timeout, CalledProcessError if it fails to run."""
41+
out = subprocess.run(
42+
[sys.executable, "-m", "tests.framework.host_jit", str(ll_path), func_name],
43+
cwd=Path(__file__).parents[2],
44+
capture_output=True,
45+
text=True,
46+
timeout=timeout,
47+
check=True,
48+
)
49+
return int(out.stdout.strip())
50+
51+
52+
if __name__ == "__main__":
53+
with open(sys.argv[1]) as f:
54+
print(run_function(f.read(), sys.argv[2]))
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Where a loop variable is visible outside its loop it stays one local, as in
2+
# Python: the outer body reads the `i` the inner loop left behind, and the
3+
# return reads the last loop's final `i`. Each outer iteration adds 0 + 1 from
4+
# the inner loop and 1 after it, the last loop adds 50, and the return adds 4:
5+
# 6 + 50 + 4 = 60.
6+
from pythonbpf import bpf, section, bpfglobal, compile
7+
from ctypes import c_void_p, c_int64
8+
9+
10+
@bpf
11+
@section("tracepoint/syscalls/sys_enter_execve")
12+
def hello(ctx: c_void_p) -> c_int64:
13+
total: c_int64 = 0
14+
for i in range(3):
15+
for i in range(2):
16+
total = total + i
17+
total = total + i
18+
for i in range(5):
19+
total = total + 10
20+
return total + i
21+
22+
23+
@bpf
24+
@bpfglobal
25+
def LICENSE() -> str:
26+
return "GPL"
27+
28+
29+
compile()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# `pass` is the natural body of a loop run only for its side effects, or of an
2+
# else-branch kept for symmetry. Returns 3: the loop variable keeps its last
3+
# value after the loop, as in Python.
4+
from pythonbpf import bpf, section, bpfglobal, compile
5+
from ctypes import c_void_p, c_int64
6+
7+
8+
@bpf
9+
@section("tracepoint/syscalls/sys_enter_execve")
10+
def hello(ctx: c_void_p) -> c_int64:
11+
i: c_int64 = 0
12+
for i in range(4):
13+
pass
14+
else:
15+
pass
16+
return i
17+
18+
19+
@bpf
20+
@bpfglobal
21+
def LICENSE() -> str:
22+
return "GPL"
23+
24+
25+
compile()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# range() stops once the next value would pass stop; it never wraps. Here the
2+
# first step overshoots INT64_MAX, so the counter must not wrap to a negative
3+
# value that is still below stop; the second loop does the same below
4+
# INT64_MIN. Leaving that way is a normal exit, so each else-branch runs.
5+
# Returns 1 + 10 + 1 + 10 = 22.
6+
from pythonbpf import bpf, section, bpfglobal, compile
7+
from ctypes import c_void_p, c_int64
8+
9+
10+
@bpf
11+
@section("tracepoint/syscalls/sys_enter_execve")
12+
def hello(ctx: c_void_p) -> c_int64:
13+
total: c_int64 = 0
14+
for i in range(9223372036854775806, 9223372036854775807, 2):
15+
total = total + 1
16+
else:
17+
total = total + 10
18+
for j in range(-9223372036854775807, -9223372036854775808, -3):
19+
total = total + 1
20+
else:
21+
total = total + 10
22+
return total
23+
24+
25+
@bpf
26+
@bpfglobal
27+
def LICENSE() -> str:
28+
return "GPL"
29+
30+
31+
compile()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# The unsigned version of range_step_overflow_signed: the step goes past
2+
# UINT64_MAX, and the counter must not wrap to 0, which is below stop. Returns 1.
3+
from pythonbpf import bpf, section, bpfglobal, compile
4+
from ctypes import c_void_p, c_int64, c_uint64
5+
6+
7+
@bpf
8+
@section("tracepoint/syscalls/sys_enter_execve")
9+
def hello(ctx: c_void_p) -> c_int64:
10+
stop: c_uint64 = 18446744073709551615
11+
total: c_int64 = 0
12+
for i in range(18446744073709551614, stop, 2):
13+
total = total + 1
14+
return total
15+
16+
17+
@bpf
18+
@bpfglobal
19+
def LICENSE() -> str:
20+
return "GPL"
21+
22+
23+
compile()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# The counter is 64-bit, and a 64-bit signed value holds every u32, so by
2+
# C's own rules a u32 stop does not make the loop unsigned: in
3+
# `for (__s64 idx = -1; idx < n; idx++)` n widens to __s64. Returns 4, as
4+
# range(-1, 3) does in Python.
5+
from pythonbpf import bpf, section, bpfglobal, compile
6+
from ctypes import c_void_p, c_int64, c_uint32
7+
8+
9+
@bpf
10+
@section("tracepoint/syscalls/sys_enter_execve")
11+
def hello(ctx: c_void_p) -> c_int64:
12+
n: c_uint32 = 3
13+
total: c_int64 = 0
14+
for i in range(-1, n):
15+
total = total + 1
16+
return total
17+
18+
19+
@bpf
20+
@bpfglobal
21+
def LICENSE() -> str:
22+
return "GPL"
23+
24+
25+
compile()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Two unrelated loops that reuse `i`. The first counts over an unsigned
2+
# bound, the second over a signed one. The second loop's `i < 0` has to see
3+
# -3, -2 and -1, not the unsigned slot the first loop left behind.
4+
# Returns 3 + 3 * 100 = 303.
5+
from pythonbpf import bpf, section, bpfglobal, compile
6+
from ctypes import c_void_p, c_int64, c_uint64
7+
8+
9+
@bpf
10+
@section("tracepoint/syscalls/sys_enter_execve")
11+
def hello(ctx: c_void_p) -> c_int64:
12+
n: c_uint64 = 3
13+
total: c_int64 = 0
14+
for i in range(n):
15+
total = total + 1
16+
for i in range(-3, 3):
17+
if i < 0:
18+
total = total + 100
19+
return total
20+
21+
22+
@bpf
23+
@bpfglobal
24+
def LICENSE() -> str:
25+
return "GPL"
26+
27+
28+
compile()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# The only way out of `while True` is the return in its body, so the block
2+
# after the loop is unreachable. Whatever closes it off has to match the
3+
# function's c_int32 return type. Returns 6.
4+
from pythonbpf import bpf, section, bpfglobal, compile
5+
from ctypes import c_void_p, c_int32
6+
7+
8+
@bpf
9+
@section("tracepoint/syscalls/sys_enter_execve")
10+
def hello(ctx: c_void_p) -> c_int32:
11+
i: c_int32 = 0
12+
while True:
13+
i = i + 1
14+
if i > 5:
15+
return i
16+
17+
18+
@bpf
19+
@bpfglobal
20+
def LICENSE() -> str:
21+
return "GPL"
22+
23+
24+
compile()

‎tests/test_config.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@
4141
"failing_tests/loops/break_outside_loop.py" = {reason = "`break` outside a loop is a SyntaxError in Python, and a compile error here", level = "ir"}
4242

4343
"failing_tests/loops/range_variable_step.py" = {reason = "range() step must be an integer literal: its sign decides the loop test", level = "ir"}
44+
45+
"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"}

0 commit comments

Comments
 (0)