Skip to content

fix: stack-depth guard and shadow-stack restore for deep recursion (follow-up to #179) - #180

Open
pyramation wants to merge 3 commits into
mainfrom
feat/stack-depth-guard
Open

pyramation wants to merge 3 commits into
mainfrom
feat/stack-depth-guard

Conversation

@pyramation

@pyramation pyramation commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #179 (ship that first). Two related crash-safety fixes for deep recursion, plus the fork pins that carry the C side.

1. Stack-depth guard (needs the fork PRs). libpg_query's check_stack_depth() has always been a no-op in our builds — stack_base_ptr is only set by set_stack_base() from PostgreSQL's main(), which the library never calls. So when recursion in the JSON/protobuf output, fingerprint, deparse or PL/pgSQL dump exhausts the 32 MiB WASM stack, the instance traps (memory access out of bounds — the Aug-2025 crash). The fork PRs add pg_query_set_stack_limit(bytes) and the check_stack_depth() calls; this PR:

Result: ulimit -s unlimited; node --stack-size=100000, SELECT 1+1+… ×250k terms → was RuntimeError: memory access out of bounds, now SqlError: stack depth limit exceeded; next call fine.

2. Stale-instance shadow stack (wrapper only). After a V8 RangeError inside the parser, callWasm re-instantiates, but sync calls in the same tick must keep using the stale instance until the replacement arrives — and the throw unwound the JS frames, not the C shadow stack, so each such call started further down it. Under node --stack-size=8000, 10 back-to-back parseSync calls of a 100k-term expression trap on the 4th on both the 128/32 and 40/32 builds. Fix: callWasm saves stackSave() before the call and stackRestore()s it on throw (stackSave/stackRestore added to EXPORTED_RUNTIME_METHODS).

Regression test versions/{17,18}/test/wasm-stale-stack.test.js (child process, --stack-size=8000, 10 sync calls, asserts no RuntimeError and that the replacement instance parses). Pre-fix on the current builds: ["RangeError"×3, "RuntimeError"×7] (fails); post-fix: RangeError×10 (passes).

Evidence (v18 built from the fork branch, v17 and v13 rebuilt too)

  • pnpm test: v18 144/144 (incl. the new test), v17 73/73, v13 72/72 (weak symbol path).
  • pnpm test:stress 18: 4/4.
  • Adversarial matrix (nested parens/subqueries/CASE, 1+1+… 20k–500k, -> 50k, 3k nested PL/pgSQL, 8.8/26 MB files; default and --stack-size=8000): no traps; verdicts unchanged except the raised-stack case above, which is now a clean error.
  • Expected wasm/libpg-query.wasm sha256 with these pins (emsdk 6.0.3, 128 MiB floor on this branch): v18 42f112ab2dde2df4…, v17 e98ac195eb54f881… — these change again once build: 40 MiB WASM initial memory default, retire low-memory variants (patch 13.8.1–18.2.1) #179 (40 MiB floor) is merged in.

Link to Devin session: https://app.devin.ai/sessions/2a9e06519f424ebab14772c74c05dd83
Open in Devin Desktop: https://app.devin.ai/desktop/session/2a9e06519f424ebab14772c74c05dd83?variant=devin
Requested by: @pyramation

libpg_query's check_stack_depth() never had a base pointer, so recursion
past the WASM stack trapped (memory access out of bounds). Call the fork's
pg_query_set_stack_limit() at every export with the stack actually left
(weak symbol: upstream pins skip it) and pin 17/18 to the fork branches.
Restore the shadow stack pointer in callWasm after a throw so sync calls
on the stale instance do not start half-way down it. Regression test:
10 parseSync of a 100k-term expression under --stack-size=8000.
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high

📍 Findings outside the diff (1) — 🟠 1 high — defects on lines GitHub can't attach comments to

🟠 High — Add stackSave/stackRestore to the WasmModule interface · index.ts:126–142 · unchanged line

// versions/18/src/index.ts
126	interface WasmModule {
127	  _malloc: (size: number) => number;
128	  _free: (ptr: number) => void;
129	  _wasm_free_string: (ptr: number) => void;
130	  _wasm_parse_query: (queryPtr: number) => number;
131	  _wasm_parse_query_raw: (queryPtr: number) => number;
132	  _wasm_free_parse_result: (ptr: number) => void;
133	  _wasm_parse_plpgsql: (queryPtr: number) => number;
134	  _wasm_fingerprint: (queryPtr: number) => number;
135	  _wasm_normalize_query: (queryPtr: number) => number;
136	  _wasm_scan: (queryPtr: number) => number;
137	  lengthBytesUTF8: (str: string) => number;

callWasm calls module.stackSave() and module.stackRestore(), but the typed WasmModule interface declares neither member (versions/18/src/index.ts:188). With strict: true (versions/18/tsconfig.json), the tsc step in scripts/build.js fails with TS2339, so pnpm build cannot produce the CJS/ESM bundles from a clean checkout. The same mismatch exists in templates/full/index.ts, which is the source this file is generated from.

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Stale-stack regression test missing for versions 13-16 (package.json:33) — The new test/wasm-stale-stack.test.js was added only to versions 17 and 18, but versions 13-16 ship byte-identical wrapper code that calls module.stackSave()/stackRestore() (versions/13/src/index.ts:176) and got the same stackSave/stackRestore exports in their Makefiles in this PR (versions/13/Makefile:83).

The change hardens the WASM boundary: every callWasm entry now saves the Emscripten shadow stack before invoking the parser and restores it on error, and the C wrappers install a stack-limit guard computed from emscripten_stack_get_free() before each parse. A new stale-stack regression test covers versions 17 and 18, and the runtime-method exports are threaded through the template and all six version Makefiles.

Reviewed commit: 548eac8

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.

1 participant