Skip to content

LiteralParser: throw RangeError on OOM when copying a JSON string value - #317

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/50a7f08f/json-parse-oom-throw
Jul 24, 2026
Merged

LiteralParser: throw RangeError on OOM when copying a JSON string value#317
Jarred-Sumner merged 2 commits into
mainfrom
farm/50a7f08f/json-parse-oom-throw

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What

JSONAtomStringCache::makeJSString constructed a new String from the lexer's span for every JSON string value longer than 16 chars. String(characters) lands in StringImpl::createUninitializedInternalNonEmpty which calls StringImplMalloc::malloc, the crash-or-succeed path. When the allocator cannot satisfy that request, JSON.parse kills the process instead of throwing.

This switches the copy to StringImpl::tryCreateUninitialized and bubbles a null result up to LiteralParser::parsePrimitiveValue, which throws RangeError: Out of memory (the same error String.prototype.repeat and other WTF-string callers throw on allocation failure). All of parsePrimitiveValue's callers already treat an empty return with a pending exception correctly: parseRecursively asserts (!!scope.exception() || !m_parseErrorMessage.isNull()) == !value, and the top-level JSON.parse callers in JSONObject.cpp check RETURN_IF_EXCEPTION before falling back to a SyntaxError.

Why

Seen in Sentry as a fastCompactMalloc null dereference on Windows (Bun issue BUN-2Z94). Reproducible on Linux by running JSON.parse on a ~200 MB quoted string under ulimit -v with the remaining address space filled so the value copy cannot fit:

# before
INPUT-OK
panic(main thread): Illegal instruction at address 0x177B834

# after
INPUT-OK
CAUGHT:RangeError:Out of memory

The maxAtomizeStringLength == 16 cached/atomized branches are unchanged; a 16-byte allocation failing is not a realistic path, and AtomStringImpl::add already returns RefPtr.

JSONAtomStringCache::makeJSString called String(characters) for string
values longer than 16 chars. That constructor goes through
StringImpl::createUninitializedInternalNonEmpty -> StringImplMalloc::malloc,
which is the crash-or-succeed allocator. Parsing a JSON string value that
cannot be allocated killed the process.

Use StringImpl::tryCreateUninitialized and surface a null result up
through LiteralParser::parsePrimitiveValue, which now throws the same
RangeError: Out of memory that String.prototype.repeat and friends throw.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Large JSON strings now use explicit StringImpl allocation with failure propagation. LiteralParser adopts the renamed tryMakeJSString helper and throws an out-of-memory error when string creation returns null.

Changes

JSON string allocation

Layer / File(s) Summary
Explicit large-string allocation
Source/JavaScriptCore/runtime/JSONAtomStringCache.h, Source/JavaScriptCore/runtime/JSONAtomStringCacheInlines.h
The cache API is renamed to tryMakeJSString; large strings allocate and populate an uninitialized StringImpl, returning null if allocation fails.
Parser allocation-failure propagation
Source/JavaScriptCore/runtime/LiteralParser.h, Source/JavaScriptCore/runtime/LiteralParser.cpp
LiteralParser uses tryMakeJSString, throws an out-of-memory error for null results, and returns an empty JSValue.

Suggested reviewers: constellation

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the change and motivation, but it omits the required bug title, Bugzilla link, reviewed-by line, and change list. Rewrite it to the repo template: bug title, Bugzilla URL, Reviewed by line, fix explanation, and bullet list of changed files/functions.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: handling OOM in JSON string parsing by throwing RangeError instead of crashing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues — the change looks correct and follows the established tryCreateUninitialized + throwOutOfMemoryError pattern — but this sits on the JSON.parse hot path (every string value > 16 chars flows through the new branch in an ALWAYS_INLINE function), so it's worth a human confirming the success path stays perf-neutral.

What was reviewed:

  • StringImpl::tryCreateUninitialized(size_t, span&) and String(RefPtr<StringImpl>&&) signatures match the new call site; copyElements mirrors existing usage in this file.
  • All parsePrimitiveValue callers (parseRecursively, parse, evalRecursivelyEntry, tryStreamingParse) already treat an empty return as failure and bail; parseRecursively's EXCEPTION_ASSERT covers the new pending-exception case.
  • tryStreamingParse can already receive !value with a pending exception via parseRecursively's RETURN_IF_EXCEPTION, so the new OOM path doesn't introduce a new state there.
Extended reasoning...

Overview

Two-file change to make JSON.parse throw RangeError: Out of memory instead of crashing when copying a large JSON string value fails to allocate. JSONAtomStringCache::makeJSString swaps the crash-or-succeed String(span) constructor for StringImpl::tryCreateUninitialized + WTF::copyElements, returning nullptr on failure. LiteralParser::parsePrimitiveValue gains a null check that declares a throw scope and calls throwOutOfMemoryError.

Security risks

None identified. The change only affects allocation-failure handling; the success path is a byte-for-byte equivalent (allocate uninitialized StringImpl of characters.size(), memcpy the span in). No new user-controlled sizes or pointer arithmetic — characters.size() was already the allocation size before.

Level of scrutiny

Medium-high. This is core JavaScriptCore runtime on the JSON.parse hot path: makeJSString is ALWAYS_INLINE and the modified branch runs for every JSON string value longer than 16 characters. The transformation itself is mechanical and matches the pattern used widely across JSC for fallible string allocation, and the added branch is [[unlikely]], but hot-path changes in JSC generally warrant a human look for codegen/inlining regressions.

