---
sidebar_position: 5
title: eBPF
---

# eBPF

Yeet scripts load a compiled eBPF object (`.bpf.o`), attach its programs, and talk to its maps from JavaScript — ring buffers, hash maps, arrays, LPM tries, bloom filters, per-CPU maps, and `.data`/`.rodata`/`.bss` globals — with keys and values typed end-to-end from the object's BTF.

The flow is always the same:

```js
import probe from './probe.bpf.o';
import { RingBuf } from 'yeet:bpf';

// 1. declare the maps you want to reach (and any attach overrides), then start
const control = await probe
  .bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 4096 })
  .start();   // every program in the object auto-attaches by its ELF section

// 2. open a typed handle to a bound map and use it
const events = new RingBuf(control, "events");
// ringbuf records are decoded into an object keyed by the BTF struct name
await events.subscribe(({ event }) => console.log(event));
```

## Importing

Any path ending in `.bpf.o` resolves to a default-exported [`BpfObject`](#bpf-object) bound to that path:

```js
import probe from './probe.bpf.o';
```

The ELF is not read at import time — it is loaded, verified, and attached when [`start()`](#bpf-start) is called.

The map handle classes and builder are exported from `yeet:bpf`:

```js
import {
  BpfObject, BpfControl,
  RingBuf, BpfSubscription, DataSec,
  HashMap, LruHashMap, LpmTrie, ArrayMap, BloomFilter,
  PercpuHashMap, LruPercpuHashMap, PercpuArrayMap,
} from 'yeet:bpf';
```

## <Obj /> BpfObject {#bpf-object}

A chainable builder representing a compiled eBPF object. Declare the maps you want to reach and any attach overrides, then call [`start()`](#bpf-start) to load it. `bind` and `attach` both return `this`, so they chain.

### <Method /> BpfObject.bind {#bpf-bind}

```ts
bind(name: string, spec: { kind: string, ...opts }): BpfObject
```

Declares that the named map should be exposed to JavaScript. **Every map you open a handle to must be bound before `start()`** — a handle on an unbound map rejects when you call it, because no daemon-side service was created for it.

| Field | Type | Description |
|---|---|---|
| `name` | `string` | Map name as it appears in the ELF. |
| `kind` | `string` | **Required.** The map type (see table below). |
| `btf_struct` | `string` | _(RingBuf)_ Name of the record struct in the object's BTF; drives event decoding. |
| `capacity` | `number` | _(RingBuf)_ In-process broadcast buffer size in entries, for fanning events out to subscribers. Defaults to a built-in value. |
| _any other field_ | `any` | _(data)_ Written into the global of that name before the program first runs — see [seeding](#bpf-datasec-seed). |

`kind` selects the map type and must match the map's actual kernel type. Each accepts several spellings:

| Map type | Accepted `kind` values |
|---|---|
| Ring buffer | `"ringbuf"` (canonical), `"ring_buf"` |
| Data section (`.data`/`.rodata`/`.bss`) | `"data"` |
| Hash | `"hashmap"`, `"hash_map"`, `"hash"` |
| LRU hash | `"lru_hashmap"`, `"lru_hash_map"`, `"lru_hash"` |
| LPM trie | `"lpm_trie"`, `"lpm"` |
| Array | `"array"` |
| Per-CPU hash | `"percpu_hashmap"`, `"percpu_hash_map"`, `"percpu_hash"` |
| LRU per-CPU hash | `"lru_percpu_hashmap"`, `"lru_percpu_hash_map"`, `"lru_percpu_hash"` |
| Per-CPU array | `"percpu_array"` |
| Bloom filter | `"bloom_filter"`, `"bloom"` |

Ring buffer records carry no `__type` annotation, so you name their struct explicitly with `btf_struct`. Key/value maps instead lift their types from the `__type(key, …)` / `__type(value, …)` annotations on the BPF C side — no `btf_struct` needed. Any field other than `kind` is passed through as an option; binding the same map twice throws.

Data sections are maps too, named `<object>.<section>` by libbpf — `probe.bpf.o` gets `probe.rodata`, `probe.data`, and `probe.bss` (see [DataSec](#bpf-datasec) for the exact naming rule).

```js
probe
  .bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 8192 })
  .bind("flows",  { kind: "hashmap" })
  .bind("probe.rodata", { kind: "data" });
```

### <Method /> BpfObject.attach {#bpf-attach}

```ts
attach(progName: string, spec: object | null): BpfObject
```

Declares how the named program attaches. A non-null `spec.kind` selects one of five lanes; the program's own BPF type must match the kind you pass, or `start()` rejects. Pass `null` for the section-name auto-attach path.

**You do not need to call `attach` for every program.** On `start()` the daemon attaches *all* programs in the object (verified: a tracepoint program with no `attach()` call still fires). What `attach()` controls is the *spec* for programs that need or accept one:

- **kprobe / kretprobe, tracepoint, fentry / fexit, cgroup hooks, …** auto-attach from their ELF section name. No `attach()` call is needed; if you list one anyway, pass `null`.
- **xdp** and **tcx** attach with sane defaults (host namespace, all interfaces) — call `attach()` only to override the target.
- **perf**, **uprobe**, and **usdt** *require* an `attach()` spec — without one, `start()` rejects with an invalid-opts error.

Declaring the same program twice throws.

#### kind: `"uprobe"`

Attaches a uprobe/uretprobe to a user-space binary. Entry vs. return is taken from the program's section name (`uprobe`/`uretprobe`), not the spec.

| Field | Type | Required | Description |
|---|---|---|---|
| `binary` | `string` | yes | Bare name resolved via `ld.so.cache` (e.g. `libssl.so.1.1`) or an absolute path. |
| `symbol` | `string` | no | Symbol to resolve (e.g. `SSL_read`). If omitted, `offset` is a raw ELF offset. |
| `offset` | `number` | no | Added to the resolved symbol offset (default `0`). |
| `pid` | `number` | no | Attach only to this PID. If omitted, attaches to every process mapping the binary. |

```js
probe.attach("trace_ssl", { kind: "uprobe", binary: "libssl.so.1.1", symbol: "SSL_read" });
```

#### kind: `"usdt"`

Attaches to a **USDT** (userspace statically defined tracepoint) — the `.note.stapsdt` probes baked into binaries built with `sys/sdt.h`, such as `libjvm.so`, `libc`, PostgreSQL, Node, and Python. The program lives in a `SEC("usdt/<provider>/<name>")` section; a single attach covers every place the probe was compiled in, inlined copies included.

| Field | Type | Required | Description |
|---|---|---|---|
| `binary` | `string` | yes | Bare name resolved via `ld.so.cache` (e.g. `libc.so.6`) or an absolute path to any ELF carrying the probe's notes. |
| `provider` | `string` | yes | Probe provider, the first half of the `provider:name` pair in the note (e.g. `hotspot`). |
| `name` | `string` | yes | Probe name, the second half (e.g. `gc__begin`). |
| `pid` | `number` | no | Attach only to this PID. If omitted, attaches to every process mapping the binary. |

```js
// Count JVM garbage collections as they happen.
probe.attach("on_gc", { kind: "usdt", binary: libjvm, provider: "hotspot", name: "gc__begin" });
```

You can enumerate a binary's probes with `readelf -n <file>` (each note prints its `Provider:` and `Name:`).

:::note No return variant
A USDT probe is a point marker, not a function boundary — there is no entry/return duality as with `uprobe`/`uretprobe`. Paired regions like `gc__begin`/`gc__end` or `mem__pool__gc__begin`/`…__end` are **two separately-named probes** the instrumenter placed; attach a program to each and correlate them yourself (typically by thread id and timestamp).
:::

:::note Semaphore-gated probes
Some probes (the JVM's `hotspot:*` among them) are guarded by a semaphore and only fire while a tracer is attached — the attach bumps the semaphore in the target's memory. For these, **scope to a `pid`** so the semaphore can be bumped reliably in that process. Argument-free lifecycle probes (`gc__begin`, `safepoint__begin`, `thread__start`, `compiled__method__load`) need no special JVM flags; method- and monitor-level probes require `-XX:+ExtendedDTraceProbes` / `-XX:+DTraceMonitorProbes` and carry real overhead.
:::

##### Reading arguments

Probe arguments are read inside the BPF program with libbpf's `bpf_usdt_arg` (directly, or via the `BPF_USDT(...)` wrapper from `usdt.bpf.h`), then handed to JavaScript through a map like any other value:

```c
#include <bpf/usdt.bpf.h>

SEC("usdt/hotspot/thread__start")
int BPF_USDT(on_thread_start, char *name, u64 name_len, u64 tid) {
    // …read name/tid, emit on a ring buffer…
}
```

The `char *` / `u64` values arrive in JavaScript with their usual BTF types — `char[N]` copied out with `bpf_probe_read_user_str` decodes to a string, `u64` to a `BigInt`, and so on.

##### Containers

USDT attaches by the binary's on-disk inode, so a containerized process is reachable through its proc-root path — the daemon runs in the host namespaces and can open into any container's mount namespace:

```js
// host-visible PID of the containerized process (not its in-container PID)
const pid = 4242;
probe.attach("on_gc", {
  kind: "usdt",
  binary: `/proc/${pid}/root/opt/jdk/lib/server/libjvm.so`,
  provider: "hotspot",
  name: "gc__begin",
  pid,
});
```

The two rules that make it work: address the binary by its **absolute** `/proc/<host-pid>/root/…` path (a bare name resolves against the *host's* `ld.so.cache`, not the container's), and scope with the process's **host** PID (the one `/proc` shows, not the container-internal PID). This is the same mechanism `bcc`/`bpftrace` use for containers.

#### kind: `"perf"`

Arms a perf event and runs the program on each sample. One program fans out to one perf event per requested CPU.

| Field | Type | Required | Description |
|---|---|---|---|
| `event` | `object` | yes | `{ kind: "software", name }` or `{ kind: "hardware", name }`. |
| `cpu` | `number[]` | no | CPU indices to arm. Omit for every online CPU. |
| `target` | `object` | no | Which tasks to observe (see below). Defaults to all PIDs in the host namespace. |
| `sample` | `object` | no | `{ freq: N }` (≈N samples/sec) or `{ period: N }` (every N events). Omit for a non-sampling counter. |

**Software event names:** `cpu_clock`, `task_clock`, `page_faults`, `context_switches`, `cpu_migrations`, `page_faults_min`, `page_faults_maj`, `alignment_faults`, `emulation_faults`.

**Hardware event names:** `cpu_cycles`, `instructions`, `cache_references`, `cache_misses`, `branch_instructions`, `branch_misses`, `bus_cycles`, `stalled_cycles_frontend`, `stalled_cycles_backend`, `ref_cpu_cycles`.

`target` is one of:
- `{ kind: "pids", ns?, pids? }` — `pids` is an array (omit for all); `ns` is a [namespace scope](#bpf-ns).
- `{ kind: "cgroup", path }` — observe every task in the cgroup at `path`.

```js
probe.attach("on_tick", {
  kind: "perf",
  event: { kind: "software", name: "cpu_clock" },
  sample: { freq: 99 },
  target: { kind: "pids", ns: { pid: 1234 } },
});
```

#### kind: `"xdp"` / kind: `"tcx"`

Network attachments. Both take a [namespace scope](#bpf-ns) via `ns`/`ifindex`. `tcx` additionally takes `order`.

| Field | Type | Required | Description |
|---|---|---|---|
| `ns` | `"host" \| { pid } \| { path }` | no | Network namespace to attach in. Defaults to the host. |
| `ifindex` | `number[]` | no | Interface indices. Omit for all interfaces. |
| `order` | `"default" \| "before" \| "after"` | no | _(tcx only)_ Placement in the TCX chain. Default lets the kernel choose. |

```js
probe.attach("ingress", { kind: "tcx", ifindex: [2], order: "before" });
```

#### Namespace scope {#bpf-ns}

`ns` (and the perf `target`'s `ns`) names a PID or network namespace by:

- `"host"` — the host namespace (the default when `ns` is omitted).
- `{ pid: 1234 }` — the namespace that PID 1234 lives in.
- `{ path: "/proc/1234/ns/net" }` — a namespace by path.

(Resolving a namespace by container name arrives with `yeet:container`.) Under the hood these desugar to the daemon's `{ handle, ifindex }` / `{ handle, pids }` wire shapes; you can pass those explicitly instead, but not alongside the sugar.

### <Method /> BpfObject.start {#bpf-start}

```ts
start(): Promise<BpfControl>
```

Loads the ELF, runs the verifier, binds the declared maps, and attaches every program. Resolves to a [`BpfControl`](#bpf-control) handle. Rejects on failure — common causes: bad ELF path, verifier rejection, a missing `attach` spec for a perf/uprobe program, a kind/program mismatch, or missing `CAP_BPF`.

## <Obj /> BpfControl {#bpf-control}

The handle returned by [`start()`](#bpf-start). Pass it to a map handle constructor, and call `stop()` to tear the object down.

### <Property /> BpfControl.id {#bpf-control-id}

```ts
id: string
```

Identifier of the running instance. Map handles capture it to route their operations.

### <Method /> BpfControl.stop {#bpf-control-stop}

```ts
stop(): Promise<void>
```

Detaches all programs and tears down the loaded object and its map services.

## Map handles

Open a typed handle to a **bound** map by constructing the class for its kind with `(control, name)`:

```js
import { HashMap, RingBuf } from 'yeet:bpf';

const flows  = new HashMap(control, "flows");
const events = new RingBuf(control, "events");
```

Keys and values are typed end-to-end from the map's BTF: a struct becomes a plain object whose fields keep their C types — `__u64` → `BigInt`, `__u8[N]` → `Uint8Array`, and scalars / strings / nested structs / arrays map to their JS equivalents. The `__type(key, …)` / `__type(value, …)` annotations on the BPF C side drive the lift.

The constructor never validates the kind — it just records `(control.id, name)`. The native bindings reject with `InvalidMapKind` the first time you call a method whose map isn't the kind you constructed.

### <Obj /> RingBuf {#bpf-ringbuf}

Streams `BPF_MAP_TYPE_RINGBUF` events into JavaScript.

#### <Method /> RingBuf.subscribe {#bpf-ringbuf-subscribe}

```ts
subscribe(cb: (event: any) => void, onError?: (err: Error) => void): Promise<BpfSubscription>
```

Subscribes `cb` to events. Each record is decoded via the map's `btf_struct` and delivered as an object **keyed by that struct name** — a record of `struct event { … }` arrives as `{ event: { …fields } }`, so destructure it: `subscribe(({ event }) => …)`. Fields keep their BTF types (`__u64` → `BigInt`, `__u8[N]` → `Uint8Array`, `char[N]` → a NUL-terminated string, …).

Resolves to a [`BpfSubscription`](#bpf-subscription) once the daemon-side pump is wired up; setup failures reject the Promise. Runtime errors (e.g. a slow consumer lagging the broadcast and dropping events) flow to `onError`, which defaults to logging the message via `console.error`.

#### Example {#bpf-ringbuf-example}

Every `nanosleep(2)` on the box becomes one record carrying the caller and the duration it asked for. The BPF side reads the `timespec` out of the syscall argument, reserves a slot, fills it, and submits:

```c
struct sleep_event {
	__u32 pid;
	__u64 ns;
	char comm[16];
};

// Only referenced through a local pointer, so clang would drop it from
// the object's BTF; a global keeps it there for `btf_struct` to find.
const struct sleep_event *unused_sleep_event __attribute__((unused));

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
	struct __kernel_timespec ts;
	if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
		return 0;

	struct sleep_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
	if (!e)
		return 0;

	e->pid = bpf_get_current_pid_tgid() >> 32;
	e->ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
	bpf_get_current_comm(e->comm, sizeof(e->comm));
	bpf_ringbuf_submit(e, 0);
	return 0;
}
```

The JS side binds the map with the struct name, subscribes, and takes the first five records:

```js
import probe from "./ringbuf.bpf.o";
import { RingBuf } from "yeet:bpf";

const control = await probe
  .bind("events", { kind: "ringbuf", btf_struct: "sleep_event", capacity: 4096 })
  .start();

const events = new RingBuf(control, "events");

let seen = 0;
let done;
const finished = new Promise((resolve) => { done = resolve; });

const sub = await events.subscribe(({ sleep_event: e }) => {
  if (seen >= 5) return;
  console.log(`${e.comm} pid=${e.pid} sleeps for ${e.ns / 1000n}µs`);
  if (++seen === 5) done();
});

await finished;
await sub.unsubscribe();
await control.stop();
```

```
containerd pid=4054 sleeps for 10000µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs
```

`comm` arrives as a string, `pid` as a number, and the `__u64` `ns` as a `BigInt`.

:::note Who calls nanosleep
Go runtimes (`containerd`, `dockerd`, most Kubernetes agents) call `nanosleep` steadily, so the examples on this page fill up on their own on any box running one. A shell `sleep` and Python's `time.sleep` go through the `clock_nanosleep` syscall instead — swap the section name to `tracepoint/syscalls/sys_enter_clock_nanosleep` (the `timespec` is then `args[2]`) to see those.
:::

:::note The struct has to be in the object's BTF
`btf_struct` is looked up in the `.bpf.o`'s own BTF, and clang only emits BTF for types reachable from globals, map definitions, and function signatures. A record struct that is only ever touched through a local pointer — the usual `reserve`/`submit` pattern — gets dropped. Keep it reachable with a throwaway global as above.
:::

### <Obj /> BpfSubscription {#bpf-subscription}

Returned by [`RingBuf.subscribe`](#bpf-ringbuf-subscribe).

#### <Method /> BpfSubscription.unsubscribe {#bpf-subscription-unsubscribe}

```ts
unsubscribe(): Promise<void>
```

Stops delivery and releases the daemon-side subscription.

### <Obj /> HashMap / LruHashMap {#bpf-hashmap}

`HashMap` wraps `BPF_MAP_TYPE_HASH`; `LruHashMap` wraps `BPF_MAP_TYPE_LRU_HASH`. The JS surface is identical — the choice of class is the choice of kernel semantics: on a full map `update()` rejects with `E2BIG` for `HashMap` but silently evicts the least-recently-used entry for `LruHashMap` (whose `lookup`/iteration also refresh recency).

| Method | Signature | Description |
|---|---|---|
| `lookup` | `lookup(key): Promise<value \| undefined>` | Point lookup; `undefined` on a clean miss. |
| `update` | `update(key, value): Promise<void>` | `BPF_ANY` — insert or overwrite. |
| `delete` | `delete(key): Promise<void>` | Rejects with `NotFound` if the key is absent. |
| `entries` | `entries({ batchSize? }): AsyncIterator<[key, value]>` | Cursor-paged async iteration over all pairs. |
| `updateBatch` | `updateBatch(pairs): Promise<void>` | Bulk insert of `[key, value]` pairs in one syscall (kernel ≥5.6); atomic per batch. |
| `deleteBatch` | `deleteBatch(keys): Promise<void>` | Bulk delete (kernel ≥5.6). |
| `lookupBatch` | `lookupBatch(count?): Promise<[key, value][]>` | Kernel-batched page fetch — fast, not resumable across calls (kernel ≥5.6). |
| `drainBatch` | `drainBatch(count?): Promise<[key, value][]>` | Atomic "fetch and delete up to `count`" (kernel ≥5.6). |
| `info` | `info(): { map_type, key_size, value_size, max_entries }` | Cached kernel map metadata. |

:::note Iteration under churn
Kernel iteration order is unstable across concurrent modification — entries inserted mid-walk may or may not appear, and entries deleted between the scan and a follow-up `lookup` are silently skipped. For correctness under churn, collect stale keys during `entries()` and delete them in a separate pass.
:::

#### Example {#bpf-hashmap-example}

Total requested sleep time per process. The `__type` annotations are what give JS its typed `__u32` keys and `__u64` values:

```c
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 1024);
	__type(key, __u32);
	__type(value, __u64);
} sleep_ns_by_pid SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
	struct __kernel_timespec ts;
	if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
		return 0;

	__u32 pid = bpf_get_current_pid_tgid() >> 32;
	__u64 ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;

	__u64 *total = bpf_map_lookup_elem(&sleep_ns_by_pid, &pid);
	if (total)
		__sync_fetch_and_add(total, ns);
	else
		bpf_map_update_elem(&sleep_ns_by_pid, &pid, &ns, BPF_NOEXIST);
	return 0;
}
```

```js
import probe from "./hashmap.bpf.o";
import { HashMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleep_ns_by_pid", { kind: "hashmap" }).start();
const totals = new HashMap(control, "sleep_ns_by_pid");

await sleep(1000);
console.log(await totals.info());

for await (const [pid, ns] of totals.entries()) {
  console.log(`pid ${pid} asked for ${ns / 1_000_000n}ms of sleep`);
}

await totals.update(0, 42n);
console.log("lookup(0) =", await totals.lookup(0));
await totals.delete(0);
console.log("after delete =", await totals.lookup(0));

await totals.updateBatch([[1_000_001, 1n], [1_000_002, 2n]]);
console.log("page:", await totals.lookupBatch(4));
console.log("drained:", (await totals.drainBatch(1024)).length, "entries");
console.log("left:", (await totals.lookupBatch(1024)).length);

await control.stop();
```

```
{ map_type: 'Hash', key_size: 4, value_size: 8, max_entries: 1024 }
pid 4004 asked for 10ms of sleep
pid 4054 asked for 50ms of sleep
pid 1237 asked for 0ms of sleep
lookup(0) = 42n
after delete = undefined
page: [ [ 1000002, 2n ], [ 1000001, 1n ], [ 4004, 10000000n ], [ 4054, 50000000n ] ]
drained: 5 entries
left: 0
```

`__u64` values are `BigInt`s in both directions — pass `42n`, not `42`. `drainBatch` fetches and deletes in one syscall, so the map is empty afterwards apart from anything the tracepoint re-inserted in between.

`LruHashMap` is a drop-in swap. Declare the map as `BPF_MAP_TYPE_LRU_HASH` with a small `max_entries`, bind with `kind: "lru_hashmap"`, and overfill it:

```js
import probe from "./lru_hashmap.bpf.o";
import { LruHashMap } from "yeet:bpf";

const control = await probe.bind("sleep_ns_by_pid", { kind: "lru_hashmap" }).start();
const totals = new LruHashMap(control, "sleep_ns_by_pid");

const { max_entries } = await totals.info();
const pairs = Array.from({ length: max_entries * 2 }, (_, i) => [i + 1, BigInt(i)]);
await totals.updateBatch(pairs);

let n = 0;
for await (const _ of totals.entries()) n++;
console.log(`inserted ${pairs.length}, map holds ${n} (max_entries ${max_entries})`);
console.log("oldest key survived?", (await totals.lookup(1)) !== undefined);
console.log("newest key survived?", (await totals.lookup(pairs.length)) !== undefined);

await control.stop();
```

```
inserted 128, map holds 64 (max_entries 64)
oldest key survived? false
newest key survived? true
```

A plain `HashMap` would have rejected the batch with `E2BIG`. The kernel keeps a little per-CPU slack in LRU maps, so the live count can sit a few entries under `max_entries`.

### <Obj /> ArrayMap {#bpf-array}

Wraps `BPF_MAP_TYPE_ARRAY`. The key is always a `__u32` index and the slot count is fixed at `max_entries`. The kernel rejects deletes on arrays, so there is **no** `delete`/`deleteBatch`/`drainBatch` — clear a slot by writing a zero value.

| Method | Signature | Description |
|---|---|---|
| `lookup` | `lookup(index): Promise<value \| undefined>` | Lookup by `__u32` index; `undefined` past `max_entries`. |
| `update` | `update(index, value): Promise<void>` | Write a slot; an index past `max_entries` rejects with `E2BIG`. |
| `entries` | `entries({ batchSize? }): AsyncIterator<[index, value]>` | Async iteration over all slots. |
| `updateBatch` | `updateBatch(pairs): Promise<void>` | Bulk write (kernel ≥5.6). |
| `lookupBatch` | `lookupBatch(count?): Promise<[index, value][]>` | Kernel-batched page fetch (kernel ≥5.6). |
| `info` | `info(): { map_type, key_size, value_size, max_entries }` | Kernel map metadata. |

This is distinct from the `.data`/`.rodata`/`.bss` arrays that back BPF globals — those use [`DataSec`](#bpf-datasec).

#### Example {#bpf-array-example}

A log2 histogram of requested sleep durations. Slots are fixed at build time, so an array is the natural shape:

```c
// Slot b counts sleeps of 2^b .. 2^(b+1) microseconds; slot 23 is 8s+.
struct {
	__uint(type, BPF_MAP_TYPE_ARRAY);
	__uint(max_entries, 24);
	__type(key, __u32);
	__type(value, __u64);
} sleep_hist SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
	struct __kernel_timespec ts;
	if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
		return 0;

	__u64 us = ts.tv_sec * 1000000ULL + (__u64)ts.tv_nsec / 1000;
	__u32 slot = 0;
	while (us > 1 && slot < 23) {
		us >>= 1;
		slot++;
	}

	__u64 *n = bpf_map_lookup_elem(&sleep_hist, &slot);
	if (n)
		__sync_fetch_and_add(n, 1);
	return 0;
}
```

```js
import probe from "./array.bpf.o";
import { ArrayMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleep_hist", { kind: "array" }).start();
const hist = new ArrayMap(control, "sleep_hist");

await sleep(1000);
console.log(await hist.info());

for await (const [slot, n] of hist.entries()) {
  if (n === 0n) continue;
  console.log(`${2 ** slot}µs..${2 ** (slot + 1)}µs: ${n}`);
}

console.log("slot 13 =", await hist.lookup(13));
await hist.update(13, 0n);
await hist.updateBatch([[0, 0n], [1, 0n]]);
console.log("page:", (await hist.lookupBatch(24)).length, "slots");

console.log("lookup(24) =", await hist.lookup(24));
try {
  await hist.update(24, 0n);
} catch (e) {
  console.log("update(24):", e.message);
}

await control.stop();
```

```
{ map_type: 'Array', key_size: 4, value_size: 8, max_entries: 24 }
2µs..4µs: 4
16µs..32µs: 36
8192µs..16384µs: 6
slot 13 = 6n
page: 24 slots
lookup(24) = undefined
update(24): Map operation failed: Argument list too long (os error 7)
```

Every slot exists from map creation, so `entries()` always yields exactly `max_entries` pairs and untouched slots read as `0n` — reset one by writing zero, since arrays can't delete.

### <Obj /> LpmTrie {#bpf-lpmtrie}

Wraps `BPF_MAP_TYPE_LPM_TRIE` — keys are `(prefixlen, data)` pairs and the kernel matches the longest prefix on lookup. Methods mirror [`HashMap`](#bpf-hashmap) (`lookup`, `update`, `delete`, `entries`, `updateBatch`, `deleteBatch`, `lookupBatch`, `info`) **except `drainBatch`**, which the kernel's trie ops don't implement — use `entries()` + `deleteBatch()` to drain.

Keys accept two shapes:

- A **string** — a full-length byte-prefix match. Each `charCodeAt(i)` becomes one byte, so input is interpreted as **ASCII / Latin-1**; multi-byte UTF-8 is not supported here.
- An explicit `{ prefixlen, data }` object — `prefixlen` in bits, `data` the byte array. Use this for non-ASCII keys.

#### Example {#bpf-lpmtrie-example}

Count `nanosleep` calls from processes whose name starts with a given prefix. The key is the standard LPM shape — `prefixlen` in bits, then the bytes — and the program looks its own `comm` up as a full-length key, so any stored prefix of it matches:

```c
struct comm_key {
	__u32 prefixlen;
	__u8 data[16];
};

struct {
	__uint(type, BPF_MAP_TYPE_LPM_TRIE);
	__uint(max_entries, 256);
	__type(key, struct comm_key);
	__type(value, __u64);
	__uint(map_flags, BPF_F_NO_PREALLOC);
} watched SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
	struct comm_key key = { .prefixlen = 8 * sizeof(key.data) };
	bpf_get_current_comm(key.data, sizeof(key.data));

	__u64 *calls = bpf_map_lookup_elem(&watched, &key);
	if (calls)
		__sync_fetch_and_add(calls, 1);
	return 0;
}
```

```js
import probe from "./lpm_trie.bpf.o";
import { LpmTrie } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("watched", { kind: "lpm_trie" }).start();
const watched = new LpmTrie(control, "watched");

// A string key is a byte prefix: every comm starting with "contain" matches.
await watched.update("contain", 0n);
await watched.update("docker", 0n);
// The explicit form — prefixlen in bits, data as bytes — spells "co".
await watched.update({ prefixlen: 16, data: [0x63, 0x6f] }, 0n);

await sleep(1000);
for await (const [key, calls] of watched.entries()) {
  const name = String.fromCharCode(...key.data.subarray(0, key.prefixlen / 8));
  console.log(`${name}/${key.prefixlen}: ${calls} nanosleeps`);
}

console.log("containerd ->", await watched.lookup("containerd"));
console.log("coredns    ->", await watched.lookup("coredns"));
console.log("bash       ->", await watched.lookup("bash"));

await watched.deleteBatch(["docker"]);
console.log("after delete ->", await watched.lookup("dockerd"));

await control.stop();
```

```
contain/56: 5 nanosleeps
co/16: 0 nanosleeps
docker/48: 0 nanosleeps
containerd -> 5n
coredns    -> 0n
bash       -> undefined
after delete -> undefined
```

`containerd` matched the longer `contain` entry rather than `co`; `coredns` fell through to `co`. Keys come back from `entries()` in the explicit shape: `data` is a `Uint8Array` padded to the slot width, so slice it by `prefixlen / 8` to recover the string.

For IP prefixes use the explicit form — a string like `"10.0.0.0"` would be matched as eight ASCII bytes, not an address:

```c
struct ipv4_key {
	__u32 prefixlen;
	__u8 data[4];
};
```

```js
await acl.update({ prefixlen: 8,  data: [10, 0, 0, 0] }, 1);
await acl.update({ prefixlen: 24, data: [10, 1, 2, 0] }, 2);

await acl.lookup({ prefixlen: 32, data: [10, 1, 2, 3] });      // 2 — the /24 wins
await acl.lookup({ prefixlen: 32, data: [10, 9, 9, 9] });      // 1 — falls back to the /8
await acl.lookup({ prefixlen: 32, data: [192, 168, 0, 1] });   // undefined
```

### <Obj /> BloomFilter {#bpf-bloom}

Wraps `BPF_MAP_TYPE_BLOOM_FILTER` — a probabilistic set with no keys and no removal. `contains` may report false positives but never false negatives.

| Method | Signature | Description |
|---|---|---|
| `insert` | `insert(value): Promise<void>` | Set the bits for `value`. Idempotent. |
| `contains` | `contains(value): Promise<boolean>` | `true` if `value` was likely inserted, `false` if definitely not. |
| `info` | `info(): { map_type, value_size, max_entries }` | Metadata (no `key_size` — bloom is keyless). |

#### Example {#bpf-bloom-example}

Remember which PIDs have ever called `nanosleep`. The BPF side inserts with `bpf_map_push_elem` (and would test with `bpf_map_peek_elem`); `map_extra` sets the number of hash functions:

```c
struct {
	__uint(type, BPF_MAP_TYPE_BLOOM_FILTER);
	__uint(max_entries, 1024);
	__type(value, __u32);
	__uint(map_extra, 3);
} sleepers SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
	__u32 pid = bpf_get_current_pid_tgid() >> 32;

	bpf_map_push_elem(&sleepers, &pid, BPF_ANY);
	return 0;
}
```

```js
import probe from "./bloom_filter.bpf.o";
import { BloomFilter } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleepers", { kind: "bloom_filter" }).start();
const sleepers = new BloomFilter(control, "sleepers");

await sleep(1000);
console.log(await sleepers.info());

console.log("pid 1 slept?", await sleepers.contains(1));
console.log("pid 4000000 slept?", await sleepers.contains(4_000_000));

await sleepers.insert(4_000_000);
console.log("after insert:", await sleepers.contains(4_000_000));

await control.stop();
```

```
{ map_type: 'BloomFilter', value_size: 4, max_entries: 1024 }
pid 1 slept? false
pid 4000000 slept? false
after insert: true
```

There is no way to list or remove members; `false` is definitive, `true` means "probably".

### <Obj /> Per-CPU maps {#bpf-percpu}

`PercpuHashMap` (`BPF_MAP_TYPE_PERCPU_HASH`), `LruPercpuHashMap` (`…_LRU_PERCPU_HASH`), and `PercpuArrayMap` (`…_PERCPU_ARRAY`) split values across CPUs:

- `lookup(key)` resolves to an **array of values**, one per possible CPU in CPU-index order (`undefined` on a miss). Aggregate on the caller side — e.g. `vals.reduce((a, b) => a + b, 0n)` for a per-CPU counter.
- `update(key, valuesPercpu)` takes an array whose length must equal `info().num_cpus`.
- `entries()` yields `[key, [v_cpu0, v_cpu1, …]]`.
- `info()` additionally reports `num_cpus`.

Kernel-batched paths (`lookupBatch`/`updateBatch`/`deleteBatch`/`drainBatch`) are **not** available on per-CPU maps — use `entries()` for full scans. The two hash variants also have `delete(key)`; the array variant does not (arrays can't delete). `LruPercpuHashMap` evicts on a full `update` like its non-percpu sibling, with per-CPU LRU bookkeeping.

#### Example: PercpuArrayMap {#bpf-percpu-array-example}

A single global counter with no cache-line contention. Because each CPU owns its slot, the program can bump it without an atomic:

```c
struct {
	__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
	__uint(max_entries, 1);
	__type(key, __u32);
	__type(value, __u64);
} sleeps SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
	__u32 zero = 0;

	__u64 *n = bpf_map_lookup_elem(&sleeps, &zero);
	if (n)
		*n += 1;
	return 0;
}
```

```js
import probe from "./percpu_array.bpf.o";
import { PercpuArrayMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleeps", { kind: "percpu_array" }).start();
const sleeps = new PercpuArrayMap(control, "sleeps");

await sleep(1000);
const info = await sleeps.info();
console.log(info);

const perCpu = await sleeps.lookup(0);
console.log("per-cpu:", perCpu);
console.log("total:", perCpu.reduce((a, b) => a + b, 0n));

await sleeps.update(0, new Array(info.num_cpus).fill(0n));
for await (const [slot, values] of sleeps.entries()) {
  console.log(`slot ${slot} reset, sum now ${values.reduce((a, b) => a + b, 0n)}`);
}

try {
  await sleeps.update(0, [0n]);
} catch (e) {
  console.log("wrong length:", e.message);
}

await control.stop();
```

```
{ map_type: 'PercpuArray', key_size: 4, value_size: 8, max_entries: 1, num_cpus: 16 }
per-cpu: [ 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 5n, 0n, 1n, 0n, 0n, 0n, 0n, 0n ]
total: 6n
slot 0 reset, sum now 0
wrong length: Map operation failed: Expected 16 per-CPU values, got 1
```

#### Example: PercpuHashMap / LruPercpuHashMap {#bpf-percpu-hash-example}

Per-process call counts where each CPU keeps its own tally. Same shape as the [`HashMap` example](#bpf-hashmap-example), minus the atomic:

```c
struct {
	__uint(type, BPF_MAP_TYPE_PERCPU_HASH);
	__uint(max_entries, 1024);
	__type(key, __u32);
	__type(value, __u64);
} sleeps_by_pid SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
	__u32 pid = bpf_get_current_pid_tgid() >> 32;
	__u64 one = 1;

	__u64 *n = bpf_map_lookup_elem(&sleeps_by_pid, &pid);
	if (n)
		*n += 1;
	else
		bpf_map_update_elem(&sleeps_by_pid, &pid, &one, BPF_NOEXIST);
	return 0;
}
```

```js
import probe from "./percpu_hashmap.bpf.o";
import { PercpuHashMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleeps_by_pid", { kind: "percpu_hashmap" }).start();
const counts = new PercpuHashMap(control, "sleeps_by_pid");

await sleep(1000);
const { num_cpus } = await counts.info();

for await (const [pid, perCpu] of counts.entries()) {
  const total = perCpu.reduce((a, b) => a + b, 0n);
  const busy = perCpu.filter((n) => n > 0n).length;
  console.log(`pid ${pid}: ${total} nanosleeps across ${busy}/${num_cpus} cpus`);
}

await counts.update(0, new Array(num_cpus).fill(1n));
console.log("lookup(0) =", await counts.lookup(0));
await counts.delete(0);
console.log("after delete =", await counts.lookup(0));

await control.stop();
```

```
pid 4054: 5 nanosleeps across 1/16 cpus
pid 1237: 14 nanosleeps across 1/16 cpus
lookup(0) = [ 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n ]
after delete = undefined
```

For the LRU variant declare `BPF_MAP_TYPE_LRU_PERCPU_HASH`, bind with `kind: "lru_percpu_hashmap"`, and construct `LruPercpuHashMap` — the script is otherwise identical.

### <Obj /> DataSec {#bpf-datasec}

Reads and writes the `.data` / `.rodata` / `.bss` sections that back the object's BPF globals.

libbpf exposes each section as a map named `<object>.<section>`, where `<object>` is the file name up to its first `.`, truncated to eight characters: `probe.bpf.o` yields `probe.data` / `probe.rodata` / `probe.bss`, while `execsnoop.bpf.o` yields `execsnoo.bss`. Bind and open the handle with that name:

```js
const control = await probe.bind("probe.bss", { kind: "data" }).start();
const bss = new DataSec(control, "probe.bss");
```

Which section a global lands in follows the usual C rules: initialised globals go to `.data`, zero-initialised ones to `.bss`, and `const volatile` ones to `.rodata`.

#### Seeding at start {#bpf-datasec-seed}

Any field on the bind spec other than `kind` is written into the global of that name before the program first runs — the daemon-side equivalent of setting a knob before load:

```js
await probe.bind("probe.data", { kind: "data", target_fd: 2 }).start();
```

Seed `.data` or `.bss` globals only. The kernel freezes `.rodata` when the object loads, so a `const volatile` global is readable but never writable from JS — seeding it fails `start()` with `Write Access Denied`, and `patch` rejects with `Symbol … is not writable`. Use a plain (non-`const`) global for anything you want to set from JS.

#### <Method /> DataSec.read {#bpf-datasec-read}

```ts
read(sym?: string): Promise<any>
```

With a symbol name, reads that global; with no argument, reads the whole section as a typed composite (`BigInt` for `__u64`, `Uint8Array` for `__u8[N]`, numbers / strings / nested objects / arrays for the rest). Returns `undefined` for a genuine miss.

#### <Method /> DataSec.patch {#bpf-datasec-patch}

```ts
patch(sym: string, value: any): Promise<void>
patch(values: object): Promise<void>
```

Partial update — only the leaves you name are written; unspecified bytes are left as-is. Atomic per call: if any field fails to lift, nothing changes. Pass a single `(sym, value)` to write one symbol (scalar or sub-tree), or an object to write several at once.

```js
const cfg = new DataSec(control, "probe.data");
await cfg.patch("target_pid", 1234);
await cfg.patch({ sample_rate: 100, enabled: 1 });
```

#### Example {#bpf-datasec-example}

Three globals in three sections: a read-only version stamp, a threshold the program reads, and counters it writes:

```c
const volatile __u32 version = 1;
__u64 long_sleep_ns = 1000000000;
__u64 calls = 0;
__u64 long_sleeps = 0;

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
	struct __kernel_timespec ts;
	if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
		return 0;

	__u64 ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
	__sync_fetch_and_add(&calls, 1);
	if (ns >= long_sleep_ns)
		__sync_fetch_and_add(&long_sleeps, 1);
	return 0;
}
```

```js
import probe from "./data.bpf.o";
import { DataSec } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe
  .bind("data.rodata", { kind: "data" })
  .bind("data.data", { kind: "data", long_sleep_ns: 10_000_000n })   // seeded before the first run
  .bind("data.bss", { kind: "data" })
  .start();

const rodata = new DataSec(control, "data.rodata");
const data = new DataSec(control, "data.data");
const bss = new DataSec(control, "data.bss");

console.log(await rodata.read(), await data.read());

await sleep(1000);
console.log("long sleeps:", await bss.read("long_sleeps"));
console.log(await bss.read());

await data.patch("long_sleep_ns", 1_000_000_000n);
await bss.patch({ calls: 0n, long_sleeps: 0n });
await sleep(1000);
console.log(await data.read(), await bss.read());

try {
  await rodata.patch("version", 2);
} catch (e) {
  console.log("rodata is frozen:", e.message);
}

await control.stop();
```

```
{ version: 1 } { long_sleep_ns: 10000000n }
long sleeps: 6n
{ calls: 8n, long_sleeps: 6n }
{ long_sleep_ns: 1000000000n } { calls: 45n, long_sleeps: 0n }
rodata is frozen: Failed to patch map: Symbol `version` is not writable
```

The seed lowered the threshold to 10ms before the program ever ran, so the first second counted six long sleeps; after the `patch` raised it back to a full second, none qualified. Reads and writes go straight to the section's memory, so a `patch` takes effect on the very next program run — no reload.

## Full example

```js
import probe from './execsnoop.bpf.o';
import { RingBuf, HashMap } from 'yeet:bpf';

const control = await probe
  .bind("events", { kind: "ringbuf", btf_struct: "exec_event", capacity: 8192 })
  .bind("counts", { kind: "hashmap" })   // per-pid exec counter
  .start();                              // tracepoint auto-attaches by section

const events = new RingBuf(control, "events");
const counts = new HashMap(control, "counts");

// records arrive keyed by the BTF struct name; fields keep their C types
const sub = await events.subscribe(({ exec_event: e }) => {
  console.log(`${e.comm} (pid ${e.pid}) exec'd ${e.filename}`);
});

// the isolate exits once the top-level module settles, so hold it open —
// here, until the first SIGINT-equivalent teardown you wire up. A bare
// setTimeout does NOT keep it alive; await a promise instead.
await new Promise(resolve => {
  setTimeout(async () => {
    for await (const [pid, n] of counts.entries()) console.log(pid, n);
    await sub.unsubscribe();
    await control.stop();
    resolve();
  }, 10_000);
});
```
