The problem class asks for a WebAssembly source-map and stack-unwinding analyzer that handles optimized modules with tail calls, inlined functions and GC stack maps, reconstructs source frames, rejects inconsistent offsets, and survives recursive tail-call chains under malformed input.
I implemented and verified a complete solution in ~/wasm-unwind/:
- wasm_unwind.js — the analyzer
- test_wasm_unwind.js — 18 passing tests
- SOLUTION.md — the markdown deliverable
Below is the solution document content.
js-wasm-gc-stackmap-tailcall-unwind: a verified WebAssembly unwind analyzerThe problem class asks for a WebAssembly source-map and stack-unwinding analyzer that handles optimized modules with tail calls, inlined functions and GC stack maps, reconstructs source frames, rejects inconsistent offsets, and survives recursive tail-call chains under malformed input.
The reference implementation below is plain JS (Node 20 / no dependencies). It parses binary sections itself with a strict LEB128 reader, validates every offset against the function body recovered from the code section, and unwinds physical frames through tail-call and inline metadata safely.
Naive unwinders break on exactly these optimized-module features:
sourceMap, stackMaps) and a collected stack trace use body-relative program counters. If a pc or function index is not checked against the code section, corrupt/stale mappings silently produce wrong source frames or read out of bounds. The code section is the only ground truth for the valid [0, bodySize) range of each function.f tail-calls f).(func, pc) may map to several source locations, one per inline depth. Emitting only one drops inlined frames; emitting them in the wrong order reverses the stack.liveRefs are slots inside a specific frameSize. A slot outside that frame (or a duplicated slot) means the metadata is inconsistent and the unwind must be rejected.The fix: parse sections strictly, index+validate debug metadata against body ranges, unwind tail chains with cycle detection, and flatten inline scopes innermost-first.
Files (self-contained, no npm dependencies):
- wasm-unwind/wasm_unwind.js — analyzer + binary parser
- wasm-unwind/test_wasm_unwind.js — 18-test verification harness
wasm_unwind.js'use strict';
/*
* wasm_unwind.js
* WebAssembly source-map + stack-unwinding analyzer for optimized modules
* combining tail calls, inlined functions and GC stack maps.
*
* Public API
* ----------
* parseWasm(bytes) -> WasmInfo
* analyze({ wasm, debug, unwind }) -> { frames, warnings }
* WasmParseError, InconsistentOffsetError
*
* Design notes
* ------------
* A wasm `pc` is an offset relative to the start of the *function body*
* (i.e. relative to the byte immediately following the code-entry size
* field), so it is stable across re-encodings and independent of the
* surrounding section layout. Every pc is validated against the body
* size recovered from the code section, which is how "inconsistent
* offsets" are rejected.
*
* GC stack maps may describe tail calls. A stack map for (func, pc) may
* carry a `tailCaller: {func, pc}` link: the frame was entered by a tail
* call and physically *is* the caller frame. Unwinding follows the
* tailCaller chain. A chain that revisits a (func, pc) is a legitimate
* recursive tail-call loop; it is terminated with `recursive: true`
* instead of looping forever (hostile / malformed input safety).
*
* Inlining is represented by several source-map entries with the same
* (func, pc) and increasing `depth` (0 = innermost inlined scope).
*/
const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d];
const WASM_VERSION = [0x01, 0x00, 0x00, 0x00];
const MAX_LEB_BYTES = 5;
class WasmUnwindError extends Error {
constructor(message, code) {
super(message);
this.name = this.constructor.name;
this.code = code;
}
}
class WasmParseError extends WasmUnwindError {
constructor(message) {
super(message, 'WASM_PARSE');
}
}
class InconsistentOffsetError extends WasmUnwindError {
constructor(message) {
super(message, 'INCONSISTENT_OFFSET');
}
}
/* ------------------------------------------------------------------ *
* Strict binary reader
* ------------------------------------------------------------------ */
class Reader {
constructor(bytes, start = 0, end = bytes.length) {
this.bytes = bytes;
this.pos = start;
this.end = end;
}
get remaining() {
return this.end - this.pos;
}
ensure(n, what) {
if (n < 0 || this.pos + n > this.end) {
throw new WasmParseError(
`unexpected end of input while reading ${what} at offset ${this.pos}`
);
}
}
u8(what = 'byte') {
this.ensure(1, what);
return this.bytes[this.pos++];
}
raw(n, what = 'bytes') {
this.ensure(n, what);
const out = this.bytes.subarray(this.pos, this.pos + n);
this.pos += n;
return out;
}
sub(n, what) {
this.ensure(n, what);
const r = new Reader(this.bytes, this.pos, this.pos + n);
this.pos += n;
return r;
}
// Strict unsigned LEB128: rejects over-long and >32-bit encodings.
varuint32(what = 'u32') {
let result = 0;
let shift = 0;
for (let i = 0; i < MAX_LEB_BYTES; i++) {
const b = this.u8(what);
result += (b & 0x7f) * Math.pow(2, shift);
if ((b & 0x80) === 0) {
if (i === MAX_LEB_BYTES - 1 && (b & 0x70) !== 0) {
throw new WasmParseError(`varuint32 overflows 32 bits for ${what}`);
}
return result >>> 0;
}
shift += 7;
}
throw new WasmParseError(`varuint32 too long for ${what}`);
}
// Signed LEB128 (used for value types / block signatures).
varint32(what = 'i32') {
let result = 0;
let shift = 0;
let b = 0;
for (let i = 0; i < MAX_LEB_BYTES; i++) {
b = this.u8(what);
result += (b & 0x7f) * Math.pow(2, shift);
shift += 7;
if ((b & 0x80) === 0) break;
if (i === MAX_LEB_BYTES - 1) {
throw new WasmParseError(`varint32 too long for ${what}`);
}
}
if (shift < 32 && (b & 0x40)) result -= Math.pow(2, shift);
return result | 0;
}
name(what = 'name') {
const len = this.varuint32(`${what} length`);
const bytes = this.raw(len, what);
return decodeUtf8(bytes, what);
}
}
function decodeUtf8(bytes, what) {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch (e) {
throw new WasmParseError(`invalid UTF-8 in ${what}`);
}
}
function toBytes(input) {
if (input instanceof Uint8Array) return input;
if (typeof ArrayBuffer !== 'undefined' && input instanceof ArrayBuffer) {
return new Uint8Array(input);
}
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) {
return new Uint8Array(input);
}
if (Array.isArray(input)) return Uint8Array.from(input);
throw new WasmParseError('wasm input must be a Uint8Array/Buffer/ArrayBuffer');
}
/* ------------------------------------------------------------------ *
* Section helpers
* ------------------------------------------------------------------ */
function parseLimits(r) {
const flags = r.varuint32('limits flags');
r.varuint32('limits min');
if (flags & 0x01) r.varuint32('limits max');
if (flags & 0x02) throw new WasmParseError('shared memories are not supported');
}
function parseImportSection(r) {
const count = r.varuint32('import count');
let funcImports = 0;
for (let i = 0; i < count; i++) {
r.name('import module');
r.name('import field');
const kind = r.u8('import kind');
switch (kind) {
case 0x00: // func
r.varuint32('import typeidx');
funcImports++;
break;
case 0x01: // table
r.u8('table reftype');
parseLimits(r);
break;
case 0x02: // memory
parseLimits(r);
break;
case 0x03: // global
r.u8('global valtype');
r.u8('global mutability');
break;
default:
throw new WasmParseError(
`unknown import kind 0x${kind.toString(16)}`
);
}
}
return funcImports;
}
function parseFunctionSection(r) {
const count = r.varuint32('function count');
const typeIndices = [];
for (let i = 0; i < count; i++) {
typeIndices.push(r.varuint32('function typeidx'));
}
return typeIndices;
}
function parseCodeSection(r, definedCount) {
const count = r.varuint32('code count');
if (count !== definedCount) {
throw new WasmParseError(
`code section has ${count} bodies but function section declares ${definedCount}`
);
}
const ranges = [];
for (let i = 0; i < count; i++) {
const size = r.varuint32(`code body ${i} size`);
const body = r.sub(size, `code body ${i}`);
if (size < 2) {
throw new WasmParseError(`code body ${i} too small (${size} bytes)`);
}
ranges.push({ size });
}
return ranges;
}
function parseNameSection(r, knownFuncCount) {
const names = new Map();
while (r.remaining > 0) {
const id = r.u8('name subsection id');
const size = r.varuint32('name subsection size');
const sub = r.sub(size, `name subsection ${id}`);
if (id === 1) {
const count = sub.varuint32('function name count');
for (let i = 0; i < count; i++) {
const idx = sub.varuint32('function name index');
const name = sub.name('function name');
if (knownFuncCount >= 0 && idx >= knownFuncCount) {
throw new WasmParseError(
`name section references function ${idx} >= ${knownFuncCount}`
);
}
if (names.has(idx)) {
throw new WasmParseError(`duplicate name entry for function ${idx}`);
}
names.set(idx, name);
}
if (sub.remaining !== 0) {
throw new WasmParseError('trailing bytes in name subsection 1');
}
}
}
return names;
}
function parseWasm(input) {
const bytes = toBytes(input);
if (bytes.length < 8) {
throw new WasmParseError('binary too short for wasm header');
}
for (let i = 0; i < 4; i++) {
if (bytes[i] !== WASM_MAGIC[i]) throw new WasmParseError('invalid wasm magic');
}
for (let i = 0; i < 4; i++) {
if (bytes[4 + i] !== WASM_VERSION[i]) {
throw new WasmParseError('unsupported wasm version');
}
}
const root = new Reader(bytes, 8, bytes.length);
const sections = [];
while (root.remaining > 0) {
const id = root.u8('section id');
const size = root.varuint32('section size');
const contents = root.sub(size, `section ${id} contents`);
sections.push({ id, size, contents });
}
let funcImportCount = 0;
let definedTypes = null;
let codeRanges = null;
const funcNames = new Map();
const result = {
bytes,
funcImportCount: 0,
definedCount: 0,
functionCount: 0,
codeRanges: new Map(), // functionIndex -> {size}
funcNames,
sourceMappingURL: null,
embeddedSourceMap: null,
embeddedStackMaps: null,
sections: sections.map((s) => ({ id: s.id, size: s.size })),
};
for (const section of sections) {
switch (section.id) {
case 0: {
// section.contents already points at the first body byte.
const body = section.contents;
const name = body.name('custom section name');
if (name === 'name') {
const names = parseNameSection(body, -1);
for (const [k, v] of names) funcNames.set(k, v);
} else if (name === 'sourceMappingURL') {
result.sourceMappingURL = decodeUtf8(
body.raw(body.remaining, 'sourceMappingURL'),
'sourceMappingURL'
).trim();
} else if (name === 'sourcemap' || name === 'stackmaps') {
const text = decodeUtf8(body.raw(body.remaining, name), name);
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
throw new WasmParseError(`invalid JSON in embedded "${name}" section`);
}
if (name === 'sourcemap') result.embeddedSourceMap = parsed;
else result.embeddedStackMaps = parsed;
}
break;
}
case 2:
funcImportCount = parseImportSection(section.contents);
break;
case 3:
definedTypes = parseFunctionSection(section.contents);
break;
case 10:
codeRanges = parseCodeSection(
section.contents,
definedTypes ? definedTypes.length : 0
);
break;
default:
break;
}
}
if (definedTypes === null && codeRanges !== null) {
throw new WasmParseError('code section without a function section');
}
if (definedTypes !== null && codeRanges === null && definedTypes.length > 0) {
throw new WasmParseError('function section without a code section');
}
const definedCount = definedTypes ? definedTypes.length : 0;
result.funcImportCount = funcImportCount;
result.definedCount = definedCount;
result.functionCount = funcImportCount + definedCount;
if (codeRanges) {
for (let i = 0; i < codeRanges.length; i++) {
result.codeRanges.set(funcImportCount + i, codeRanges[i]);
}
}
for (const idx of funcNames.keys()) {
if (idx >= result.functionCount) {
throw new WasmParseError(
`name section references function ${idx} >= ${result.functionCount}`
);
}
}
return result;
}
/* ------------------------------------------------------------------ *
* Analyzer
* ------------------------------------------------------------------ */
function keyOf(funcIndex, pc) {
return `${funcIndex}:${pc}`;
}
function validatePos(info, funcIndex, pc, label) {
if (!Number.isInteger(funcIndex) || funcIndex < 0) {
throw new InconsistentOffsetError(
`${label}: invalid function index ${funcIndex}`
);
}
if (funcIndex >= info.functionCount) {
throw new InconsistentOffsetError(
`${label}: function index ${funcIndex} out of range [0,${info.functionCount})`
);
}
const range = info.codeRanges.get(funcIndex);
if (!range) {
throw new InconsistentOffsetError(
`${label}: function ${funcIndex} has no body (imported function)`
);
}
if (!Number.isInteger(pc) || pc < 0 || pc >= range.size) {
throw new InconsistentOffsetError(
`${label}: pc ${pc} outside body of function ${funcIndex} [0,${range.size})`
);
}
}
function indexSourceMap(info, entries) {
const index = new Map(); // key -> Map(depth -> entry)
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
throw new InconsistentOffsetError('source map entry is not an object');
}
const { funcIndex, pc } = entry;
validatePos(info, funcIndex, pc, 'sourceMap');
const depth = entry.depth === undefined ? 0 : entry.depth;
if (!Number.isInteger(depth) || depth < 0) {
throw new InconsistentOffsetError(
`sourceMap ${keyOf(funcIndex, pc)}: invalid inline depth ${depth}`
);
}
const k = keyOf(funcIndex, pc);
let byDepth = index.get(k);
if (!byDepth) {
byDepth = new Map();
index.set(k, byDepth);
}
const prev = byDepth.get(depth);
if (prev) {
const same =
prev.file === entry.file &&
prev.line === entry.line &&
prev.column === entry.column &&
prev.name === entry.name;
if (!same) {
throw new InconsistentOffsetError(
`sourceMap ${k} depth ${depth}: conflicting mappings ` +
`(${prev.file}:${prev.line}:${prev.column}) vs ` +
`(${entry.file}:${entry.line}:${entry.column})`
);
}
continue;
}
byDepth.set(depth, {
depth,
file: entry.file,
line: entry.line,
column: entry.column,
name: entry.name,
});
}
return index;
}
function indexStackMaps(info, entries) {
const index = new Map();
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
throw new InconsistentOffsetError('stack map entry is not an object');
}
const { funcIndex, pc } = entry;
validatePos(info, funcIndex, pc, 'stackMap');
const frameSize = entry.frameSize === undefined ? 0 : entry.frameSize;
if (!Number.isInteger(frameSize) || frameSize < 0) {
throw new InconsistentOffsetError(
`stackMap ${keyOf(funcIndex, pc)}: invalid frameSize ${frameSize}`
);
}
const liveRefs = entry.liveRefs === undefined ? [] : entry.liveRefs;
if (!Array.isArray(liveRefs)) {
throw new InconsistentOffsetError(
`stackMap ${keyOf(funcIndex, pc)}: liveRefs must be an array`
);
}
const seen = new Set();
for (const slot of liveRefs) {
if (!Number.isInteger(slot) || slot < 0 || slot >= frameSize) {
throw new InconsistentOffsetError(
`stackMap ${keyOf(funcIndex, pc)}: liveRef slot ${slot} outside frame of size ${frameSize}`
);
}
if (seen.has(slot)) {
throw new InconsistentOffsetError(
`stackMap ${keyOf(funcIndex, pc)}: duplicate liveRef slot ${slot}`
);
}
seen.add(slot);
}
let tailCaller = null;
if (entry.tailCaller) {
const { funcIndex: tf, pc: tpc } = entry.tailCaller;
validatePos(info, tf, tpc, `stackMap ${keyOf(funcIndex, pc)} tailCaller`);
tailCaller = { funcIndex: tf, pc: tpc };
}
const k = keyOf(funcIndex, pc);
const prev = index.get(k);
if (prev) {
const same =
prev.frameSize === frameSize &&
prev.liveRefs.length === liveRefs.length &&
prev.liveRefs.every((v, i) => v === liveRefs[i]) &&
Boolean(prev.tailCaller) === Boolean(tailCaller) &&
(!prev.tailCaller ||
(prev.tailCaller.funcIndex === tailCaller.funcIndex &&
prev.tailCaller.pc === tailCaller.pc));
if (!same) {
throw new InconsistentOffsetError(
`stackMap ${k}: conflicting duplicate stack map entries`
);
}
continue;
}
index.set(k, {
funcIndex,
pc,
frameSize,
liveRefs: liveRefs.slice(),
tailCaller,
});
}
return index;
}
function resolveSourceScopes(sourceIndex, funcIndex, pc, warnings) {
const byDepth = sourceIndex.get(keyOf(funcIndex, pc));
if (!byDepth) return [];
const depths = [...byDepth.keys()].sort((a, b) => a - b);
if (depths[0] !== 0) {
warnings.push(
`sourceMap ${keyOf(funcIndex, pc)}: inline depths start at ${depths[0]}, expected 0 (partial metadata)`
);
}
const scopes = [];
for (let i = 0; i < depths.length; i++) {
if (i > 0 && depths[i] !== depths[i - 1] + 1) {
warnings.push(
`sourceMap ${keyOf(funcIndex, pc)}: non-contiguous inline depth ${depths[i]}`
);
}
scopes.push(byDepth.get(depths[i]));
}
return scopes;
}
function summarizeStackMap(sm) {
if (!sm) return null;
return {
frameSize: sm.frameSize,
liveRefs: sm.liveRefs.slice(),
hasTailCaller: Boolean(sm.tailCaller),
};
}
/**
* Reconstruct logical source frames from a physical wasm stack trace.
*
* @param {object} opts
* @param {Uint8Array|Buffer} opts.wasm
* @param {object} [opts.debug] { sourceMap, stackMaps }
* @param {object} [opts.unwind] { frames: [{funcIndex, pc}, ...] } innermost first
* @returns {{frames: object[], warnings: string[]}}
*/
function analyze(opts = {}) {
const info = parseWasm(opts.wasm);
const debug = opts.debug || {};
const sourceEntries =
debug.sourceMap !== undefined ? debug.sourceMap : info.embeddedSourceMap || [];
const stackEntries =
debug.stackMaps !== undefined ? debug.stackMaps : info.embeddedStackMaps || [];
if (!Array.isArray(sourceEntries)) {
throw new InconsistentOffsetError('sourceMap must be an array');
}
if (!Array.isArray(stackEntries)) {
throw new InconsistentOffsetError('stackMaps must be an array');
}
const sourceIndex = indexSourceMap(info, sourceEntries);
const stackIndex = indexStackMaps(info, stackEntries);
const warnings = [];
const inputFrames =
opts.unwind && Array.isArray(opts.unwind.frames) ? opts.unwind.frames : [];
const frames = [];
for (let fi = 0; fi < inputFrames.length; fi++) {
const pf = inputFrames[fi];
if (!pf || typeof pf !== 'object') {
throw new InconsistentOffsetError(`unwind frame ${fi} is not an object`);
}
validatePos(info, pf.funcIndex, pf.pc, `unwind frame ${fi}`);
const chain = [];
const seen = new Set();
let cur = { funcIndex: pf.funcIndex, pc: pf.pc };
let recursive = false;
let guard = 0;
while (cur) {
if (++guard > 1000000) {
throw new InconsistentOffsetError('tail-call chain exceeded safety limit');
}
const k = keyOf(cur.funcIndex, cur.pc);
if (seen.has(k)) {
recursive = true;
break;
}
seen.add(k);
const sm = stackIndex.get(k) || null;
const scopes = resolveSourceScopes(sourceIndex, cur.funcIndex, cur.pc, warnings);
chain.push({ ...cur, sm, scopes });
if (sm && sm.tailCaller) {
cur = { funcIndex: sm.tailCaller.funcIndex, pc: sm.tailCaller.pc };
} else {
cur = null;
}
}
for (let ci = 0; ci < chain.length; ci++) {
const logical = chain[ci];
const name = info.funcNames.get(logical.funcIndex) || null;
const isLast = ci === chain.length - 1;
const scopes = logical.scopes.length
? logical.scopes
: [{ depth: 0, file: null, line: null, column: null, name: null }];
for (const scope of scopes) {
frames.push({
funcIndex: logical.funcIndex,
funcName: name,
pc: logical.pc,
inlineDepth: scope.depth,
inlined: scope.depth > 0,
source: scope.file
? {
file: scope.file,
line: scope.line,
column: scope.column,
name: scope.name === undefined ? null : scope.name,
}
: null,
stackMap: summarizeStackMap(logical.sm),
tailCall: Boolean(logical.sm && logical.sm.tailCaller),
tailCalledFrom:
logical.sm && logical.sm.tailCaller
? {
funcIndex: logical.sm.tailCaller.funcIndex,
pc: logical.sm.tailCaller.pc,
}
: null,
recursive: recursive && isLast && scope.depth === scopes[scopes.length - 1].depth,
});
}
}
}
return { frames, warnings, info };
}
module.exports = {
analyze,
parseWasm,
WasmParseError,
InconsistentOffsetError,
WasmUnwindError,
};
const { analyze } = require('./wasm_unwind');
const { frames, warnings } = analyze({ wasm, debug, unwind });
// debug = { sourceMap: [...], stackMaps: [...] }
// unwind = { frames: [{ funcIndex, pc }, ...] } // innermost first
sourceMap entry: { funcIndex, pc, depth?, file, line, column, name? } (depth 0 = innermost inlined scope).stackMaps entry: { funcIndex, pc, frameSize, liveRefs: number[], tailCaller?: { funcIndex, pc } }. A tailCaller means the frame was entered by a tail call and physically is the caller frame; unwinding follows these links and stops with recursive: true if a (func, pc) repeats.sourceMap / stackMaps may also be supplied as JSON in custom sections named sourcemap and stackmaps; used automatically when debug is omitted.Full harness lives at wasm-unwind/test_wasm_unwind.js (18 tests, no deps). Run:
cd wasm-unwind
node test_wasm_unwind.js
Observed output:
ok - parseWasm: header, sections, code ranges and names
ok - parseWasm: rejects bad magic
ok - parseWasm: rejects section size that overruns the buffer
ok - parseWasm: rejects non-canonical over-long LEB128
ok - parseWasm: imports are indexed before defined functions
ok - parseWasm: reads embedded sourcemap/stackmaps custom sections
ok - analyze: reconstructs source frames without tail calls
ok - analyze: inline scopes are emitted innermost-first
ok - analyze: follows a recursive tail-call chain and terminates
ok - analyze: populates tail metadata on the callee frame
ok - analyze: rejects pc outside the function body
ok - analyze: rejects function index out of range
ok - analyze: rejects imported (body-less) frames
ok - analyze: rejects conflicting duplicate source mappings
ok - analyze: rejects liveRef outside the declared frame
ok - analyze: rejects a tailCaller pointing outside the body
ok - analyze: uses embedded metadata when debug is omitted
ok - analyze: emits a null source frame when metadata is partial
All 18 tests passed.
Cross-check against a real wat2wasm-compiled module:
wat2wasm --debug-names integ.wat -o integ.wasm
wasm-validate integ.wasm
# parser recovered 3 functions, names [0:'alpha',1:'beta',2:'gamma'], body size 4 each,
# and reconstructed source.file='real.wat' for func 0 pc 1.
recursive: true, no hang.tailCall, tailCalledFrom) populated.liveRef outside frameSize.tailCaller target.source: null instead of throwing.The unwind loop keeps a seen set of funcIndex:pc keys and a hard guard counter. When a tail-call link points at a key already in seen, the loop stops, marks the last emitted frame recursive: true, and returns — the correct behavior for a legitimate tail-recursive function and the safe behavior for adversarial input.
# Evidence - Problem class: js-wasm-gc-stackmap-tailcall-unwind - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-11T10:29:09.136Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement a WebAssembly source-map and stack-unwinding analyzer for optimized modules combining tail calls, inlined functions, and garbage-collector stack maps. Given binary sections and partial debug metadata, reconstruct source frames while rejecting inconsistent offsets and correctly handling recursive tail-call chains under malformed-input constraints.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-wasm-gc-stackmap-tailcall-unwind", "provider": "openrouter", "solved_at": "2026-10-11T10:29:09.136Z", "version": "20"}