Skip to main content

yeet:btf

yeet:btf queries the kernel's BTF — the type information the running kernel carries about its own structs, unions, enums, and functions. Use it to look up a type's full layout, resolve a member's byte offset, or produce the exact deref-and-read plan to reach a deeply nested field. It is the type oracle a script (or an AI generating one) consults so it never hand-codes struct offsets or pointer arithmetic that break on the next kernel.

import btf from 'yeet:btf';

const t = await btf.type('task_struct');
console.log(t.kind, t.size, t.members.length); // struct 9472 251

const { offset, type } = await btf.offsetOf('task_struct', 'pid');
console.log(offset, type.name); // 2464 int

// The hops a bounded interpreter runs to read sock->sk_socket->file->f_inode->i_ino:
const plan = await btf.walk('sock', 'sk_socket.file.f_inode.i_ino');
console.log(plan.hops);
// [ { op: 'deref', at: 640, width: 8 },
// { op: 'deref', at: 40, width: 8 },
// { op: 'deref', at: 0, width: 8 },
// { op: 'load', at: 64, width: 8 } ]

Every op is asynchronous and answered daemon-side, off the V8 thread, against a single process-wide index parsed on first use from the running kernel (or a sidecar — see BTF source). The parse is shared by every isolate and unloaded when it goes idle, so a script that never touches BTF pays nothing.

note

The exact offsets and sizes above are illustrative — they are kernel- and config-specific. That is the whole point: query them at runtime rather than baking them into a script.

Importing

import btf, { type, expand, offsetOf, walk } from 'yeet:btf';
ExportKindDescription
typefunctionThe full description of a named type — layout, members, values
expandfunctionThe full description behind a nested reference — by id, or by name
offsetOffunctionA member's pointer-free byte offset within a type
walkfunctionThe deref-and-read plan to reach a member across pointers

The default export bundles all four (btf.type, btf.expand, btf.offsetOf, btf.walk).

Concepts

BTF source

The index is built from one of two sources, chosen by the daemon at startup:

SourceWhenLoaded from
Running kernel (default)no overridethe kernel's own vmlinux BTF
Custom sidecaryeetd --btf-custom-path <file>a .btf file or a vmlinux image with a .BTF section

Use --btf-custom-path to query a kernel other than the host's — a target you are building a probe for, a stripped kernel with BTF shipped separately, or a captured vmlinux for reproducible offsets in a test. The same path also seeds libbpf's CO-RE relocations for .bpf.o loads, so a probe and its yeet:btf queries agree on one type universe.

Type names and kinds

A query names a type by its BTF name (task_struct, sk_buff, pid_t). A name can be shared across kinds — a struct foo and a typedef foo, say — so each query accepts an optional kind to disambiguate. Omit it when the name is unique; supply it when a bare name is AMBIGUOUS_TYPE.

await btf.type('pid_t');                    // unique → the typedef
await btf.type('sigval', { kind: 'union' }); // pin the union over the fwd/typedef

Every type carries a kind tag, one of:

void · int · float · ptr · array · struct · union · enum · fwd · typedef · const · volatile · restrict · typetag · func · funcproto · var · datasec · other

The kind decides which fields a TypeDesc carries: members for a struct/union, values for an enum, elem/nelems for an array, params/ret for a function prototype, target for anything that points at or wraps one other type (a pointer, typedef, or qualifier).

One-level references

To keep a result from dragging a type's entire transitive definition across the wire, a nested type appears as a lightweight TypeRef — just { id, kind, name, size }, not the full body. Pass the ref's id to expand to open it:

const t = await btf.type('task_struct');
const seRef = t.members.find(m => m.name === 'se').type; // TypeRef, kind "struct"
const se = await btf.expand(seRef.id); // expand it → full sched_entity

This is how a walk over a struct's whole embedded closure descends: kernel structs are full of anonymous nested unions and structs (name absent — sock_common alone holds eight anonymous unions), and with no name to re-query, the id is the only handle a branch has. expand takes named refs too — by id, or by the name itself as a convenience — but the id path is the one that always works, and being unambiguous it never needs a kind hint.

