Skip to content

Deeply nested expressions trap with "out of bounds memory access" (WASM stack overflow) instead of a catchable error #47

@paulmanoni

Description

@paulmanoni

Summary

Evaluating a deeply‑nested expression traps with wasm error: out of bounds memory access instead of returning a catchable error. The trap is a WASM call‑stack (shadow‑stack) overflow from QuickJS's recursive C parser/evaluator, and it happens at a fairly low nesting depth. Two problems compound it:

  1. The depth ceiling is low enough that real‑world code hits it (see "Real‑world impact" below).
  2. The overflow surfaces as a Go panic (from (*Runtime).call), not an error, and it leaves the runtime/context unusable for subsequent calls.

Notably, Option.MaxStackSize and Option.MemoryLimit do not prevent it — MaxStackSize bounds QuickJS's JS‑level recursion (which would throw a catchable RangeError), but this overflow is in QuickJS's C‑level recursion compiled into the WASM, which exhausts the module's shadow stack first.

Environment

  • github.com/fastschema/qjs v0.0.6
  • github.com/tetratelabs/wazero v1.9.0
  • Go 1.26.2, darwin/arm64 (also reproduces on linux/amd64)

Minimal repro (no other dependencies)

package main

import (
	"fmt"
	"strings"

	"github.com/fastschema/qjs"
)

func tryDepth(n int) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("depth=%-6d PANIC: %v\n", n, r)
		}
	}()
	// Generous QuickJS limits — these do NOT prevent the trap.
	rt, err := qjs.New(qjs.Option{MaxStackSize: 64 << 20, MemoryLimit: 1 << 30})
	if err != nil {
		panic(err)
	}
	defer rt.Close()

	code := strings.Repeat("(", n) + "1" + strings.Repeat(")", n)
	if _, err := rt.Context().Eval("x.js", qjs.Code(code)); err != nil {
		fmt.Printf("depth=%-6d error: %v\n", n, err)
		return
	}
	fmt.Printf("depth=%-6d ok\n", n)
}

func main() {
	for _, n := range []int{100, 1000, 5000} {
		tryDepth(n)
	}
}

Output:

depth=100    ok
depth=1000   PANIC: failed to call QJS_Eval: wasm error: out of bounds memory access
depth=5000   PANIC: failed to call QJS_Eval: wasm error: out of bounds memory access

(Nested array literals [[[…]]] and nested ternaries behave the same; member chains x?.b?.b… trap around depth ~200.)

Observed vs. expected

  • Observed: a Go panic failed to call QJS_Eval: wasm error: out of bounds memory access. The runtime is then unusable — subsequent Evals on it also fail, and Close() panics during teardown.
  • Expected: either deeper nesting is supported, or QuickJS's stack‑overflow check fires and Eval returns a normal, catchable error (e.g. a RangeError: Maximum call stack size exceeded) that leaves the runtime usable.

Real‑world impact

I'm using qjs to run @vue/compiler-sfc (which embeds @babel/parser) in‑process as a CGo‑free alternative to a native QuickJS binding. The compiler adds its own recursion on top of the source's nesting, so the effective ceiling drops to only ~11–20 levels of source nesting. Ordinary Vue <script setup> code — an array of config objects with arrow callbacks containing ternaries, optional chaining, and template literals — trips it. Across one real project, 10 of 128 components fail this way, while the equivalent native (CGo) QuickJS binding compiles all 128 fine (it has a large C stack and proper stack‑overflow detection).

Suggestions

  1. Grow the WASM shadow/stack (or expose it as an Option) so realistic recursion depths are supported. Wazero lets you configure module memory; the QuickJS‑NG wasm build's stack size is the key lever.
  2. Don't panic on wasm traps — have (*Runtime).call / Eval return the trap as an error, and ideally let QuickJS's own stack‑overflow guard (driven by MaxStackSize) fire first so it's a catchable RangeError rather than a hard trap. A single deep Eval shouldn't poison the runtime or crash the host.

Happy to test a patch. Thanks for the library — where it doesn't hit this, output has been byte‑identical to a native QuickJS binding.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions