From c83b1987d523ea0b62ef3963a3c97d661d458845 Mon Sep 17 00:00:00 2001 From: Ben Asher Date: Fri, 26 Jun 2026 16:18:24 -0700 Subject: [PATCH] build: reduce WASM memory footprint to cut Node load overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WASM module reserves its full initial linear memory the instant it is instantiated, before any query is parsed. The binaries declared 128 MiB initial (2048 pages), which Node reports as ~129 MB of `external` memory on load regardless of workload — the bulk of the ~200 MB footprint users hit. Since the build already sets ALLOW_MEMORY_GROWTH=1 (max 1 GiB), a large initial value buys nothing: it only inflates the load-time footprint. Static data in the binary is <1 MiB. Two changes: - INITIAL_MEMORY 128 -> 24 MiB. - STACK_SIZE 32 -> 16 MiB (this was the floor on INITIAL_MEMORY). Profiling shows the parser fails cleanly (catchable "memory exhausted" SqlError) under deep nesting rather than overflowing the C stack, and practical nesting depth is bounded well within 16 MiB. Net: `external` at load drops ~129 MB -> ~24 MB. Regression guards (per-package, run by CI which rebuilds the WASM from source): - memory.test.js asserts HEAPU8.buffer.byteLength (the live initial allocation) stays within budget. - deep-nesting.test.js asserts deeply nested input always parses or throws a clean error, never a hard WebAssembly trap — so a too-small stack fails CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- full/Makefile | 4 ++-- full/package.json | 2 +- full/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ full/test/memory.test.js | 29 +++++++++++++++++++++++++++ templates/Makefile.template | 4 ++-- versions/13/Makefile | 4 ++-- versions/14/Makefile | 4 ++-- versions/14/package.json | 2 +- versions/14/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ versions/14/test/memory.test.js | 29 +++++++++++++++++++++++++++ versions/15/Makefile | 4 ++-- versions/15/package.json | 2 +- versions/15/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ versions/15/test/memory.test.js | 29 +++++++++++++++++++++++++++ versions/16/Makefile | 4 ++-- versions/16/package.json | 2 +- versions/16/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ versions/16/test/memory.test.js | 29 +++++++++++++++++++++++++++ versions/17/Makefile | 4 ++-- versions/17/package.json | 2 +- versions/17/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ versions/17/test/memory.test.js | 29 +++++++++++++++++++++++++++ versions/18/Makefile | 4 ++-- versions/18/package.json | 2 +- versions/18/test/deep-nesting.test.js | 29 +++++++++++++++++++++++++++ versions/18/test/memory.test.js | 29 +++++++++++++++++++++++++++ 26 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 full/test/deep-nesting.test.js create mode 100644 full/test/memory.test.js create mode 100644 versions/14/test/deep-nesting.test.js create mode 100644 versions/14/test/memory.test.js create mode 100644 versions/15/test/deep-nesting.test.js create mode 100644 versions/15/test/memory.test.js create mode 100644 versions/16/test/deep-nesting.test.js create mode 100644 versions/16/test/memory.test.js create mode 100644 versions/17/test/deep-nesting.test.js create mode 100644 versions/17/test/memory.test.js create mode 100644 versions/18/test/deep-nesting.test.js create mode 100644 versions/18/test/memory.test.js diff --git a/full/Makefile b/full/Makefile index cdbf4db..49de5b4 100644 --- a/full/Makefile +++ b/full/Makefile @@ -66,9 +66,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/full/package.json b/full/package.json index 6848207..a5d3226 100644 --- a/full/package.json +++ b/full/package.json @@ -21,7 +21,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/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js", + "test": "node --test test/parsing.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js", "yamlize": "node ./scripts/yamlize.js" }, "author": "Constructive ", diff --git a/full/test/deep-nesting.test.js b/full/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/full/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/full/test/memory.test.js b/full/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/full/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +}); diff --git a/templates/Makefile.template b/templates/Makefile.template index 422f286..7d25287 100644 --- a/templates/Makefile.template +++ b/templates/Makefile.template @@ -71,9 +71,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/13/Makefile b/versions/13/Makefile index 4ace118..229195d 100644 --- a/versions/13/Makefile +++ b/versions/13/Makefile @@ -74,9 +74,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/14/Makefile b/versions/14/Makefile index 1d9b073..8a80879 100644 --- a/versions/14/Makefile +++ b/versions/14/Makefile @@ -71,9 +71,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/14/package.json b/versions/14/package.json index c1f7fd6..51af32c 100644 --- a/versions/14/package.json +++ b/versions/14/package.json @@ -28,7 +28,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": "node --test test/parsing.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/14/test/deep-nesting.test.js b/versions/14/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/versions/14/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/versions/14/test/memory.test.js b/versions/14/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/versions/14/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +}); diff --git a/versions/15/Makefile b/versions/15/Makefile index 68777d3..f775ffe 100644 --- a/versions/15/Makefile +++ b/versions/15/Makefile @@ -73,9 +73,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/15/package.json b/versions/15/package.json index adf7cd3..d563ad6 100644 --- a/versions/15/package.json +++ b/versions/15/package.json @@ -28,7 +28,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": "node --test test/parsing.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/15/test/deep-nesting.test.js b/versions/15/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/versions/15/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/versions/15/test/memory.test.js b/versions/15/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/versions/15/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +}); diff --git a/versions/16/Makefile b/versions/16/Makefile index 6bd9e62..e2b546d 100644 --- a/versions/16/Makefile +++ b/versions/16/Makefile @@ -71,9 +71,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/16/package.json b/versions/16/package.json index 8ec811d..1126c11 100644 --- a/versions/16/package.json +++ b/versions/16/package.json @@ -28,7 +28,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": "node --test test/parsing.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/16/test/deep-nesting.test.js b/versions/16/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/versions/16/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/versions/16/test/memory.test.js b/versions/16/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/versions/16/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +}); diff --git a/versions/17/Makefile b/versions/17/Makefile index 6674f7a..f4c9ad6 100644 --- a/versions/17/Makefile +++ b/versions/17/Makefile @@ -71,9 +71,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/17/package.json b/versions/17/package.json index 0014343..d6d866a 100644 --- a/versions/17/package.json +++ b/versions/17/package.json @@ -28,7 +28,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": "node --test test/parsing.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/17/test/deep-nesting.test.js b/versions/17/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/versions/17/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/versions/17/test/memory.test.js b/versions/17/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/versions/17/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +}); diff --git a/versions/18/Makefile b/versions/18/Makefile index b66d7d4..af77345 100644 --- a/versions/18/Makefile +++ b/versions/18/Makefile @@ -71,9 +71,9 @@ ifdef EMSCRIPTEN -sMODULARIZE=1 \ -sEXPORT_ES6=0 \ -sALLOW_MEMORY_GROWTH=1 \ - -sINITIAL_MEMORY=134217728 \ + -sINITIAL_MEMORY=25165824 \ -sMAXIMUM_MEMORY=1073741824 \ - -sSTACK_SIZE=33554432 \ + -sSTACK_SIZE=16777216 \ -lpg_query \ -o $@ \ $(SRC_FILES) diff --git a/versions/18/package.json b/versions/18/package.json index a76c966..ffdc1ed 100644 --- a/versions/18/package.json +++ b/versions/18/package.json @@ -28,7 +28,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": "node --test test/parsing.test.js test/errors.test.js test/memory.test.js test/deep-nesting.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/18/test/deep-nesting.test.js b/versions/18/test/deep-nesting.test.js new file mode 100644 index 0000000..a67f257 --- /dev/null +++ b/versions/18/test/deep-nesting.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const query = require('../'); + +// Pairs with the reduced STACK_SIZE in the Makefile. Parsing and serializing a +// deeply nested tree recurses on the WASM C stack; if that stack is too small, +// deep input crashes the module with a hard WebAssembly trap instead of failing +// gracefully. This test asserts the parser always either succeeds or throws a +// clean, catchable error (e.g. SqlError "memory exhausted") — never a WASM trap. +// CI rebuilds the WASM from source, so a stack that's too small fails here. +describe('Deep nesting fails gracefully', () => { + // Nested function calls build a genuinely deep parse tree (FuncCall nodes) — + // the shape that recurses hardest during tree->JSON serialization. Depths span + // the band that parses today through where it fails cleanly. + const nestedFuncs = (depth) => 'SELECT ' + 'f('.repeat(depth) + '1' + ')'.repeat(depth); + + for (const depth of [1000, 4000, 7000, 10000]) { + it(`depth ${depth}: parses or fails cleanly, never a WASM trap`, async () => { + try { + await query.parse(nestedFuncs(depth)); + } catch (err) { + assert.ok( + !(err instanceof WebAssembly.RuntimeError), + `deep input crashed with a WASM trap (stack too small?): ${err && err.message}` + ); + } + }); + } +}); diff --git a/versions/18/test/memory.test.js b/versions/18/test/memory.test.js new file mode 100644 index 0000000..6fe91cb --- /dev/null +++ b/versions/18/test/memory.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Emscripten MODULARIZE factory; calling it instantiates a fresh, isolated +// WASM instance with its own linear memory. +const createModule = require('../wasm/libpg-query.js'); + +// Guards the WASM load-time footprint from regressing. The module reserves its +// initial linear memory on instantiation, before any parse — that reservation +// is what inflates load-time memory. Growth is enabled (-sALLOW_MEMORY_GROWTH), +// so the initial size is a footprint knob, not a hard limit. We read the live +// size from the instance (HEAPU8 views the linear memory) instead of parsing +// the binary: no dependency, and it reflects the real allocation. +describe('WASM memory footprint', () => { + // Ceiling for the initial reservation; the single source of truth. Keep it a + // bit above -sINITIAL_MEMORY in the Makefile so normal tuning doesn't trip it. + const MAX_INITIAL_BYTES = 64 * 1024 * 1024; + + it('reserves an initial linear memory within budget', async () => { + const m = await createModule(); + const initialBytes = m.HEAPU8.buffer.byteLength; + const mib = (n) => (n / (1024 * 1024)).toFixed(0); + assert.ok( + initialBytes <= MAX_INITIAL_BYTES, + `WASM initial memory ${mib(initialBytes)} MiB exceeds the ${mib(MAX_INITIAL_BYTES)} MiB budget. ` + + `Lower -sINITIAL_MEMORY in the Makefile (memory grows on demand via -sALLOW_MEMORY_GROWTH).` + ); + }); +});