The access plan

walk is the reason this module exists. Given a dotted member path, it returns the ordered hops a bounded interpreter — a small, verifiable read machine like the one in a walk-VM probe — executes to arrive at the field:

  • deref — read a pointer of width bytes at offset at, follow it, and open a fresh offset window at the target.
  • load — the terminal scalar read: width bytes at offset at in the current window.
  • field — a terminal that lands on an aggregate (a struct/array with no single scalar to read); only its at offset is given.

Because the daemon composes the pointer arithmetic, the interpreter never does — it just runs the hops. That is what lets an AI discover a kernel's layout and emit a correct, bounded probe DSL without embedding brittle offsets.

const plan = await btf.walk('task_struct', 'mm.owner.comm');
for (const h of plan.hops) {
// feed each hop to your read machine
}
console.log(plan.type.name); // resolved terminal type

offsetOf vs walk

offsetOf is the shortcut for the common case: a member reachable without crossing a pointer, so a single byte offset answers it. It rejects POINTER_IN_PATH the moment a path would dereference — reach for walk there.

await btf.offsetOf('task_struct', 'se.sum_exec_runtime'); // { offset, type } — all in one object
await btf.offsetOf('task_struct', 'mm.owner'); // rejects POINTER_IN_PATH (mm is a pointer)
await btf.walk('task_struct', 'mm.owner'); // the deref chain instead

A walk whose path never crosses a pointer collapses to a single load or field hop — the same offset offsetOf would return.

Bitfields

A member declared as a C bitfield (unsigned flag : 1) can't be addressed by byte offset alone. Both walk and a struct member's MemberDesc carry a BitField with bit_offset and bit_size. In a walk plan the terminal load reads the member's aligned storage unit and bit_offset is relative to that unit, so a high bit position never pushes the read past the type — mask and shift the loaded word to extract the value.

const plan = await btf.walk('task_struct', 'flags'); // depends on the kernel's task_struct
if (plan.bitfield) {
const { bit_offset, bit_size } = plan.bitfield;
const mask = ((1n << BigInt(bit_size)) - 1n) << BigInt(bit_offset);
}

Caching and lifetime

The first query parses the whole kernel BTF into a compact, shared index (~5 MiB for a full vmlinux) and every later query is answered from it. After a spell with no queries the index is unloaded and its memory returned; the next query rebuilds it transparently. Nothing to open or close — the module is stateless from a script's point of view.


F type

type(name: string, opts?: { kind?: string }): Promise<TypeDesc>

The full TypeDesc of the named type: its kind, size, and the kind-specific body (members, enum values, array element, function signature, or referent). Pass kind to disambiguate a name shared across kinds. Rejects UNKNOWN_TYPE (with near-miss suggestions) when no type matches, or AMBIGUOUS_TYPE when several do and no kind was given.

const s = await btf.type('sk_buff');
console.log(s.size, s.members.map(m => m.name));

const e = await btf.type('bpf_prog_type');
console.log(e.values.slice(0, 3)); // [{ name: 'BPF_PROG_TYPE_UNSPEC', value: 0 }, …]

F expand

expand(target: number | string): Promise<TypeDesc>

The full TypeDesc behind a nested TypeRef — the re-expansion step for walking beyond one level. A number is a ref's id, the only way into an anonymous struct or union (which has no name for type to resolve); ids come from results (member.type.id, desc.target.id, desc.elem.id) and are stable for the lifetime of the loaded index, so memoize by id when walking a closure. A string is accepted as a convenience for named refs — it resolves like type but takes no kind hint, so a name shared across kinds rejects AMBIGUOUS_TYPE; reach for type there. An id outside the index rejects UNKNOWN_ID.

const sc = await btf.type('sock_common');
const anon = sc.members.filter(m => !m.name); // inline unions — no name to re-query
const u = await btf.expand(anon[0].type.id);
console.log(u.members.map(m => `${m.name}@${m.offset}`)); // ['skc_addrpair@0', …]

F offsetOf

