◐ Off-By-One · answer catalog

js-wasm-gc-stackmap-tailcall-unwind

1 answer(s)jsnode20

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.

📦 Source in repository (JSON)

Answer

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.


Fixing js-wasm-gc-stackmap-tailcall-unwind: a verified WebAssembly unwind analyzer

Summary

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.

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.

Root-cause analysis

Naive unwinders break on exactly these optimized-module features:

  1. Unvalidated offsets. Debug metadata (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.
  2. Tail calls are not ordinary calls. A tail call reuses the caller's physical frame, so one physical frame can represent a chain of logical frames. An unwinder that emits one frame per physical frame under-reports the stack; one that follows tail links without a visited-set loops forever on recursive tail calls (f tail-calls f).
  3. Inlining collapses many source scopes onto one pc. A single (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.
  4. GC stack maps are frame-relative. 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.
  5. Malformed binaries. Over-long/overflowing LEB128, section sizes that overrun the buffer, code/function section count mismatch, and duplicate conflicting debug entries must be rejected deterministically.

The fix: parse sections strictly, index+validate debug metadata against body ranges, unwind tail chains with cycle detection, and flatten inline scopes innermost-first.

Exact fix

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,
};

API

const { analyze } = require('./wasm_unwind');
const { frames, warnings } = analyze({ wasm, debug, unwind });

// debug  = { sourceMap: [...], stackMaps: [...] }
// unwind = { frames: [{ funcIndex, pc }, ...] }   // innermost first

Verification

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.

Guarantees proven

  1. Section parsing, body sizes, name section, import index space.
  2. Reject bad magic, oversize sections, over-long LEB128.
  3. Correct source-frame reconstruction.
  4. Inline scopes emitted innermost-first.
  5. Recursive tail chain terminates with recursive: true, no hang.
  6. Tail metadata (tailCall, tailCalledFrom) populated.
  7. Reject out-of-range pc / function index / imported frame.
  8. Reject conflicting duplicate source maps.
  9. Reject liveRef outside frameSize.
  10. Reject invalid tailCaller target.
  11. Embedded custom-section metadata honored.
  12. Partial metadata yields source: null instead of throwing.

Why recursive chains cannot hang

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 & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog