Skip to content

Dev error renderer permanently dies with wasm unreachable: SourceMapConsumer created per frame, never destroyed #4495

Description

@jilarganti

Environment

Symptom

Once a few errors have been rendered by the dev error handler, every subsequent error render crashes with:

ERROR  unreachable

    at wasm://wasm/0002f63a:wasm-function[54]:0xb65c
    at wasm://wasm/0002f63a:wasm-function[23]:0xae24
    at BasicSourceMapConsumer._parseMappings (source-map/lib/source-map-consumer.js:322:44)
    at _ErrorParser.sourceLoader (.nuxt/dev/index.mjs:4235:41)
    at async #getSource (youch-core/build/index.js:142:22)
    at async #enhanceFrames (youch-core/build/index.js:232:27)

From that point on the dev server never shows a real error again — only unreachable spam — until restart. The original errors are completely masked.

Cause

sourceLoader in runtime/internal/error/dev.ts creates a new SourceMapConsumer(rawSourceMap) per stack frame per error and never calls consumer.destroy():

const consumer = await new SourceMapConsumer(rawSourceMap);
const originalPosition = consumer.originalPositionFor({ line: frame.lineNumber!, column: frame.columnNumber! });

source-map@0.7.x parses mappings inside a shared wasm instance with its own heap; undestroyed consumers leak that heap (this is documented in the source-map README — consumers must be destroy()ed or used via SourceMapConsumer.with). When an allocation finally fails, the Rust code panics — that's the unreachable trap — and the shared wasm instance stays poisoned forever: every later _parseMappings call throws too.

With a real-world dev bundle map (.nuxt/dev/index.mjs.map, 6.3 MB, 634 sources) it takes ~38 consumers to hit the wall. A typical SSR error has 10+ "app" frames in .nuxt/dev/index.mjs, and each frame parses the same 6.3 MB map again, so 2–4 rendered errors are enough to kill the renderer for the rest of the session.

Minimal repro

import { readFile } from "node:fs/promises";
import { SourceMapConsumer } from "source-map"; // 0.7.6

const raw = await readFile(".nuxt/dev/index.mjs.map", "utf8"); // any multi-MB map
for (let i = 1; i <= 100; i++) {
  const consumer = await new SourceMapConsumer(raw);
  consumer.originalPositionFor({ line: 1, column: 0 });
  // no destroy() — mirrors sourceLoader
}

Output on my machine:

consumer #38: CRASH -> unreachable
follow-up consumer: STILL CRASHING -> unreachable   <- instance is poisoned for good

Suggested fix

Two independent layers:

  1. Cache one consumer (promise) per fileName instead of one per frame — bounded wasm heap, and frames of the same file stop re-parsing the same map. (Or destroy() after use / SourceMapConsumer.with, at the cost of re-parsing per frame.)
  2. Wrap the sourcemap lookup in try/catch so that even if the consumer ever throws, the error renderer degrades to unmapped frames instead of replacing the user's real error with unreachable.

Happy to send a PR if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions