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_PATH — walk 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');