diff --git a/templates/Makefile.template b/templates/Makefile.template index b3163b4..ab781ef 100644 --- a/templates/Makefile.template +++ b/templates/Makefile.template @@ -78,7 +78,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 e97b609..5b03c34 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 268d289..6e9bcd8 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 4eb9784..28ff48e 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 59c5a97..f53bcec 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 08d42f8..9c80236 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -82,7 +82,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 f39ac71..467bf7e 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 3815168..2e13e26 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 db44a9b..3e64d63 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -77,7 +77,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 f39ac71..467bf7e 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 3815168..2e13e26 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 8969dc3..07a7f25 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -77,7 +77,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 f39ac71..467bf7e 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 3815168..2e13e26 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 0b537cf..a5da0fd 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -77,7 +77,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 f39ac71..467bf7e 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 3815168..2e13e26 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 36624df..cae768b 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -19,7 +19,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 := ab619b807feea69fdaaa4ea1516236b575bc6d1d CACHE_DIR := .cache @@ -77,7 +77,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 567a67b..2db61e0 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": "ab619b807feea69fdaaa4ea1516236b575bc6d1d" }, "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 f39ac71..467bf7e 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 3815168..2e13e26 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 0000000..eec569f --- /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 268e2f1..29ec2d4 100644 --- a/versions/18/Makefile +++ b/versions/18/Makefile @@ -19,7 +19,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 := b66cc16ea07eeec340f317b91a93e0cbea7922c0 CACHE_DIR := .cache @@ -77,7 +77,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 1ee183f..665078f 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": "b66cc16ea07eeec340f317b91a93e0cbea7922c0", "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 56c93d6..9985de4 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 63ae5da..f76d6c7 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 0000000..eec569f --- /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)}`); + }); +});