Skip to content

Raise wasm C stack size so deep recursion no longer traps (fixes #47)#48

Open
paulmanoni wants to merge 1 commit into
fastschema:masterfrom
paulmanoni:fix/wasm-stack-size-deep-recursion
Open

Raise wasm C stack size so deep recursion no longer traps (fixes #47)#48
paulmanoni wants to merge 1 commit into
fastschema:masterfrom
paulmanoni:fix/wasm-stack-size-deep-recursion

Conversation

@paulmanoni
Copy link
Copy Markdown

@paulmanoni paulmanoni commented May 29, 2026

Problem

Evaluating deeply‑nested expressions traps with wasm error: out of bounds memory access instead of completing (or returning a catchable error). It's a C call‑stack overflow: QuickJS's parser/evaluator recurse in C, and the qjswasm target linked with wasm‑ld's small default stack.

The ceiling is low enough to hit real code:

  • plain Eval of ~1000 nested parens traps;
  • via @vue/compiler-sfc (which adds its own recursion on top of the source AST) it traps at only ~15–20 levels of ordinary source nesting — array/object config literals with arrow callbacks, ternaries, optional chaining, etc.

Fixes #47.

Root cause

--stack-first and --initial-memory are added via add_link_options(...) after add_executable(qjswasm) in qjswasm.cmake, so they never apply to that target (add_link_options only affects targets created after it). The effective link therefore used wasm‑ld's default stack size.

Fix

Move the stack sizing into the target_link_options(qjswasm PRIVATE ...) block that actually applies to the target:

"LINKER:-z,stack-size=16777216"     # 16 MiB C stack (was wasm-ld's small default)
"LINKER:--initial-memory=20971520"  # 20 MiB — with --stack-first the stack lives at the
                                     # bottom of linear memory, so initial memory must
                                     # exceed stack-size + data

qjs.wasm regenerated via make + wasm-opt -O3 (WASI SDK 33).

Verification

  • Nested‑paren Eval now succeeds to depth 20000 (previously trapped at 1000).
  • A real 128‑component Vue app compiled through @vue/compiler-sfc on qjs: 10/128 files failed before, 0/128 fail now.
rt, _ := qjs.New(qjs.Option{MaxStackSize: 8 << 20, MemoryLimit: 1 << 30})
code := strings.Repeat("(", 5000) + "1" + strings.Repeat(")", 5000)
_, err := rt.Context().Eval("x.js", qjs.Code(code)) // before: panic; after: ok

Notes / follow‑ups (not in this PR)

  • 16 MiB is a pragmatic default; happy to tune.
  • Separately, it would be nice if a wasm trap from (*Runtime).call returned an error instead of panic-ing (today a single deep Eval can crash the host and poison the runtime), and if QuickJS's own JS_SetMaxStackSize guard were coupled just under the linker stack so true runaway recursion throws a catchable RangeError. Glad to do those in a follow‑up if you're interested.

QuickJS's parser/evaluator recurse in C. The qjswasm target linked with
wasm-ld's small default stack, so deeply (but ordinarily) nested source
overflowed it and surfaced as "wasm error: out of bounds memory access"
— e.g. ~1000 nested parens via plain Eval, and as few as ~15-20 levels
of source nesting when running @vue/compiler-sfc (which adds its own
recursion on top). Fixes fastschema#47.

Root cause: the `--stack-first` / `--initial-memory` add_link_options
sit AFTER add_executable(qjswasm), so they never applied to that target.
Put the stack sizing in the target_link_options(qjswasm ...) block that
actually takes effect:

  -z stack-size=16777216   (16 MiB C stack; was wasm-ld's small default)
  --initial-memory=20971520 (20 MiB; with --stack-first the stack lives
                             at the bottom of linear memory, so initial
                             memory must exceed stack-size + data)

Verified: nested-paren Eval now succeeds to depth 20000 (was trapping at
1000), and a real 128-component Vue app that previously failed on 10
files now compiles all 128. qjs.wasm regenerated via `make` + wasm-opt -O3.
@paulmanoni paulmanoni changed the title fix(wasm): raise C stack size so deep recursion no longer traps Raise wasm C stack size so deep recursion no longer traps (fixes #47) May 29, 2026
paulmanoni pushed a commit to paulmanoni/nexus that referenced this pull request Jun 1, 2026
Point qjs at paulmanoni/qjs (fix/wasm-stack-size-deep-recursion) via a
replace directive. The fork raises the wasm C stack so QuickJS's
recursive parser no longer traps on deep expressions — upstream qjs
v0.0.6 traps with "out of bounds memory access" while compiling ordinary
SFC <script setup> code.

With this pin the CGo-free vue_qjs backend compiles a real 128-SFC app
128/128 (was 10 failures). Pure Go, no MaxStackSize needed.

TEMPORARY: drop this replace and bump to a released version once the
upstream fix lands (fastschema/qjs#48). Does not affect default builds —
the qjs backend only compiles under -tags vue_qjs.
paulmanoni pushed a commit to paulmanoni/nexus that referenced this pull request Jun 1, 2026
Plain `nexus build` / `nexus dev` (no cgo, no build tags) now compile
.vue via the QuickJS-NG-over-WASM backend, so Vue support works in the
standard pure-Go install. The native CGo binding stays available as an
opt-in via `-tags vue` (CGO_ENABLED=1), which is faster per SFC.

Backend plumbing:
- Untag bootstrap.go / plugin.go (they were pure Go, only cgo-tagged
  because the package was) and compile_qjs.go (drop the vue_qjs tag) so
  the WASM backend + bundle bootstrap + esbuild plugin compile without
  cgo.
- Generalize Pool to a factory (NewPool(func() (SFCCompiler, error),
  size)) so either backend gets the same concurrency; add Close() to
  the SFCCompiler interface so the pool can tear members down.

CLI wiring (cmd/nexus):
- frontend_vue_common.go: shared backend-agnostic bootstrap+pool+plugin
  helper and vuePoolSize().
- frontend_build_vue_qjs.go (//go:build !vue): default hook → WASM.
- frontend_build_vue.go (//go:build cgo && vue): opt-in hook → native.
- Update the "no SFC compiler" message: it now only triggers when you
  pass -tags vue without cgo.

Verified: a pure-Go (CGO_ENABLED=0) nexus binary bundles a real 128-SFC
app — 261 output files with code splitting in ~4s, all SFCs compiled.
Requires the qjs WASM stack fix (currently via the fork-pin in go.mod;
upstream fastschema/qjs#48).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants