Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions pythonbpf/maps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from .maps import HashMap, PerfEventArray, RingBuffer
from .maps import ArrayMap, HashMap, PerfEventArray, RingBuffer
from .maps_pass import maps_proc
from .map_types import BPFMapType

__all__ = ["HashMap", "PerfEventArray", "maps_proc", "RingBuffer", "BPFMapType"]
__all__ = [
"ArrayMap",
"HashMap",
"PerfEventArray",
"maps_proc",
"RingBuffer",
"BPFMapType",
]
20 changes: 20 additions & 0 deletions pythonbpf/maps/maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ def update(self, key, value, flags=None):
raise KeyError(f"Key {key} not found in map")


class ArrayMap:
def __init__(self, key, value, max_entries):
self.key = key
self.value = value
self.max_entries = max_entries
self.entries = {}

def lookup(self, key):
return self.entries.get(key)

def update(self, key, value, flags=None):
self.entries[key] = value

def delete(self, key):
if key in self.entries:
del self.entries[key]
else:
raise KeyError(f"Key {key} not found in map")


class PerfEventArray:
def __init__(self, key_size, value_size):
self.key_type = key_size
Expand Down
8 changes: 8 additions & 0 deletions pythonbpf/maps/maps_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ def process_hash_map(map_name, rval, compilation_context):
return map_global


@MapProcessorRegistry.register("ArrayMap")
def process_array_map(map_name, rval, compilation_context):
"""Document the planned BPF_ARRAY map support with an explicit failure."""
raise NotImplementedError(
"ArrayMap is not implemented yet; add BPF_MAP_TYPE_ARRAY metadata support"
)


@MapProcessorRegistry.register("PerfEventArray")
def process_perf_event_map(map_name, rval, compilation_context):
"""Process a BPF_PERF_EVENT_ARRAY map declaration"""
Expand Down
49 changes: 48 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,52 @@ All xfails use `strict = True`: if a test starts **passing** it shows up as **XP
2. Run `make test` — the file is discovered and tested automatically at all levels.
3. If the test is expected to fail, add it to `tests/test_config.toml` instead of `passing_tests/`.

## Kernel selftest equivalents

`tests/kernel_selftest_equivalent/` contains PythonBPF versions of important
kernel BPF selftests from `bpf-next/tools/testing/selftests/bpf`. Each file names
its upstream original in a header comment.

The directory holds two kinds of test, and both are useful:

- **Ports that pass.** A program PythonBPF can already express. These widen the
range of program types under test — `raw_tp`, `perf_event`,
`tracepoint/sched/*` and others that nothing else exercises.
- **Roadmap tests that fail.** A program describing a feature PythonBPF should
grow next. These must be listed as **strict** expected failures in
`tests/test_config.toml` until the feature lands, at which point they turn up
as XPASS and should be promoted.

### What a passing port proves — and does not

A kernel selftest is two halves: the BPF program under `progs/`, and a userspace
driver under `prog_tests/` that loads it through a skeleton, triggers it and
asserts on the result. **Only the BPF half is ported**, because this framework
compiles and verifies programs but never runs them.

So a passing test here says PythonBPF emits a loadable, verifiable object for
that program type and feature mix. It does not say the program behaves the way
the kernel's version does. Treat it as a compiler assertion, not a semantic one.

### `WORKAROUND(globals)`

The selftest corpus overwhelmingly reports results through global variables: the
program writes a global and the driver reads it back. PythonBPF has no global
variable support, so each becomes a one-entry `HashMap` keyed by index, tagged in
a comment naming the variable it replaces:

```bash
grep -rn "WORKAROUND(globals)" tests/kernel_selftest_equivalent/
```

This is deliberate scaffolding, not the intended shape — the tag exists so the
sweep is mechanical once real globals land. It is not a cosmetic substitution
either: it changes what a future userspace driver would read.

Anything importing from `vmlinux` belongs in `vmlinux/`, which is registered in
`VMLINUX_TEST_DIRS_PASSING` so it is skipped rather than failed where no
`vmlinux.py` has been generated.

## Directory structure

```
Expand All @@ -104,5 +150,6 @@ tests/
│ ├── compiler.py ← wrappers around compile_to_ir() + _run_llc()
│ └── verifier.py ← bpftool subprocess wrapper
├── passing_tests/ ← programs that should compile and verify cleanly
└── failing_tests/ ← programs with known issues (declared in test_config.toml)
├── failing_tests/ ← programs with known issues (declared in test_config.toml)
└── kernel_selftest_equivalent/ ← ports of kernel selftests + feature roadmap tests
```
14 changes: 11 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""

import logging
import warnings

import pytest

Expand All @@ -25,11 +26,15 @@
# ── vmlinux availability ────────────────────────────────────────────────────

try:
import vmlinux # noqa: F401
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import vmlinux # noqa: F401