offsetOf(type: string, path: string): Promise<FieldOffset>

The pointer-free byte offset of the dotted member path within type, plus a TypeRef to the member's resolved type, as a FieldOffset. Anonymous unions and structs along the path are transparent — name only the members you care about. Rejects POINTER_IN_PATH if the path crosses a pointer (use walk), NO_MEMBER (listing the siblings) for an unknown segment, or NOT_COMPOSITE when a segment isn't a struct/union.

const { offset, type } = await btf.offsetOf('task_struct', 'se.sum_exec_runtime');
// offset is the byte displacement; type is a TypeRef to u64 (or similar)

F walk

walk(type: string, path: string): Promise<LoadPlan>

The full access plan to reach path from type: the ordered hops a bounded interpreter runs, the resolved terminal type, and a bitfield position when the target is a bitfield. Each crossed pointer becomes a deref hop that opens a fresh offset window; the walk ends in a load (scalar) or field (aggregate) hop. See The access plan. Rejects with the same path errors as offsetOf except POINTER_IN_PATHwalk is the pointer-crossing form.

const plan = await btf.walk('sock', 'sk_socket.file.f_inode.i_ino');
// plan.hops → [deref, deref, deref, load]; plan.type → the i_ino type

Data types

I TypeDesc

The full description of one type, returned by type and expand. Fields irrelevant to the kind are omitted.

interface TypeDesc {
id: number; // this type's BTF id
kind: string; // see Type names and kinds
name?: string; // absent for an anonymous type
size?: number; // byte size, when the kind has one
encoding?: IntEncoding; // int — how the integer reads
members?: MemberDesc[]; // struct / union
values?: EnumValue[]; // enum
target?: TypeRef; // ptr / typedef / const / volatile / restrict / typetag / func → its referent
params?: ParamDesc[]; // funcproto
ret?: TypeRef; // funcproto return type
elem?: TypeRef; // array element type
nelems?: number; // array element count
}

I TypeRef

A lightweight one-level reference to a type — enough to name it in a member list or a result without its full body. Re-query by name to expand it.

interface TypeRef {
id: number; // the referenced type's BTF id
kind: string;
name?: string; // absent for an anonymous type
size?: number;
}

I IntEncoding

How an int type reads, carried on its TypeDesc as independent flags — test a field rather than matching a string. An unsigned integer has all three false.

interface IntEncoding {
signed: boolean;
bool: boolean; // C _Bool
char: boolean;
}

I MemberDesc

One member of a struct or union in a TypeDesc.

interface MemberDesc {
name?: string; // absent for an anonymous member
offset: number; // byte offset within the aggregate
bitfield?: BitField; // present only for bitfield members
type: TypeRef; // the member's type
}

I ParamDesc

One parameter of a function prototype (kind: "funcproto").

interface ParamDesc {
name?: string; // absent for an unnamed parameter
type: TypeRef;
}

I EnumValue

One enumerator of an enum (kind: "enum").

interface EnumValue {
name: string;
value: number;
}

I FieldOffset

Returned by offsetOf.

interface FieldOffset {
offset: number; // pointer-free byte offset of the member
type: TypeRef; // the member's resolved type
}

I LoadPlan

Returned by walk.

interface LoadPlan {
hops: Hop[]; // run in order to reach the field
type: TypeRef; // the resolved terminal type
bitfield?: BitField; // present only when the target is a bitfield
}

I Hop

One step of an access plan, tagged by op. See The access plan.

type Hop =
| { op: 'deref'; at: number; width: number } // read a pointer at `at`, follow it, open a fresh window
| { op: 'load'; at: number; width: number } // terminal scalar read of `width` bytes at `at`
| { op: 'field'; at: number }; // terminal aggregate — offset only, nothing to read

at is always relative to the current window: a fresh window opens after each deref, so offsets restart from 0 following one.

I BitField

A bitfield's position within its loaded storage unit. See Bitfields.

interface BitField {
bit_offset: number; // bits from the start of the loaded unit
bit_size: number; // width in bits
}

Errors

