From 548eac885718a9fd27ab20b9b061b25902a9b2e2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 24 Oct 2026 01:46:17 +0000 Subject: [PATCH 1/2] fix: stack-depth guard and shadow-stack restore for deep recursion 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. --- templates/Makefile.template | 2 +- templates/full/index.ts | 5 +++ templates/full/wasm_wrapper.c | 18 +++++++++++ templates/index.ts | 5 +++ templates/wasm_wrapper.c | 12 +++++++ versions/13/Makefile | 2 +- versions/13/src/index.ts | 5 +++ versions/13/src/wasm_wrapper.c | 12 +++++++ versions/14/Makefile | 2 +- versions/14/src/index.ts | 5 +++ versions/14/src/wasm_wrapper.c | 12 +++++++ versions/15/Makefile | 2 +- versions/15/src/index.ts | 5 +++ versions/15/src/wasm_wrapper.c | 12 +++++++ versions/16/Makefile | 2 +- versions/16/src/index.ts | 5 +++ versions/16/src/wasm_wrapper.c | 12 +++++++ versions/17/Makefile | 4 +-- versions/17/package.json | 4 +-- versions/17/src/index.ts | 5 +++ versions/17/src/wasm_wrapper.c | 12 +++++++ versions/17/test/wasm-stale-stack.test.js | 38 +++++++++++++++++++++++ versions/18/Makefile | 4 +-- versions/18/package.json | 4 +-- versions/18/src/index.ts | 5 +++ versions/18/src/wasm_wrapper.c | 18 +++++++++++ versions/18/test/wasm-stale-stack.test.js | 38 +++++++++++++++++++++++ 27 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 versions/17/test/wasm-stale-stack.test.js create mode 100644 versions/18/test/wasm-stale-stack.test.js diff --git a/templates/Makefile.template b/templates/Makefile.template index 952520d5..5f82744c 100644 --- a/templates/Makefile.template +++ b/templates/Makefile.template @@ -76,7 +76,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="{{EXPORTED_FUNCTIONS}}" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/templates/full/index.ts b/templates/full/index.ts index 682f05b5..435df9a9 100644 --- a/templates/full/index.ts +++ b/templates/full/index.ts @@ -177,9 +177,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/templates/full/wasm_wrapper.c b/templates/full/wasm_wrapper.c index 268d2893..6e9bcd8f 100644 --- a/templates/full/wasm_wrapper.c +++ b/templates/full/wasm_wrapper.c @@ -1,10 +1,21 @@ #include "pg_query.h" #include "protobuf/pg_query.pb-c.h" #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -28,6 +39,7 @@ static void* safe_malloc(size_t size) { EMSCRIPTEN_KEEPALIVE char* wasm_parse_query(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -47,6 +59,7 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); PgQueryParseResult* result = (PgQueryParseResult*)malloc(sizeof(PgQueryParseResult)); if (!result) { return NULL; @@ -66,6 +79,7 @@ void wasm_free_parse_result(PgQueryParseResult* result) { EMSCRIPTEN_KEEPALIVE char* wasm_parse_plpgsql(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -106,6 +120,7 @@ char* wasm_parse_plpgsql(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_fingerprint(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -125,6 +140,7 @@ char* wasm_fingerprint(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_normalize_query(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -165,6 +181,7 @@ typedef struct { EMSCRIPTEN_KEEPALIVE WasmDetailedResult* wasm_parse_query_detailed(const char* input) { + set_stack_guard(); WasmDetailedResult* result = safe_malloc(sizeof(WasmDetailedResult)); if (!result) { return NULL; @@ -365,6 +382,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi EMSCRIPTEN_KEEPALIVE char* wasm_scan(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } diff --git a/templates/index.ts b/templates/index.ts index 4a9a8929..6e487eb4 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -165,9 +165,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/templates/wasm_wrapper.c b/templates/wasm_wrapper.c index 59c5a979..f53bcecf 100644 --- a/templates/wasm_wrapper.c +++ b/templates/wasm_wrapper.c @@ -1,8 +1,19 @@ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -18,6 +29,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/13/Makefile b/versions/13/Makefile index 47cdf4d7..fb5fc97c 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -80,7 +80,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/13/src/index.ts b/versions/13/src/index.ts index ea70bb2a..7c1340a1 100644 --- a/versions/13/src/index.ts +++ b/versions/13/src/index.ts @@ -172,9 +172,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/13/src/wasm_wrapper.c b/versions/13/src/wasm_wrapper.c index 38151683..2e13e261 100644 --- a/versions/13/src/wasm_wrapper.c +++ b/versions/13/src/wasm_wrapper.c @@ -6,10 +6,21 @@ */ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -25,6 +36,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/14/Makefile b/versions/14/Makefile index 5fe746c8..57a74d36 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -75,7 +75,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/14/src/index.ts b/versions/14/src/index.ts index ea70bb2a..7c1340a1 100644 --- a/versions/14/src/index.ts +++ b/versions/14/src/index.ts @@ -172,9 +172,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/14/src/wasm_wrapper.c b/versions/14/src/wasm_wrapper.c index 38151683..2e13e261 100644 --- a/versions/14/src/wasm_wrapper.c +++ b/versions/14/src/wasm_wrapper.c @@ -6,10 +6,21 @@ */ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -25,6 +36,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/15/Makefile b/versions/15/Makefile index a16a538c..af9a3f42 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -75,7 +75,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/15/src/index.ts b/versions/15/src/index.ts index ea70bb2a..7c1340a1 100644 --- a/versions/15/src/index.ts +++ b/versions/15/src/index.ts @@ -172,9 +172,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/15/src/wasm_wrapper.c b/versions/15/src/wasm_wrapper.c index 38151683..2e13e261 100644 --- a/versions/15/src/wasm_wrapper.c +++ b/versions/15/src/wasm_wrapper.c @@ -6,10 +6,21 @@ */ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -25,6 +36,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/16/Makefile b/versions/16/Makefile index 05e7e6f3..f19ce690 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -75,7 +75,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/16/src/index.ts b/versions/16/src/index.ts index ea70bb2a..7c1340a1 100644 --- a/versions/16/src/index.ts +++ b/versions/16/src/index.ts @@ -172,9 +172,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/16/src/wasm_wrapper.c b/versions/16/src/wasm_wrapper.c index 38151683..2e13e261 100644 --- a/versions/16/src/wasm_wrapper.c +++ b/versions/16/src/wasm_wrapper.c @@ -6,10 +6,21 @@ */ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -25,6 +36,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/17/Makefile b/versions/17/Makefile index 747eb3f0..3e8b3e28 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -17,7 +17,7 @@ WASM_STACK_SIZE ?= 33554432 # byte-for-byte. Set in package.json `x-publish` and applied by copy:templates. LIBPG_QUERY_REPO := https://github.com/constructive-io/libpg_query.git LIBPG_QUERY_TAG := 17-constructive -LIBPG_QUERY_COMMIT := 1ade880fd1bbc6e89c33ea2424ddc366c41c1408 +LIBPG_QUERY_COMMIT := 5691182b996645135febd16d6403668298bf0c8b CACHE_DIR := .cache @@ -75,7 +75,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/17/package.json b/versions/17/package.json index 8f6e5e26..44c121a9 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -15,7 +15,7 @@ "distTag": "pg17", "libpgQueryRepo": "https://github.com/constructive-io/libpg_query.git", "libpgQueryTag": "17-constructive", - "libpgQueryCommit": "1ade880fd1bbc6e89c33ea2424ddc366c41c1408" + "libpgQueryCommit": "5691182b996645135febd16d6403668298bf0c8b" }, "files": [ "wasm/*" @@ -30,7 +30,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js" + "test": "node --test test/parsing.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js test/wasm-stale-stack.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/17/src/index.ts b/versions/17/src/index.ts index ea70bb2a..7c1340a1 100644 --- a/versions/17/src/index.ts +++ b/versions/17/src/index.ts @@ -172,9 +172,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/17/src/wasm_wrapper.c b/versions/17/src/wasm_wrapper.c index 38151683..2e13e261 100644 --- a/versions/17/src/wasm_wrapper.c +++ b/versions/17/src/wasm_wrapper.c @@ -6,10 +6,21 @@ */ #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -25,6 +36,7 @@ static void* safe_malloc(size_t size) { // Raw struct access functions for parse EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return NULL; } diff --git a/versions/17/test/wasm-stale-stack.test.js b/versions/17/test/wasm-stale-stack.test.js new file mode 100644 index 00000000..eec569f1 --- /dev/null +++ b/versions/17/test/wasm-stale-stack.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +// A V8 stack overflow inside the parser unwinds the JS frames but not the C +// shadow stack. Sync calls in the same tick must keep using that instance until +// its replacement arrives, and used to start half-way down the stack: with a +// smaller native stack (--stack-size=8000) the third or fourth one overran the +// shadow stack and trapped instead of throwing RangeError. +const child = ` + const lib = require(process.argv[1]); + const deep = 'SELECT 1' + '+1'.repeat(100000); + (async () => { + await lib.loadModule(); + const seen = []; + for (let i = 0; i < 10; i++) { + try { lib.parseSync(deep); seen.push('ok'); } catch (error) { seen.push(error.constructor.name); } + } + await lib.loadModule(); + seen.push(lib.parseSync('SELECT 1').stmts.length); + console.log(JSON.stringify(seen)); + })(); +`; + +describe('Sync calls on a stale instance after a stack overflow', { timeout: 120000 }, () => { + it('throw RangeError (or an error from libpg_query), never a WebAssembly trap', () => { + const stdout = execFileSync( + process.execPath, + ['--stack-size=8000', '-e', child, path.join(__dirname, '..', 'wasm', 'index.cjs')], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ); + const seen = JSON.parse(stdout.trim().split('\n').pop()); + assert.equal(seen.pop(), 1, 'the replacement instance parses'); + assert.ok(!seen.includes('ok'), 'the deep expression must not parse'); + for (const name of seen) assert.notEqual(name, 'RuntimeError', `trap: ${JSON.stringify(seen)}`); + }); +}); diff --git a/versions/18/Makefile b/versions/18/Makefile index c0c91c28..b1674817 100644 --- a/versions/18/Makefile +++ b/versions/18/Makefile @@ -17,7 +17,7 @@ WASM_STACK_SIZE ?= 33554432 # byte-for-byte. Set in package.json `x-publish` and applied by copy:templates. LIBPG_QUERY_REPO := https://github.com/constructive-io/libpg_query.git LIBPG_QUERY_TAG := 18-constructive -LIBPG_QUERY_COMMIT := b0f5f331dc50d69597ec610de7b21afa47a66808 +LIBPG_QUERY_COMMIT := 64e7edacbce8e376754d35240c55c62289783d44 CACHE_DIR := .cache @@ -75,7 +75,7 @@ ifdef EMSCRIPTEN -I$(LIBPG_QUERY_DIR)/vendor \ -L$(LIBPG_QUERY_DIR) \ -sEXPORTED_FUNCTIONS="['_malloc','_free','_wasm_parse_query','_wasm_parse_plpgsql','_wasm_fingerprint','_wasm_normalize_query','_wasm_scan','_wasm_parse_query_detailed','_wasm_free_detailed_result','_wasm_free_string','_wasm_parse_query_raw','_wasm_free_parse_result']" \ - -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32']" \ + -sEXPORTED_RUNTIME_METHODS="['lengthBytesUTF8','stringToUTF8','getValue','UTF8ToString','HEAPU8','HEAPU32','stackSave','stackRestore']" \ -sEXPORT_NAME="$(WASM_MODULE_NAME)" \ -sENVIRONMENT="web,node,worker" \ -sASSERTIONS=0 \ diff --git a/versions/18/package.json b/versions/18/package.json index bcaaf273..779f2803 100644 --- a/versions/18/package.json +++ b/versions/18/package.json @@ -15,7 +15,7 @@ "distTag": "pg18", "libpgQueryRepo": "https://github.com/constructive-io/libpg_query.git", "libpgQueryTag": "18-constructive", - "libpgQueryCommit": "b0f5f331dc50d69597ec610de7b21afa47a66808", + "libpgQueryCommit": "64e7edacbce8e376754d35240c55c62289783d44", "fullApi": true }, "files": [ @@ -31,7 +31,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js test/pg18.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js" + "test": "node --test test/parsing.test.js test/pg18.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js test/wasm-stale-stack.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/18/src/index.ts b/versions/18/src/index.ts index e905d6c6..3a997688 100644 --- a/versions/18/src/index.ts +++ b/versions/18/src/index.ts @@ -184,9 +184,14 @@ instantiate(); function callWasm(fn: () => number): number { const proc = (globalThis as any).process; const exitCode = proc?.exitCode; + const module = wasmModule; + const stackPointer = module.stackSave(); try { return fn(); } catch (error) { + // The throw unwound the JS frames but not the C shadow stack; give it + // back so later sync calls on this instance do not start half-way down it. + module.stackRestore(stackPointer); failures++; stale = true; if (++staleFailures >= MAX_STALE_FAILURES) wasmModule = undefined; diff --git a/versions/18/src/wasm_wrapper.c b/versions/18/src/wasm_wrapper.c index 63ae5dab..f76d6c75 100644 --- a/versions/18/src/wasm_wrapper.c +++ b/versions/18/src/wasm_wrapper.c @@ -8,10 +8,21 @@ #include "pg_query.h" #include "protobuf/pg_query.pb-c.h" #include +#include #include #include #include +/* libpg_query's check_stack_depth() needs a reference point and a limit per + * entry: the shadow stack left after the previous call, minus a 512 KiB + * margin for the frames between here and the recursion. */ +extern void pg_query_set_stack_limit(size_t bytes) __attribute__((weak)); +static void set_stack_guard(void) { + if (!pg_query_set_stack_limit) return; /* libpg_query without the guard (upstream pins) */ + size_t free_bytes = emscripten_stack_get_free(); + pg_query_set_stack_limit(free_bytes > 524288 ? free_bytes - 524288 : 0); +} + static int validate_input(const char* input) { return input != NULL && strlen(input) > 0; } @@ -35,6 +46,7 @@ static void* safe_malloc(size_t size) { EMSCRIPTEN_KEEPALIVE char* wasm_parse_query(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -54,6 +66,7 @@ char* wasm_parse_query(const char* input) { EMSCRIPTEN_KEEPALIVE PgQueryParseResult* wasm_parse_query_raw(const char* input) { + set_stack_guard(); PgQueryParseResult* result = (PgQueryParseResult*)malloc(sizeof(PgQueryParseResult)); if (!result) { return NULL; @@ -73,6 +86,7 @@ void wasm_free_parse_result(PgQueryParseResult* result) { EMSCRIPTEN_KEEPALIVE char* wasm_parse_plpgsql(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -113,6 +127,7 @@ char* wasm_parse_plpgsql(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_fingerprint(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -132,6 +147,7 @@ char* wasm_fingerprint(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_normalize_query(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } @@ -172,6 +188,7 @@ typedef struct { EMSCRIPTEN_KEEPALIVE WasmDetailedResult* wasm_parse_query_detailed(const char* input) { + set_stack_guard(); WasmDetailedResult* result = safe_malloc(sizeof(WasmDetailedResult)); if (!result) { return NULL; @@ -372,6 +389,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi EMSCRIPTEN_KEEPALIVE char* wasm_scan(const char* input) { + set_stack_guard(); if (!validate_input(input)) { return safe_strdup("Invalid input: query cannot be null or empty"); } diff --git a/versions/18/test/wasm-stale-stack.test.js b/versions/18/test/wasm-stale-stack.test.js new file mode 100644 index 00000000..eec569f1 --- /dev/null +++ b/versions/18/test/wasm-stale-stack.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +// A V8 stack overflow inside the parser unwinds the JS frames but not the C +// shadow stack. Sync calls in the same tick must keep using that instance until +// its replacement arrives, and used to start half-way down the stack: with a +// smaller native stack (--stack-size=8000) the third or fourth one overran the +// shadow stack and trapped instead of throwing RangeError. +const child = ` + const lib = require(process.argv[1]); + const deep = 'SELECT 1' + '+1'.repeat(100000); + (async () => { + await lib.loadModule(); + const seen = []; + for (let i = 0; i < 10; i++) { + try { lib.parseSync(deep); seen.push('ok'); } catch (error) { seen.push(error.constructor.name); } + } + await lib.loadModule(); + seen.push(lib.parseSync('SELECT 1').stmts.length); + console.log(JSON.stringify(seen)); + })(); +`; + +describe('Sync calls on a stale instance after a stack overflow', { timeout: 120000 }, () => { + it('throw RangeError (or an error from libpg_query), never a WebAssembly trap', () => { + const stdout = execFileSync( + process.execPath, + ['--stack-size=8000', '-e', child, path.join(__dirname, '..', 'wasm', 'index.cjs')], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ); + const seen = JSON.parse(stdout.trim().split('\n').pop()); + assert.equal(seen.pop(), 1, 'the replacement instance parses'); + assert.ok(!seen.includes('ok'), 'the deep expression must not parse'); + for (const name of seen) assert.notEqual(name, 'RuntimeError', `trap: ${JSON.stringify(seen)}`); + }); +}); From 32656620063311ebc1560603b9b98cd8955dc215 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 24 Oct 2026 02:49:42 +0000 Subject: [PATCH 2/2] chore: pin v17/v18 to the fork tips that also free the PL/pgSQL alias list --- versions/17/Makefile | 2 +- versions/17/package.json | 2 +- versions/18/Makefile | 2 +- versions/18/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/versions/17/Makefile b/versions/17/Makefile index 3e8b3e28..d101e0c5 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -17,7 +17,7 @@ WASM_STACK_SIZE ?= 33554432 # byte-for-byte. Set in package.json `x-publish` and applied by copy:templates. LIBPG_QUERY_REPO := https://github.com/constructive-io/libpg_query.git LIBPG_QUERY_TAG := 17-constructive -LIBPG_QUERY_COMMIT := 5691182b996645135febd16d6403668298bf0c8b +LIBPG_QUERY_COMMIT := ab619b807feea69fdaaa4ea1516236b575bc6d1d CACHE_DIR := .cache diff --git a/versions/17/package.json b/versions/17/package.json index 44c121a9..f640640f 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -15,7 +15,7 @@ "distTag": "pg17", "libpgQueryRepo": "https://github.com/constructive-io/libpg_query.git", "libpgQueryTag": "17-constructive", - "libpgQueryCommit": "5691182b996645135febd16d6403668298bf0c8b" + "libpgQueryCommit": "ab619b807feea69fdaaa4ea1516236b575bc6d1d" }, "files": [ "wasm/*" diff --git a/versions/18/Makefile b/versions/18/Makefile index b1674817..a911156c 100644 --- a/versions/18/Makefile +++ b/versions/18/Makefile @@ -17,7 +17,7 @@ WASM_STACK_SIZE ?= 33554432 # byte-for-byte. Set in package.json `x-publish` and applied by copy:templates. LIBPG_QUERY_REPO := https://github.com/constructive-io/libpg_query.git LIBPG_QUERY_TAG := 18-constructive -LIBPG_QUERY_COMMIT := 64e7edacbce8e376754d35240c55c62289783d44 +LIBPG_QUERY_COMMIT := b66cc16ea07eeec340f317b91a93e0cbea7922c0 CACHE_DIR := .cache diff --git a/versions/18/package.json b/versions/18/package.json index 779f2803..f4d52b23 100644 --- a/versions/18/package.json +++ b/versions/18/package.json @@ -15,7 +15,7 @@ "distTag": "pg18", "libpgQueryRepo": "https://github.com/constructive-io/libpg_query.git", "libpgQueryTag": "18-constructive", - "libpgQueryCommit": "64e7edacbce8e376754d35240c55c62289783d44", + "libpgQueryCommit": "b66cc16ea07eeec340f317b91a93e0cbea7922c0", "fullApi": true }, "files": [