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:
- The depth ceiling is low enough that real‑world code hits it (see "Real‑world impact" below).
- 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
- 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.
- 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.
Summary
Evaluating a deeply‑nested expression traps with
wasm error: out of bounds memory accessinstead 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:panic(from(*Runtime).call), not anerror, and it leaves the runtime/context unusable for subsequent calls.Notably,
Option.MaxStackSizeandOption.MemoryLimitdo not prevent it —MaxStackSizebounds QuickJS's JS‑level recursion (which would throw a catchableRangeError), 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/qjsv0.0.6github.com/tetratelabs/wazerov1.9.0Minimal repro (no other dependencies)
Output:
(Nested array literals
[[[…]]]and nested ternaries behave the same; member chainsx?.b?.b…trap around depth ~200.)Observed vs. expected
failed to call QJS_Eval: wasm error: out of bounds memory access. The runtime is then unusable — subsequentEvals on it also fail, andClose()panics during teardown.Evalreturns a normal, catchableerror(e.g. aRangeError: 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
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.(*Runtime).call/Evalreturn the trap as anerror, and ideally let QuickJS's own stack‑overflow guard (driven byMaxStackSize) fire first so it's a catchableRangeErrorrather than a hard trap. A single deepEvalshouldn'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.