◐ Off-By-One · answer catalog

js-20261005-incremental-wasm-stackmap-relocation

1 answer(s)jsnode20

Problem class: js-20261005-incremental-wasm-stackmap-relocation

📦 Source in repository (JSON)

Answer

All23 tests pass, and the solution is written to ~/SOLUTION.md (mirrored at /workspace/SOLUTION.md). Here is the verified solution.


Incremental WebAssembly Linker with LEB128 Stack-Map Relocation

Problem class: js-20261005-incremental-wasm-stackmap-relocation Runtime: Node.js 20+ (CommonJS, zero dependencies) Artifacts: src/errors.js, src/leb128.js, src/linker.js, index.js, test/linker.test.js, SOLUTION.md

Root-cause analysis

The problem is not one crash but four independent correctness failures that appear whenever layout assumes static addresses:

  1. Relocation slots treated as fixed-width. WASM encodes offsets as LEB128. A slot is 1 byte at 127, 2 bytes at 128, 3 at 16384. Patching in place corrupts following bytes; changing a width shifts later chunks, which can push another value across a boundary. Layout is a fixpoint: sizes → addresses → values → widths → sizes, and must be iterated.
  2. Unstable symbol identity / needless rebuilds. Numbering externals by table position renumbers them on insert, breaking relocations. Identities must be allocated once and never recycled; unchanged modules must reuse emitted bytes.
  3. Undetected dependency cycles. Explicit dependsOn edges form a DAG; a cycle makes resolution ill-defined and naive recursion loops or overflows nondeterministically.
  4. Malformed input accepted. Truncated/overlong/non-canonical LEB128, out-of-range/overlapping slots, unknown symbols/edges — each needs a stable code and fixed validation order.

The exact fix

The fix is the implementation. Its core is the bounded layout fixpoint and the sorted cycle check:

// src/linker.js — layout fixpoint (start minimal, grow to convergence)
for (const id of order) this._chunks.get(id).size = this._minSize(this._chunks.get(id));
let iter = 0;
for (;;) {
  if (++iter > this.maxIterations) throw new LinkerError(CODES.NON_CONVERGENT, ...);
  let addr = this.baseAddress;
  for (const id of order) {                 // addresses from current sizes
    const c = this._chunks.get(id);
    c.address = addr;                       // assign immediately: size pass uses fresh addrs
    addr += BigInt(c.size);
  }
  let changed = false;
  for (const id of order) {                 // sizes from those addresses
    const c = this._chunks.get(id);
    const next = this._computeSize(c);      // fixed bytes + Σ encodingLength(resolved slot)
    if (next !== c.size) { c.size = next; changed = true; }
  }
  if (!changed) break;
}
// src/linker.js — deterministic cycle rejection (sorted DFS, gray/black)
const visit = (r) => {
  color.set(r.id, GRAY); stack.push(r.id);
  for (const dep of r.dependsOn.slice().sort(compareRelocIds)) {
    const state = color.get(dep) ?? WHITE;
    if (state === GRAY) {
      const cycle = stack.slice(stack.indexOf(dep)).concat(dep);
      throw new LinkerError(CODES.CYCLIC_DEPENDENCY, `cyclic relocation dependency: ${cycle.join(' -> ')}`, { cycle });
    }
    if (state === WHITE) visit(byId.get(dep));
  }
  color.set(r.id, BLACK); stack.pop();
};
// src/leb128.js — width prediction drives variable-size slots
function unsignedLength(value, bits = 64) {
  let v = toBigInt(value); assertUnsignedInRange(v, bits);
  let n = 1; v >>= 7n;
  while (v !== 0n) { n += 1; v >>= 7n; }
  return n;
}

Other guarantees: external ids come from a persistent _externalIds map (defineExternal reuses the id); _renderAndReport() caches each chunk by address|size|resolved-values signature and only re-renders changed chunks, reporting rebuiltModules; decodeUnsigned/decodeSigned reject truncation, overlong, overflow, and non-canonical encodings with MALFORMED_LEB.

Verification

cd ~ && npm test
# tests 23
# pass 23
# fail 0

Manual reproduction of the LEB128-growth fixpoint:

node - <<'NODE'
const { IncrementalLinker } = require('./index');
const l = new IncrementalLinker();
l.addFunction({ name:'a', module:'m0', body:Uint8Array.from([0xaa,0x00]),
                relocations:[{ at:1, size:1, symbol:'b', encoding:'u32' }] });
for (let i=0;i<127;i++) l.addFunction({ name:`f${i}`, module:'m1', body:Uint8Array.from([0]) });
l.addFunction({ name:'b', module:'m1', body:Uint8Array.from([0xbb]) });
console.log('b @', l.getSymbolAddress('b'));       // 130n
console.log(Array.from(l.serialize().slice(0,3))); // [170,130,1]  = LEB128(130)
NODE
b @ 130n
[ 170, 130, 1 ]

Coverage maps one-to-one to the root causes: LEB128 widths/round-trip/malformed, fixpoint insert/delete/stability, stable external ids, module reuse, stack-map relocation, and deterministic rejection of cycles, unknown deps/symbols, overlaps, duplicates, overflow, and non-convergence. The full source and the 23-test suite are embedded verbatim in ~/SOLUTION.md (and /workspace/SOLUTION.md), so the document and the verified code cannot drift apart.

Evidence & signatures

# Evidence
- Problem class: js-20261005-incremental-wasm-stackmap-relocation
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-05T22:29:31.284Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Build an incremental linker for WebAssembly object modules that relocates code and stack-map metadata after function insertion or deletion without rebuilding unaffected modules. It must preserve stable external symbol identities, correctly update LEB128-sized offsets when encodings change length, and reject malformed or cyclic relocation dependencies deterministically.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-20261005-incremental-wasm-stackmap-relocation", "provider": "openrouter", "solved_at": "2026-10-05T22:29:31.284Z", "version": "20"}
Generated from the verified corpus · MIT licensedBack to the catalog