yeet:sym
yeet:sym inspects the symbols and debug information of a binary, the running kernel, or a live process. Use it to enumerate a symbol table, resolve a name to an address (or an address back to a name, file, and line), demangle C++/Rust names, and read DWARF type signatures for decoding function arguments.
import { Inspector } from 'yeet:sym';
const insp = await Inspector.open('libssl.so');
const [hit] = await insp.find('SSL_read');
console.log(hit.name, hit.file_offset); // SSL_read 261856
const frame = await insp.symbolize(hit.addr);
console.log(frame.name, frame.file, frame.line); // source location, if the binary has DWARF
await insp.close();
Parsing happens daemon-side, off the V8 thread, and every parse is cached and shared process-wide — a second open() of the same file (from any isolate) is served from memory. The heavy operations are asynchronous; only demangle is synchronous.
Importing
import { Inspector, demangle } from 'yeet:sym';
| Export | Kind | Description |
|---|---|---|
Inspector | class | Opens a target and answers queries against it |
demangle | function | Pure, synchronous name demangling |
Concepts
Sources
An Inspector is opened over one of three sources, each with its own constructor:
| Source | Constructor | Enumerable? | Symbolize input | DWARF queries |
|---|---|---|---|---|
| ELF file | Inspector.open | yes | virtual address or file offset | yes (with debugSyms) |
| Kernel | Inspector.kernel | yes (kallsyms) | absolute address | no |
| Process | Inspector.process | no (symbolize-only) | absolute (process-virtual) address | no |
Target resolution
The path passed to Inspector.open is resolved daemon-side, the same way a uprobe attach target is, so a bare soname lands on the real file:
- An absolute path is used as-is (it must exist).
- A bare name such as
libssl.sois resolved through/etc/ld.so.cache— the loader's own cache, which already accounts for multiarch directories and per-distro layout. A name matches exactly or as a versioned form (libssl.so→libssl.so.3). - The run's
-M/ mapping overrides are applied first, so a mapped name lands on the mapped file.
Non-soname targets (.a archives, JIT-dumped images, executables outside the cache) must be passed as absolute paths.
Debug symbols and separate debug info
By default an inspector reads only the ELF symbol table (.symtab / .dynsym) — fast, and enough for name↔address work. Pass debugSyms: true to additionally parse DWARF, which is required for source-level symbolize (file:line and inlined frames) and for signature / expandType.
When the target is stripped of its own .debug_* sections, DWARF queries follow a separate debug file the way GDB does:
- by build-id —
.note.gnu.build-id→/usr/lib/debug/.build-id/ab/cdef….debug - by name —
.gnu_debuglink→ a sibling file, a.debug/subdirectory, or under/usr/lib/debug
symbolize (via blazesym) always follows separate debug files, but signature / expandType read DWARF directly. Networked lookup (debuginfod) and .dwo/split-DWARF are not followed — on a binary whose debug info is only reachable that way, symbolize may return file:line while signature returns null.
Addresses and BigInt
Kernel and process addresses routinely exceed 2^53, so numeric fields (a record's addr / size / file_offset, and the arguments to symbolize) are number | bigint: values that fit a JS number arrive as one, larger values arrive as BigInt. Kernel addresses are effectively always BigInt.
const kernel = await Inspector.kernel();
const [vfs] = await kernel.find('vfs_read');
await kernel.symbolize(vfs.addr + 4n); // use BigInt arithmetic on kernel addresses
JSON.stringify throws on BigInt; use a replacer if you serialize records.
Address kinds
For an ELF inspector, symbolize / symbolizeMany interpret their input per an input option:
input | Meaning | Source of the value |
|---|---|---|
"virt" (default) | link-time virtual address | a record's addr |
"fileOffset" | raw file offset | a record's file_offset (the value a uprobe attaches at) |
Kernel and process inspectors take absolute runtime addresses and ignore input.
Caching and lifetime
Each open source is parsed once and mounted on the calling isolate under an opaque handle. The parsed table (and any DWARF / symbolizer built for it) is shared with every other live holder of the same source and freed when the last one is dropped.
Always close() an inspector when done to free the daemon-side parse promptly. As a safety net a FinalizationRegistry releases it at GC, but GC timing is not guaranteed.
F demangle
demangle(name: string): string | null
Demangles a raw ELF symbol name to a readable form, or returns null if it is not a recognized mangled name (an already-plain name, a data symbol). Pure and synchronous — no I/O, runs in-isolate.
Rust is tried first (legacy _ZN…17h<hash>E and v0 _R…, with the trailing hash dropped), then Itanium C++.
import { demangle } from 'yeet:sym';
demangle('_ZN4core3fmt5write17h05af8562b5e29e21E'); // "core::fmt::write"
demangle('_Z3fooi'); // "foo(int)"
demangle('SSL_read'); // null
O Inspector
An opened target. Construct one with a static method — the constructor is private. All instance methods are asynchronous except where noted, and reject with a { code, message } object.
F Inspector.open
static open(
path: string,
opts?: { debugSyms?: boolean; onProgress?: (loaded: number) => void }
): Promise<Inspector>
Opens an ELF file (executable or shared object). path is resolved daemon-side. debugSyms additionally parses DWARF (far slower — only worth it for source-level symbolize or signature). onProgress is called with the running symbol count while the table loads, and once more with the final count.
const insp = await Inspector.open('/usr/lib/x86_64-linux-gnu/libc.so.6', {
debugSyms: false,
onProgress: n => process_ui(`${n} symbols…`),
});
F Inspector.kernel
static kernel(opts?: { onProgress?: (loaded: number) => void }): Promise<Inspector>
Opens the running kernel's symbol table from /proc/kallsyms — the core kernel, loaded modules, and JITed BPF programs. symbolize / symbolizeMany take absolute kernel addresses (a sampled PC, a stack frame). DWARF queries are unavailable.
const kernel = await Inspector.kernel();
Reading kallsyms addresses requires root or kptr_restrict=0; otherwise every address reads as zero and kernel() rejects with a SYM_FAILED error explaining the restriction. Modules loaded after the table is parsed appear only once a fresh kernel() re-parses.
F Inspector.process
static process(pid: number, opts?: { debugSyms?: boolean }): Promise<Inspector>
Opens a live process for symbolization only. symbolize / symbolizeMany take virtual addresses as the process sees them; the daemon routes each through /proc/<pid>/maps, so ASLR, every mapped object (deleted binaries included, via map_files), the vDSO, and perf-map JIT symbols are handled without folding offsets by hand. debugSyms resolves file:line and inline frames through each object's debug info.
symbols and find see an empty table (there is no single enumerable table across every mapped object — enumerate a specific object with Inspector.open), and DWARF queries reject with SYM_FAILED.
const proc = await Inspector.process(1234, { debugSyms: true });
const frame = await proc.symbolize(0x7f9c0a12b4f0n); // an address seen in that process
M symbols
symbols(opts?: { pageSize?: number }): AsyncIterableIterator<SymbolRecord>
Every symbol in the table, as an async iterator so an enumerate-all never crosses the wire as one oversized frame. The daemon byte-caps each page, so a page may be shorter than pageSize (default 512). Yields SymbolRecord objects.
for await (const s of insp.symbols()) {
if (s.kind === 'function') console.log(s.demangled ?? s.name);
}
// or collect all
const all = await Array.fromAsync(insp.symbols());
M find
find(query: string | RegExp, opts?: { exact?: boolean }): Promise<SymbolRecord[]>
All symbols matching query (see Query matching), collected into an array. Convenient for queries whose result set fits comfortably in memory; for a broad match, prefer findEach and stop early.
const reads = await insp.find(/read/i); // regex fan-out
const [malloc] = await insp.find('malloc'); // base-path match
const exact = await insp.find('Foo::bar(int)', { exact: true });
M findEach
findEach(query: string | RegExp, opts?: { exact?: boolean }): AsyncIterableIterator<SymbolRecord>
Like find, but yields matches one at a time so a broad match never buffers the whole result set (paged internally, one page in memory at a time).
for await (const s of insp.findEach(/^SSL_/)) {
if (looksInteresting(s)) { attachTo(s); break; }
}
M mangle
mangle(query: string | RegExp, opts?: { exact?: boolean }): Promise<string>
Resolves query to the single raw (mangled) symbol name it names — the inverse of demangle, useful for pinning a probe to the exact linkage name. Rejects with AMBIGUOUS_SYMBOL (naming the leading candidates) when more than one symbol matches, or SYMBOL_NOT_FOUND when none does.
await insp.mangle('core::fmt::write'); // "_ZN4core3fmt5write17h….E"
M symbolize
symbolize(
addr: number | bigint,
opts?: { input?: 'virt' | 'fileOffset' }
): Promise<SymFrame | null>
Resolves the symbol containing addr, or null if it lands in no known symbol. See Address kinds for input (ELF only; kernel/process take absolute addresses). With debugSyms the result carries file:line and the inlined-frame chain. Returns a SymFrame.
const f = await insp.symbolize(record.addr); // ELF virtual (default)
const g = await insp.symbolize(record.file_offset, { input: 'fileOffset' });
const k = await (await Inspector.kernel()).symbolize(0xffffffff81abc000n);
M symbolizeMany
symbolizeMany(
addrs: (number | bigint)[],
opts?: { input?: 'virt' | 'fileOffset' }
): Promise<(SymFrame | null)[]>
Batch form of symbolize, resolved in one round-trip through a persistent symbolizer — far cheaper than one call per address when resolving many frames (a whole flame graph, a stack). The result is aligned with addrs; an entry is null where nothing resolved. Same input semantics as symbolize.
const frames = await insp.symbolizeMany(stackIps); // kernel/process: absolute
const defs = await insp.symbolizeMany(offsets, { input: 'fileOffset' });
M signature
signature(symbol: string, opts?: { addr?: number | bigint }): Promise<FunctionSig | null>
The DWARF signature of a function — parameter names and resolved types, plus the return type. Requires debugSyms: true on open. Returns null when the binary has no debug entry for the symbol (stripped, or not a defined subprogram). Returns a FunctionSig.
symbol matches any name the binary records the function under — a raw ELF name (SSL_read), a mangled linkage name, or a readable demangled form (Foo::bar or Foo::bar(int)) — so no hand-mangling is needed. A C++ method's implicit this parameter is omitted.
When a base path like Foo::bar names several overloads, or a static C name repeats across compilation units, pass addr (a record's addr) to pin the exact one by its entry address; otherwise a defining entry is preferred over a bare declaration, falling back to the first in unit order.
const insp = await Inspector.open('/path/to/prog', { debugSyms: true });
const sig = await insp.signature('process');
// { name: "process", params: [{ name, type }, …], ret: { kind, … } }
// disambiguate an overload by address
const [rec] = await insp.find('Renderer::draw');
const exact = await insp.signature('Renderer::draw', { addr: rec.addr });
M expandType
expandType(key: string): Promise<Type | null>
Re-expands a truncated type branch by the key handed out in a signature result. Aggregates (struct / union) expand only a few levels deep before returning a stub carrying a key; call expandType(key) to drill further into that branch on demand. This keeps self-referential and giant types bounded on the wire.
const sig = await insp.signature('handle_request');
const arg0 = sig.params[0].type; // e.g. a pointer to a struct
if (arg0.kind === 'pointer' && arg0.pointee.key) {
const full = await insp.expandType(arg0.pointee.key);
}
A key is tied to the parse that produced it. One persisted across a close() and a re-open() of a recompiled binary is rejected with STALE_TYPE_KEY rather than decoding the wrong type — re-run signature to get fresh keys.
M close
close(): Promise<void>
Frees the daemon-side parse now rather than at GC. Idempotent; cancels the finalizer so the parse is never freed twice. Calling any other method after close() throws.
try {
const insp = await Inspector.open('libssl.so');
// …
} finally {
await insp.close();
}
Query matching
find, findEach, and mangle accept a string or a RegExp. The matching mode follows the argument type and the exact option:
| Query | Mode | Matches |
|---|---|---|
"malloc" | base path (default) | the demangled base path — signature and hash stripped — or the raw name |
"Foo::bar(int)" with { exact: true } | exact | the full demangled string, or the raw name, verbatim |
/re/flags | regex | the pattern against the demangled name and the raw name |
The base path is the callable path with the C++ argument signature removed: Foo::bar(int, char const*) → Foo::bar. A Rust name is already hash- and signature-free, so it is unchanged. Base matching normalizes both sides, so find("Foo::bar") and find("Foo::bar(int)") both match the symbol Foo::bar(int) — and match all its overloads (that is why mangle can report AMBIGUOUS_SYMBOL).
RegExp flags i, m, and s are honored; g / y / u / d are meaningless for a membership test and ignored.
await insp.find('Foo::bar'); // every Foo::bar overload
await insp.find('Foo::bar(int)', { exact: true }); // only that overload
await insp.find(/^napi_/); // all N-API entry points
Data types
I SymbolRecord
A resolved symbol from a table, as yielded by symbols and find.
interface SymbolRecord {
name: string; // raw (possibly mangled) ELF name
demangled: string | null; // readable form, or null if not mangled
alias_of: string | null; // preferred name at this address, if this is an alias
addr: number | bigint; // symbol virtual address
size: number | bigint | null; // size in bytes, if known
file_offset: number | bigint | null; // offset within the object (the uprobe-attach value)
kind: SymKind;
}
alias_of names the preferred symbol at the same address when this record is an alias of it (e.g. __libc_malloc aliasing malloc). Kallsyms records have file_offset: null. See Addresses and BigInt for the numeric fields.
I SymKind
type SymKind = 'function' | 'variable' | 'unknown';
Text symbols are function; data/bss/rodata are variable; anything else (absolute, undefined, ELF markers) is unknown.
I SymFrame
A resolved address, as returned by symbolize / symbolizeMany.
interface SymFrame {
name: string; // enclosing function (raw)
demangled: string | null;
addr: number | bigint; // symbol start address
offset: number | bigint; // offset of the queried address into the symbol
dir: string | null; // compilation directory (DWARF only)
file: string | null; // source file as the compiler recorded it (DWARF only)
line: number | null; // source line (DWARF only)
inlined: InlineFrame[]; // inlined frames collapsed here, innermost first
}
dir / file / line and inlined are populated only when the source was opened with debugSyms and carries DWARF.
I InlineFrame
interface InlineFrame {
name: string;
demangled: string | null;
dir: string | null;
file: string | null;
line: number | null;
}
I FunctionSig
Returned by signature.
interface FunctionSig {
name: string;
params: { name: string; type: Type }[];
ret: Type;
}
I Type
A resolved C/C++ type — a tagged union discriminated by kind. Appears in FunctionSig and expandType.
type Type =
| { kind: 'base'; name: string; size: number; enc: Encoding }
| { kind: 'pointer'; pointee: Type }
| { kind: 'struct'; name: string | null; size: number; members: Member[]; truncated: boolean; key: string | null }
| { kind: 'union'; name: string | null; size: number; members: Member[]; truncated: boolean; key: string | null }
| { kind: 'array'; elem: Type; count: number | null }
| { kind: 'enum'; name: string | null; size: number; values: { name: string; value: number }[] }
| { kind: 'void' }
| { kind: 'unknown'; tag: string };
type Encoding = 'int' | 'uint' | 'float' | 'bool' | 'char' | 'uchar';
base— a scalar.encclassifies it;sizeis in bytes.pointer— the pointee's leaf shape is always resolved (achar *never degrades tovoid *deep in a struct), but an aggregate pointee may itself betruncatedwith akey.struct/union—memberscarries byte offsets. Whentruncatedistruethe members were cut at the depth limit (or a cycle); passkeytoexpandTypeto expand that branch.array—countis the element count when the bound is known.enum—valueslists the enumerators.unknown— a DIE tag not modeled here;tagis its DWARF tag name.
I Member
interface Member {
name: string;
offset: number; // byte offset within the aggregate
type: Type;
bit_offset?: number; // present for bitfields
bit_size?: number; // present for bitfields
}
The offset is exactly the OFF(n) a struct-walking argument decoder adds to the base pointer.
Errors
Every asynchronous method rejects with a plain object — not an Error — of the shape { code, message }. Match on code, not on the string:
try {
await insp.mangle('Vec::push');
} catch (e) {
if (e.code === 'AMBIGUOUS_SYMBOL') console.log(e.message);
}
code | Raised when |
|---|---|
INVALID_ARGS | An argument is missing or the wrong type (bad pid, non-string path, bad input) |
TARGET_NOT_FOUND | path could not be resolved to an on-disk file |
SYMBOL_NOT_FOUND | mangle matched no symbol |
AMBIGUOUS_SYMBOL | mangle matched more than one symbol (message names the leading candidates) |
STALE_TYPE_KEY | expandType got a key from a superseded parse — re-query signature |
INSPECTOR_NOT_FOUND | The handle is unknown (used after close(), or the isolate was torn down) |
SYM_FAILED | Parse or query failure (e.g. kallsyms restricted, DWARF unavailable for a kernel/process source, malformed input) |
MOUNT_FAILED | The daemon could not mount the parse on the isolate |
SERIALIZE_FAILED | A result could not be serialized to the isolate |
Using an inspector after close() throws synchronously ("Inspector used after close()").
Related: uprobe symbol matching
The same matching engine backs uprobe attach in yeet:bpf. A uprobe attach spec accepts a symbol and a match mode:
match | Behavior |
|---|---|
"raw" | Hand symbol to libbpf verbatim (literal ELF name) |
"base" | Match the demangled base path, attach by resolved file offset |
"exact" | Match the full demangled name |
"regex" | Fan out to one probe per matching function |
A plain-string symbol defaults to "base"; a RegExp becomes "regex". See the eBPF reference for the full attach spec.
Recipes
Resolve a name to a uprobe attach offset
const insp = await Inspector.open('libssl.so');
const [sym] = await insp.find('SSL_write');
// sym.file_offset is the value a uprobe attaches at
await insp.close();
Symbolize a captured user stack
const insp = await Inspector.process(pid);
const frames = await insp.symbolizeMany(stackIps); // absolute process addresses
const trace = frames.map(f => f ? (f.demangled ?? f.name) : '???');
await insp.close();
Decode a function's first argument for a tracer
const insp = await Inspector.open('/opt/app/server', { debugSyms: true });
const sig = await insp.signature('handle_request');
let arg0 = sig.params[0].type;
if (arg0.kind === 'pointer') arg0 = arg0.pointee;
if (arg0.kind === 'struct') {
for (const m of arg0.members) console.log(m.name, '@', m.offset, m.type.kind);
}
await insp.close();
Kernel address → name
const kernel = await Inspector.kernel();
const frame = await kernel.symbolize(0xffffffff81abc000n);
console.log(frame ? frame.name : 'unknown');
await kernel.close();