VMLINUX_AVAILABLE = True
except ImportError:
VMLINUX_SKIP_REASON = ""
except Exception as exc:
VMLINUX_AVAILABLE = False
VMLINUX_SKIP_REASON = f"vmlinux.py not usable for current kernel: {exc}"


# ── pytest_generate_tests: parametrize on bpf_test_file ───────────────────
Expand Down Expand Up @@ -65,7 +70,10 @@ def pytest_collection_modifyitems(items):
# vmlinux skip
if case.needs_vmlinux and not VMLINUX_AVAILABLE:
item.add_marker(
pytest.mark.skip(reason="vmlinux.py not available for current kernel")
pytest.mark.skip(
reason=VMLINUX_SKIP_REASON
or "vmlinux.py not available for current kernel"
)
)
continue

Expand Down
7 changes: 5 additions & 2 deletions tests/framework/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
TESTS_DIR = Path(__file__).parent.parent
CONFIG_FILE = TESTS_DIR / "test_config.toml"

VMLINUX_TEST_DIRS_PASSING = {"passing_tests/vmlinux"}
VMLINUX_TEST_DIRS_PASSING = {
"passing_tests/vmlinux",
"kernel_selftest_equivalent/vmlinux",
}
VMLINUX_TEST_DIRS_FAILING = {
"failing_tests/vmlinux",
"failing_tests/xdp",
Expand All @@ -33,7 +36,7 @@ def collect_all_test_files() -> list[BpfTestCase]:
xfail_map: dict = config.get("xfail", {})

cases = []
for subdir in ("passing_tests", "failing_tests"):
for subdir in ("passing_tests", "failing_tests", "kernel_selftest_equivalent"):
for py_file in sorted((TESTS_DIR / subdir).rglob("*.py")):
if py_file.name == "vmlinux.py":
# Not a test case: the per-directory symlink to the master
Expand Down
111 changes: 111 additions & 0 deletions tests/kernel_selftest_equivalent/PORTING-NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Porting kernel selftests: what the first spike found

Four programs from `tools/testing/selftests/bpf/progs/` were ported as an experiment,
to answer two questions before anyone commits to doing this at scale:

1. Is LLM-assisted porting of kernel selftests viable?
2. What must real global-variable support actually handle?

Short answers: **viable, with a caveat about what a passing port proves**; and
**four distinct global shapes showed up in four programs**, which is the more
actionable finding.

## The spike

| Port | Upstream | Section | Outcome |
|---|---|---|---|
| `tracing/tracepoint_sched_switch.py` | `test_tracepoint.c` | `tracepoint/sched/sched_switch` | passes |
| `tracing/get_cgroup_id.py` | `get_cgroup_id_kern.c` | `tracepoint/syscalls/sys_enter_nanosleep` | passes |
| `tracing/autoattach.py` | `test_autoattach.c` | `raw_tp/sys_enter`, `raw_tp/sys_exit` | passes |
| `vmlinux/perf_skip.py` | `test_perf_skip.c` | `perf_event` | strict xfail — nested ctx access |

## 1. Is it viable?

**Yes, for programs inside the envelope — three of four compiled and passed `llc` on the
first attempt.** The mechanical part of a port (decorators, ctypes annotations, map
declarations, helper names) is regular enough to be reliable.

The failure was not a translation error. `perf_skip` needs `ctx.regs.ip`, which PythonBPF
genuinely cannot express, and no amount of care in the port changes that. That is the
useful kind of failure: it converts into a roadmap test that documents the gap.

Two caveats that matter more than the pass rate:

**A passing port proves less than the test it came from.** A kernel selftest is two
halves — the BPF program, and a `prog_tests/` driver that loads it through a skeleton,
triggers it, and asserts on the result. Only the BPF half is portable here, because this
framework compiles and verifies but never runs. Everything ported becomes a compiler
assertion: *PythonBPF emits a loadable, verifiable object for this program type and
feature mix*. That is worth having — it is how the `raw_tp` and `perf_event` program types
came under test at all — but it is not what "we ported the kernel's selftests" sounds
like. Closing that gap needs a runtime test tier, which is a much larger piece of work.

**Selection is the expensive step, not translation.** Of 820 real programs, 28 are
portable today. Picking those out required scoring the whole corpus against the compiler's
actual envelope; guessing from filenames does not work. The classifier that did it is
worth keeping around and re-running after each feature lands.

**Recommendation: viable and worth continuing, in small increments tied to features.**
Port a handful, let them reveal the next gap, fix the gap, port more. Bulk porting ahead of
the features would just produce a large pile of xfails.

## 2. What real globals must support

Every port that touches a global currently substitutes a one-entry `HashMap`, tagged
`WORKAROUND(globals)`. Four programs produced four distinct shapes:

| Shape | Example | What globals must support |
|---|---|---|
| none | `tracepoint_sched_switch` | — (control case) |
| scalar in + scalar out | `get_cgroup_id` | read a global, write a different one |
| flags across programs | `autoattach` | two programs in one object sharing global state |
| scalar in, compared against ctx | `perf_skip` | read-only input set by userspace before attach |
| array + cursor *(next increment)* | `cgroup_preorder` | indexed writes and read-modify-write on a global |

The last row is not in this spike but is the recommended next port precisely because it is
the most demanding shape: `result[idx++] = N` needs an array global *and* a read-modify-write
cursor, which together constrain the design more than anything here does.

### A design note worth acting on

**libbpf implements global variables as single-element `BPF_MAP_TYPE_ARRAY` maps.**
`.bss`, `.data` and `.rodata` become internal array maps at load time. Two consequences:

- A one-element **`ArrayMap`** is the structurally faithful stand-in for a global, not a
`HashMap`. `HashMap` is used here only because `ArrayMap` is still a placeholder that
raises `NotImplementedError`. Landing `ArrayMap` first would make the eventual migration
to real globals close to mechanical.
- **Most of the ELF work is already done.** `@bpfglobal` is vestigial — a metadata carrier
for `LICENSE` — but the machinery behind it already emits globals that LLVM places into
`.bss` and `.data` correctly, and that libbpf already recognises:

```
libbpf: map 'g.bss' (global data): at sec_idx 5, offset 0, flags 0.
libbpf: map 'g.data' (global data): at sec_idx 6, offset 0, flags 0.
```

What is missing is narrower than "implement global variables": name resolution in
`expr_pass.get_operand_value` (which resolves against `local_sym_tab`, then vmlinux
enums, then gives up), a Python-level surface for declaring one, and userspace access
through `pylibbpf`.

## 3. Incidental findings

- **Nested struct field access fails with a misleading error.** `ctx.regs.ip` reports
`SyntaxError: Undefined variable actual` — naming the assignment target rather than the
nested access that caused it. `_allocate_for_attribute` declines to allocate when the
attribute's base is not a plain `Name`, logging at debug level, and the expression pass
then trips over the missing symbol. The diagnostic should name the real cause.
- **One level of nested-context access already works.** `ctx.sample_period` on
`struct_bpf_perf_event_data` compiles and `llc`s cleanly, so `perf_event` contexts are
usable today for anything that does not need `regs`.
- **`@section` really does accept anything.** `tc`, `socket`, `fentry/…`, `lsm/…`,
`cgroup_skb/egress`, `netfilter` and `tp_btf/…` all compile and land in the ELF verbatim.
Program type is not a constraint; the context type is.

## Re-running the corpus scoring

The audit behind this spike scored all 976 programs against the compiler's envelope. It is
worth re-running after each feature lands, to see what the change unlocked. The blocker
histogram at the time of writing, over 820 real programs: globals 52%, verifier-test
annotations 27%, typed program macros 26%, kfuncs 20%, inline asm 16%, loops 12%.
35 changes: 35 additions & 0 deletions tests/kernel_selftest_equivalent/maps/array_map_lookup_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Adapted from bpf-next/tools/testing/selftests/bpf/progs/test_map_ops.c
# and bpf-next/tools/testing/selftests/bpf/progs/bpf_iter_bpf_array_map.c.

from ctypes import c_int32, c_uint64, c_void_p

from pythonbpf import bpf, bpfglobal, compile, map, section
from pythonbpf.maps import ArrayMap


@bpf
@map
def counters() -> ArrayMap:
return ArrayMap(key=c_int32, value=c_uint64, max_entries=8)


@bpf
@section("tracepoint/syscalls/sys_enter_getpid")
def array_map_lookup_update(ctx: c_void_p) -> c_int32:
counters.update(0, 1)

current = counters.lookup(0)
if current:
next_value = current + 1
counters.update(0, next_value)

return c_int32(0)


@bpf
@bpfglobal
def LICENSE() -> str:
return "GPL"


compile()
48 changes: 48 additions & 0 deletions tests/kernel_selftest_equivalent/ringbuf/reserve_submit_discard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Adapted from bpf-next/tools/testing/selftests/bpf/progs/test_ringbuf.c.

from ctypes import c_int32, c_uint64, c_void_p

from pythonbpf import bpf, bpfglobal, compile, map, section, struct
from pythonbpf.helper import pid
from pythonbpf.maps import RingBuffer


@bpf
@struct
class sample_t:
pid: c_uint64
seq: c_uint64
value: c_uint64


@bpf
@map
def events() -> RingBuffer:
return RingBuffer(max_entries=4096)


@bpf
@section("tracepoint/syscalls/sys_enter_getpid")
def ringbuf_reserve_submit_discard(ctx: c_void_p) -> c_int32:
first = events.reserve(24)
if first:
sample = sample_t(first)
sample.pid = pid()
sample.seq = 0
sample.value = 7
events.submit(first, 0)

second = events.reserve(24)
if second:
events.discard(second, 0)

return c_int32(0)


@bpf
@bpfglobal
def LICENSE() -> str:
return "GPL"


compile()
Loading
Loading