Fix JS_SetMemoryLimit byte accounting on WASI - #1711
Open
josefguenther wants to merge 1 commit into
Open
Conversation
js__malloc_usable_size() has no branch for WASI and falls through to returning zero. js_malloc_rt() checks the requested size against malloc_limit but advances malloc_size by the usable size plus MALLOC_OVERHEAD, so every block the arena does not handle is accounted as 8 bytes whatever its size. One allocation larger than the limit is still refused, because that check reads the requested size; any number of smaller ones are not, because nothing accumulates. JS_SetMemoryLimit() on WASI bounds an allocation count, not a byte total. The arena byte-accounts allocations up to JS_ARENA_MAX_SMALL_SIZE, so what escapes is bulk allocation. Measured on a wasm32-wasi build under an 8 MB limit: 60 retained 1 MB Uint8Arrays all succeed, and a loop appending 1 MB buffers under a 32 MB limit runs to 4095 MB, the wasm32 address space ceiling. wasi-libc declares malloc_usable_size() in <malloc.h> and defines it in libc.a, so name __wasi__ in the two selectors that already have the other platforms. Add an api-test case asserting that a large allocation moves malloc_size by its own size and that sub-limit blocks accumulate against the limit. It passes wherever the usable size is real and fails where it is reported as zero. Refs: vercel-labs/quickjs-wasi#30 Refs: justjake/quickjs-emscripten#271
Contributor
|
tl;dr wall of LLM text. In a dozen words max, what's the problem this PR fixes? |
Author
|
I'll try: the authors simply forgot the WASI branch in a couple places. When setting a memory limit, it's supposed to honor that limit whenever memory is allocated. Currently it's inconsistent on WASI: allocating some variable types like integers does honor it, but others like strings or arrays only honor it if a string over the limit is instantiated (eg if the memory limit is set to 8MB: allocating a 14MB string will fail but setting two strings 7MB long will work, exceeding the memory limit). Side note: this may affect emscripten/musl too but I didn't chase that. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On WASI,
JS_SetMemoryLimit()does not bound the bytes of any allocation the arena does not handle. Each of those is accounted at a constantMALLOC_OVERHEAD, so for bulk allocation the limit is a count rather than a byte total: apush(new Uint8Array(1MB))loop under a 32 MB limit runs to 4095 MB, the wasm32 address-space ceiling.Mechanism
js__malloc_usable_size()(cutils.h) has no branch for WASI and falls through toreturn 0.js_malloc_rt()then does:The check reads the real requested size. The accumulator advances by a constant 8 bytes. That asymmetry is the whole defect: one allocation larger than the limit is refused, because the check is honest, and no number of sub-limit allocations ever accumulates, because the accounting is not.
The arena byte-accounts blocks up to
JS_ARENA_MAX_SMALL_SIZEthrougharena_block_sizes[], so small objects are bounded. Anything larger reachesjs_arena_usable_size()'sJS_ARENA_FREE_NILbranch, which calls the platform function, so bulk allocation is what escapes — and bulk allocation is the whole exposure.Measured
wasm32-wasi, wasi-sdk 25, run under a wazero host. Same build and host, patched vs unpatched. These are measurements on one build, not a CI matrix.
Uint8Array@ 8 MB limitUint8Array@ 8 MB limitUint8Array@ 8 MB limitpush(new Uint8Array(1MB))loop @ 32 MB limitThe string row is from 0.12.1, before the arena landed; at 0.16.2 a 600-byte allocation is inside
JS_ARENA_MAX_SMALL_SIZEand that loop is bounded either way. The bulk rows are unchanged at 0.16.2, and the code path is unchanged atdf836d1: 60 × 1 MB under an 8 MB limit all still succeed.The same numbers reproduce on emscripten builds, which is what identifies the shared
#elserather than a packaging difference: justjake/quickjs-emscripten#271 has the full probe table for that side. vercel-labs/quickjs-wasi#30 is an independent report of the same behaviour, worked around downstream in #33 by supplying malloc functions with a working usable size rather than patching this file.The fix
wasi-libc declares
malloc_usable_size()in<malloc.h>and defines it inlibc.a, so__wasi__is named in the two selectors that already list the other platforms — the include and the accounting branch. No new code.Emscripten's musl provides the same symbol and falls into the same
#else. It is left alone here because wasi is what was measured end to end; happy to add it if you want both in one change.Test
api-test.cgainslarge_allocation_accounting(): a 1 MBUint8Arraymust movemalloc_sizeby at least its own size, and 64 × 1 MB under a 4 MB limit must throw with real usage staying under 8 MB. It is platform-neutral — it passes wherever the usable size is real and fails wherever it is reported as zero. Verified by forcingjs__malloc_usable_size()toreturn 0on macOS, where it aborts on the first assertion.Note it does not currently run on WASI: the
wasiCI job buildsqjs_exeand runs a script, it does not build or runapi-test. So CI will go green with and without this patch — the test documents the invariant on every platform, but nothing in the matrix exercises the one platform being fixed. Say the word and I will extend that job so it does, or drop the test if you would rather keep the change to the two lines.One pre-existing behaviour the fix reveals
Honest accounting exposes something that is already true on Linux and macOS today: a heap exhausted by many small allocations can no longer build the
Errordescribing its own exhaustion.JS_ThrowError2substitutesJS_NULLto avoid recursing, so the embedder sees a barenullrather thanInternalError: out of memory. A refused large block still reports properly, because the refusal leaves headroom.The broken accounting was hiding this on WASI — the count budget was exhausted while real memory remained, so the error object always allocated. It is not introduced by this patch. Downstream hit it as soon as they fixed the accounting (vercel-labs/quickjs-wasi#39) and solved it by reserving headroom below the enforced limit. Whether the engine should do something similar is a separate question from this one; flagging it so it is not attributed here.
Checks
cmake -B build -DCMAKE_BUILD_TYPE=Release,api-testandrun-test262 -c tests.conf(0/112 errors) pass on macOS/arm64 atdf836d1.