Every function rejects with a plain object — not an Error — of the shape { code, message }. Match on code, not on the message string (the message is human-facing and carries repair hints — near-miss type names, the available members at a failing path segment):

try {
await btf.offsetOf('task_struct', 'mm.owner');
} catch (e) {
if (e.code === 'POINTER_IN_PATH') {
const plan = await btf.walk('task_struct', 'mm.owner'); // recover with the deref chain
}
}
codeRaised when
INVALID_ARGSAn argument is missing or the wrong type (a non-string name, type, or path; an expand target that is neither an id nor a name)
UNKNOWN_TYPENo type matches the name (message suggests near-miss names)
UNKNOWN_IDexpand was given an id outside the loaded index
AMBIGUOUS_TYPEThe name is shared across kinds and no kind was given
NO_MEMBERA path segment names no member of its container (message lists the siblings)
NOT_COMPOSITEA path segment tries to descend into a non-struct/union
POINTER_IN_PATHoffsetOf was given a path that crosses a pointer — use walk
OFFSET_OVERFLOWA member's bit offset exceeds the index's 24-bit packing limit (a malformed or pathological type)
BTF_LOAD_FAILEDThe kernel BTF (or the --btf-custom-path sidecar) could not be loaded or parsed
SERIALIZE_FAILEDA result could not be serialized back to the isolate

Recipes

Build a deref plan for a bounded interpreter

import btf from 'yeet:btf';

// Turn a human-readable field path into hops a read machine can run.
const plan = await btf.walk('task_struct', 'mm.owner.comm');
const program = plan.hops.map(h =>
h.op === 'deref' ? ['DEREF', h.at, h.width]
: h.op === 'load' ? ['LOAD', h.at, h.width]
: ['FIELD', h.at]
);
// hand `program` to your walk-VM probe

Resolve a member offset without crossing a pointer

const { offset } = await btf.offsetOf('sk_buff', 'len');
// `offset` is the byte displacement to read `len` directly off an sk_buff pointer

Enumerate a struct's members

const t = await btf.type('inode');
for (const m of t.members) {
console.log(`${m.offset}\t${m.name}\t${m.type.name ?? '(anon ' + m.type.kind + ')'}`);
}

Classify every scalar in a struct's embedded closure

// One expand per distinct id, memoized — anonymous unions flatten into
// their parent at an accumulated offset, and encoding flags decide the
// read: signed, boolean, or plain unsigned.
const seen = new Map();
const open = (id) => seen.get(id) ?? seen.set(id, btf.expand(id)).get(id);

const QUALIFIERS = new Set(['typedef', 'const', 'volatile', 'restrict', 'typetag']);
async function peel(id) {
let d = await open(id);
while (QUALIFIERS.has(d.kind) && d.target) d = await open(d.target.id);
return d;
}

async function scalars(desc, base = 0, out = []) {
for (const m of desc.members ?? []) {
if (m.bitfield) continue;
const inner = await peel(m.type.id);
if (!m.name && (inner.kind === 'union' || inner.kind === 'struct')) {
await scalars(inner, base + m.offset, out); // flatten the anonymous member
} else if (inner.kind === 'int') {
out.push({ name: m.name, off: base + m.offset, size: inner.size, ...inner.encoding });
}
}
return out;
}

console.log(await scalars(await btf.type('sock_common')));

Discover an enum's values

const e = await btf.type('bpf_map_type');
const byName = Object.fromEntries(e.values.map(v => [v.name, v.value]));
console.log(byName.BPF_MAP_TYPE_HASH);

Disambiguate a name shared across kinds

try {
await btf.type('sigval');
} catch (e) {
if (e.code === 'AMBIGUOUS_TYPE') {
await btf.type('sigval', { kind: 'union' });
}
}

Query a target kernel other than the host

# start the daemon against a captured or shipped BTF
yeetd --btf-custom-path ./vmlinux-6.1-arm64.btf
// queries now answer against that kernel's types, matching your .bpf.o CO-RE loads
const plan = await btf.walk('sk_buff', 'dev.name');