Other factors

I traced every parsePrimitiveValue call site in LiteralParser.cpp: each one checks if (!result/value/primitive/lastValue) and returns {}, and the recursive/iterative parsers already have throw scopes with EXCEPTION_ASSERT((!!scope.exception() || !m_parseErrorMessage.isNull()) == !value), which the new state (empty value + pending exception, null m_parseErrorMessage) satisfies. tryStreamingParse (Bun-specific) could already see !value with a pending exception via constructEmptyArray OOM in parseRecursively, so its !valueError/NeedMoreData handling is not a new concern introduced here. The PR description's caller analysis matches what I found, and the bug-hunting pass surfaced nothing.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
23fb575d autobuild-preview-pr-317-23fb575d 2026-07-23 01:05:28 UTC
d9b06a28 autobuild-preview-pr-317-d9b06a28 2026-07-22 23:09:45 UTC

Follows the JSC convention of prefixing try- on functions that return
nullptr on allocation failure (tryCreateUninitialized, tryMakeString).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Source/JavaScriptCore/runtime/JSONAtomStringCacheInlines.h (1)

98-104: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Add an OOM regression test for both character widths.

Exercise forced tryCreateUninitialized failure for 8-bit and 16-bit JSON strings, asserting that parsing raises RangeError: Out of memory instead of terminating or reporting a syntax error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/JSONAtomStringCacheInlines.h` around lines 98 -
104, Add regression coverage for the JSON parsing path using both 8-bit and
16-bit strings, forcing StringImpl::tryCreateUninitialized to fail. Assert each
parse raises RangeError with the message “Out of memory,” and verify it does not
terminate or produce a syntax error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Source/JavaScriptCore/runtime/JSONAtomStringCacheInlines.h`:
- Around line 98-104: Add regression coverage for the JSON parsing path using
both 8-bit and 16-bit strings, forcing StringImpl::tryCreateUninitialized to
fail. Assert each parse raises RangeError with the message “Out of memory,” and
verify it does not terminate or produce a syntax error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f82340d8-ac6d-45be-860e-75f8b1e68a2b

📥 Commits

Reviewing files that changed from the base of the PR and between d9b06a2 and 23fb575.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/runtime/JSONAtomStringCache.h
  • Source/JavaScriptCore/runtime/JSONAtomStringCacheInlines.h
  • Source/JavaScriptCore/runtime/LiteralParser.cpp
  • Source/JavaScriptCore/runtime/LiteralParser.h

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues — the success path is identical to what String(span) did internally, just with tryMalloc instead of the crash-on-fail path. Deferring to a human only because this sits directly on the JSON.parse string-materialization hot path.

What was reviewed:

  • tryCreateUninitialized signature/semantics vs. the old String(span)createUninitializedInternalNonEmpty path — same allocation size and copy, only the malloc variant differs.
  • All parsePrimitiveValue call sites (parseRecursively, parse, evalRecursivelyEntry, tryLiteralParsePrimitiveValue, tryStreamingParse) already treat an empty return as failure and callers check for pending exceptions.
  • jsNontrivialString length assertion — this branch only runs for size() > 16, so length() > 1 holds.
  • Rename is complete; LiteralParser is the only caller of JSONAtomStringCache::tryMakeJSString.
Extended reasoning...

Overview

The PR renames JSONAtomStringCache::makeJSStringtryMakeJSString and replaces the > 16 char branch's jsNontrivialString(vm, String(characters)) with an explicit StringImpl::tryCreateUninitialized + copyElements + jsNontrivialString. On allocation failure it returns nullptr, which LiteralParser::parsePrimitiveValue converts into throwOutOfMemoryError and an empty JSValue. Four files touched: two headers for the rename, JSONAtomStringCacheInlines.h for the allocation change, and LiteralParser.cpp for the null check + throw.

Security risks

None introduced. The buffer returned by tryCreateUninitialized is exactly characters.size() elements and is filled by WTF::copyElements(buffer, characters) from a span of the same length, so there is no under/over-copy. tryCreateUninitialized already guards isValidLength<CharacterType>(length) before computing allocationSize, and stringOrIdentifierLength is a 31-bit unsigned, so no overflow concerns are added. The change strictly turns a process crash into a catchable RangeError.

Level of scrutiny

High — this is the string-value materialization step of JSON.parse, one of the hottest paths in the runtime. That said, the success path is byte-for-byte what String(std::span<const CharacterType>) already did (allocate uninitialized StringImpl of length, copyElements into it), only substituting StringImplMalloc::tryMalloc for the crashing malloc. The added branch is [[unlikely]] and only taken on OOM.

Other factors

  • Verified String(RefPtr<StringImpl>&&) exists (WTFString.h:84) and jsNontrivialString(VM&, String&&) calls releaseImpl().releaseNonNull() — safe because impl is non-null on this branch and length > 16.
  • Every parsePrimitiveValue caller already branches on !value and either has a ThrowScope with RETURN_IF_EXCEPTION upstream or asserts the exception/error-message invariant, so the new pending-exception-with-empty-return combination is handled without further changes.
  • WTF::move and WTF::copyElements are already used in the same function for the atom-cache branch, so no new includes or idioms.

Deferring per the guideline to not auto-approve changes on critical hot paths, not because of any concrete concern with the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants