Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions JSTests/stress/out-of-memory-when-script-sized-vector-cannot-grow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//@ slow!
//@ if $buildType == "debug" then runDefault("--maxSingleAllocationSize=1048576") else skip end
Comment thread
claude[bot] marked this conversation as resolved.

// Each builtin below keeps one entry per script-controlled item in a WTF::Vector. When that Vector
// cannot grow, because the allocation fails or because a Vector holds at most 2^31 bytes, the
// builtin has to throw the out-of-memory RangeError. It used to crash.
// --maxSingleAllocationSize makes every allocation of more than 1 MiB fail, so a few hundred thousand
// items are enough here. No string below is longer than 1 MiB, because creating one would crash too.

function shouldBe(actual, expected) {
if (actual !== expected)
throw new Error(`bad value: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
}

function shouldThrowOutOfMemory(func) {
let error = null;
try {
func();
} catch (e) {
error = e;
}
if (String(error) !== "RangeError: Out of memory")
throw new Error(`bad error: ${String(error)} from ${func}`);
}

// Never ends on its own: the only way out of the consumer is the throw, which has to close it.
function endless(value) {
const iterable = {
closed: false,
[Symbol.iterator]() {
return {
next() { return { done: false, value }; },
return() { iterable.closed = true; return { }; },
};
},
};
return iterable;
}

// String.prototype.replaceAll(string, string): the offset of every match.
shouldThrowOutOfMemory(() => "a".repeat(200000).replaceAll("a", "c"));
shouldThrowOutOfMemory(() => "a".repeat(200000).replaceAll("", "c"));
shouldBe("banana".replaceAll("a", "o"), "bonono");

// String.prototype.replace(regexp, string): one part per "$" reference of the replacement.
shouldThrowOutOfMemory(() => "x".replace(/(x)/g, "$1".repeat(100000)));
shouldThrowOutOfMemory(() => "x".replace(/(x)/g, "$$".repeat(100000)));
shouldThrowOutOfMemory(() => "x".replace(/(?<name>x)/g, "-$<name>".repeat(50000)));
shouldBe("x".replace(/(x)/g, "[$1$$$&]"), "[x$x]");

// String.prototype.split(string): the end of every piece.
shouldThrowOutOfMemory(() => ",".repeat(400000).split(","));
shouldThrowOutOfMemory(() => "\u3042".repeat(400000).split("\u3042"));
shouldThrowOutOfMemory(() => ",;".repeat(400000).split(",;"));
shouldBe(JSON.stringify("a,b,,c".split(",")), `["a","b","","c"]`);

// JSON.parse with a reviver: the source range of every array element.
shouldThrowOutOfMemory(() => JSON.parse("[" + "1,".repeat(50000) + "1]", (key, value) => value));
shouldBe(JSON.stringify(JSON.parse("[1,[2,3]]", (key, value) => value)), "[1,[2,3]]");

// FinalizationRegistry.prototype.register: every registration, in one list per unregister token.
{
const registry = new FinalizationRegistry(() => { });
const target = { };
const token = { };
shouldThrowOutOfMemory(() => {
for (;;)
registry.register(target, 1, token);
});
shouldBe(registry.unregister(token), true);
shouldBe(registry.unregister(token), false);

shouldThrowOutOfMemory(() => {
for (;;)
registry.register(target, 1);
});
// The list of the registrations without a token is full now. When a token dies before its target, the
// end of the collection moves the registration to that list. Nothing can throw there, so it is dropped.
(function () {
for (let i = 0; i < 100; ++i)
registry.register(target, 2, { });
})();
fullGC();
registry.register(target, 3, token);
shouldBe(registry.unregister(token), true);
}

// The end of a collection also moves the held values of the registrations whose target died to a second
// list. What does not fit there is dropped too. One list of registrations stops below the size that this
// second list stops at, so the registrations are spread over many tokens, each with its own target. The
// second list overflows even when the last few of them are still reachable at the collection.
{
const registry = new FinalizationRegistry(() => { });
(function () {
for (let t = 0; t < 30; ++t) {
const target = { };
const token = { };
for (let i = 0; i < 5000; ++i)
registry.register(target, 1, token);
}
})();
fullGC();
const target = { };
const token = { };
registry.register(target, 1, token);
shouldBe(registry.unregister(token), true);
}

// While a token is alive, the held values move to a list of that token, so that unregister() still finds
// them. No cleanup callback runs before this script ends, so that list fills up over several collections.
{
const registry = new FinalizationRegistry(() => { });
const token = { };
for (let round = 0; round < 7; ++round) {
(function () {
for (let t = 0; t < 6; ++t) {
const target = { };
for (let i = 0; i < 5000; ++i)
registry.register(target, 1, token);
}
})();
fullGC();
}
shouldBe(registry.unregister(token), true);
shouldBe(registry.unregister(token), false);
}

// Intl.ListFormat: every string of the iterable.
{
const listFormat = new Intl.ListFormat("en");
for (const format of [items => listFormat.format(items), items => listFormat.formatToParts(items)]) {
const items = endless("a");
shouldThrowOutOfMemory(() => format(items));
shouldBe(items.closed, true);
}
shouldBe(listFormat.format(["a", "b", "c"]), "a, b, and c");
}

// WebAssembly.Tag: every parameter type. The compile options: every name of the builtins list.
if (typeof WebAssembly === "object") {
const parameters = endless("i32");
shouldThrowOutOfMemory(() => new WebAssembly.Tag({ parameters }));
shouldBe(parameters.closed, true);

const emptyModule = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]);
const builtins = endless("js-string");
shouldThrowOutOfMemory(() => WebAssembly.validate(emptyModule, { builtins }));
shouldBe(builtins.closed, true);
shouldBe(WebAssembly.validate(emptyModule, { builtins: ["js-string"] }), true);
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ JSC_DEFINE_HOST_FUNCTION(protoFuncFinalizationRegistryRegister, (JSGlobalObject*
if (!unregisterToken.isUndefined() && !canBeHeldWeakly(unregisterToken)) [[unlikely]]
return throwVMTypeError(globalObject, scope, "register requires an object or a non-registered symbol as the unregistration token"_s);

group->registerTarget(vm, target.asCell(), holdings, unregisterToken);
if (!group->registerTarget(vm, target.asCell(), holdings, unregisterToken)) [[unlikely]] {
throwOutOfMemoryError(globalObject, scope);
return { };
}
return encodedJSUndefined();
}

Expand Down
3 changes: 2 additions & 1 deletion Source/JavaScriptCore/runtime/IntlListFormat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ static Vector<String, 4> stringListFromIterable(JSGlobalObject* globalObject, JS
}
String item = value.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, void());
result.append(item);
if (!result.tryAppend(WTF::move(item))) [[unlikely]]
throwOutOfMemoryError(globalObject, scope);
Comment thread
robobun marked this conversation as resolved.
});
return result;
}
Expand Down
42 changes: 27 additions & 15 deletions Source/JavaScriptCore/runtime/JSFinalizationRegistry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,13 @@ void JSFinalizationRegistry::reconcileWeakReferencesAtGCEnd(VM& vm, CollectionSc
RELEASE_ASSERT(iter.value.size());
#endif

// Nothing can throw at the end of a collection, and registerTarget() can leave a list full. What does not fit
// in its list is dropped: that cleanup callback never runs, which the specification allows.
bool readiedCell = false;
m_noUnregistrationLive.removeAllMatching([&] (const Registration& reg) {
ASSERT(!reg.holdings.get().isCell() || vm.heap.isMarked(reg.holdings.get().asCell()));
if (!vm.heap.isMarked(reg.target)) {
m_noUnregistrationDead.append(reg.holdings);
readiedCell = true;
readiedCell |= m_noUnregistrationDead.tryAppend(reg.holdings);
return true;
}
return false;
Expand All @@ -122,25 +123,29 @@ void JSFinalizationRegistry::reconcileWeakReferencesAtGCEnd(VM& vm, CollectionSc

bool keyIsDead = !vm.heap.isMarked(bucket.key);
DeadRegistrations* deadList = nullptr;
auto getDeadList = [&] () -> DeadRegistrations& {
auto tryAppendToDeadList = [&] (const WriteBarrier<Unknown>& holdings) -> bool {
if (!deadList) [[unlikely]]
deadList = &m_deadRegistrations.add(bucket.key, DeadRegistrations()).iterator->value;
return *deadList;
if (deadList->tryAppend(holdings)) [[likely]]
return true;
// takeDeadHoldingsValue() expects every bucket to hold a value.
if (deadList->isEmpty()) {
m_deadRegistrations.remove(bucket.key);
deadList = nullptr;
}
return false;
};

bucket.value.removeAllMatching([&] (const Registration& reg) {
ASSERT(!reg.holdings.get().isCell() || vm.heap.isMarked(reg.holdings.get().asCell()));
if (!vm.heap.isMarked(reg.target)) {
if (keyIsDead)
m_noUnregistrationDead.append(reg.holdings);
else
getDeadList().append(reg.holdings);
readiedCell = true;
readiedCell |= keyIsDead ? m_noUnregistrationDead.tryAppend(reg.holdings) : tryAppendToDeadList(reg.holdings);
return true;
}

if (keyIsDead) {
m_noUnregistrationLive.append(reg);
bool appended = m_noUnregistrationLive.tryAppend(reg);
Comment thread
robobun marked this conversation as resolved.
UNUSED_VARIABLE(appended);
return true;
}

Expand Down Expand Up @@ -204,20 +209,27 @@ JSValue JSFinalizationRegistry::takeDeadHoldingsValue()
return result;
}

void JSFinalizationRegistry::registerTarget(VM& vm, JSCell* target, JSValue holdings, JSValue token)
bool JSFinalizationRegistry::registerTarget(VM& vm, JSCell* target, JSValue holdings, JSValue token)
{
Locker locker { cellLock() };
Registration registration;
registration.target = target;
registration.holdings.setWithoutWriteBarrier(holdings);
if (token.isUndefined())
m_noUnregistrationLive.append(WTF::move(registration));
else {
if (token.isUndefined()) {
if (!m_noUnregistrationLive.tryAppend(WTF::move(registration))) [[unlikely]]
return false;
} else {
RELEASE_ASSERT(token.isCell());
auto result = m_liveRegistrations.add(token.asCell(), LiveRegistrations());
result.iterator->value.append(WTF::move(registration));
if (!result.iterator->value.tryAppend(WTF::move(registration))) [[unlikely]] {
Comment thread
robobun marked this conversation as resolved.
// reconcileWeakReferencesAtGCEnd() expects every bucket to hold a registration.
if (result.isNewEntry)
m_liveRegistrations.remove(result.iterator);
return false;
}
}
vm.writeBarrier(this);
return true;
}

bool JSFinalizationRegistry::unregister(VM&, JSCell* token)
Expand Down
3 changes: 2 additions & 1 deletion Source/JavaScriptCore/runtime/JSFinalizationRegistry.h
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ class JSFinalizationRegistry final : public JSInternalFieldObjectImpl<1> {

bool unregister(VM&, JSCell* token);
// token should be a JSObject, Symbol, or undefined.
void registerTarget(VM&, JSCell* target, JSValue holdings, JSValue token);
// Returns false, and registers nothing, when the list of registrations cannot grow.
[[nodiscard]] bool registerTarget(VM&, JSCell* target, JSValue holdings, JSValue token);

struct LiveRegistration {
JSCell* target;
Expand Down
6 changes: 4 additions & 2 deletions Source/JavaScriptCore/runtime/LiteralParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1705,8 +1705,10 @@ JSValue LiteralParser<CharType, reviverMode>::parse(VM& vm, ParserState initialS
array->putDirectIndex(m_globalObject, array->length(), lastValue);
RETURN_IF_EXCEPTION(scope, { });
if constexpr (reviverMode == JSONReviverMode::Enabled) {
if (sourceRanges)
std::get<JSONRanges::Array>(m_rangesStack.last().properties).append(WTF::move(lastValueRange));
if (sourceRanges && !std::get<JSONRanges::Array>(m_rangesStack.last().properties).tryAppend(WTF::move(lastValueRange))) [[unlikely]] {
throwOutOfMemoryError(m_globalObject, scope);
return { };
}
}

if (m_lexer.currentToken()->type == TokComma)
Expand Down
42 changes: 28 additions & 14 deletions Source/JavaScriptCore/runtime/StringPrototype.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1019,9 +1019,14 @@ JSC_DEFINE_HOST_FUNCTION(stringProtoFuncSlice, (JSGlobalObject* globalObject, Ca
RELEASE_AND_RETURN(scope, JSValue::encode(stringSlice<double>(globalObject, vm, string, length, start, end)));
}

// Return true in case of early return (resultLength got to limitLength).
enum class SplitStatus : uint8_t {
ReachedEnd,
ReachedLimit, // resultLength got to limitLength.
OutOfMemory, // result cannot grow.
};

template<typename CharacterType, typename Indice>
static ALWAYS_INLINE bool splitStringByOneCharacterImpl(Indice& result, StringImpl* string, char16_t separatorCharacter, unsigned limitLength)
static ALWAYS_INLINE SplitStatus splitStringByOneCharacterImpl(Indice& result, StringImpl* string, char16_t separatorCharacter, unsigned limitLength)
{
// 12. Let q = p.
size_t matchPosition;
Expand All @@ -1035,17 +1040,18 @@ static ALWAYS_INLINE bool splitStringByOneCharacterImpl(Indice& result, StringIm
// through q (exclusive).
// 2. Call the [[DefineOwnProperty]] internal method of A with arguments ToString(lengthA),
// Property Descriptor {[[Value]]: T, [[Writable]]: true, [[Enumerable]]: true, [[Configurable]]: true}, and false.
result.append(matchPosition);
if (!result.tryAppend(matchPosition)) [[unlikely]]
return SplitStatus::OutOfMemory;
// 3. Increment lengthA by 1.
// 4. If lengthA == lim, return A.
if (result.size() == limitLength)
return true;
return SplitStatus::ReachedLimit;

// 5. Let p = e.
// 8. Let q = p.
position = matchPosition + 1;
}
return false;
return SplitStatus::ReachedEnd;
}

JSCell* stringSplitFast(JSGlobalObject* globalObject, JSString* thisString, JSString* separatorString, unsigned limit)
Expand Down Expand Up @@ -1075,6 +1081,12 @@ JSCell* stringSplitFast(JSGlobalObject* globalObject, JSString* thisString, JSSt

auto& result = vm.stringSplitIndice;
result.shrink(0);
// result keeps its capacity for the next call. A buffer that could not grow is not worth keeping.
auto throwOutOfMemory = [&]() -> JSCell* {
result.clear();
throwOutOfMemoryError(globalObject, scope);
return nullptr;
};
constexpr unsigned atomStringsArrayLimit = 100;
const bool subjectIsAtom = input->impl()->isAtom();

Expand Down Expand Up @@ -1207,13 +1219,13 @@ JSCell* stringSplitFast(JSGlobalObject* globalObject, JSString* thisString, JSSt

if (separatorLength == 1) {
char16_t separatorCharacter = separatorImpl->at(0);
if (stringImpl->is8Bit()) {
if (splitStringByOneCharacterImpl<Latin1Character>(result, stringImpl, separatorCharacter, limit))
RELEASE_AND_RETURN(scope, cacheAndCreateArray());
} else {
if (splitStringByOneCharacterImpl<char16_t>(result, stringImpl, separatorCharacter, limit))
RELEASE_AND_RETURN(scope, cacheAndCreateArray());
}
SplitStatus status = stringImpl->is8Bit()
? splitStringByOneCharacterImpl<Latin1Character>(result, stringImpl, separatorCharacter, limit)
: splitStringByOneCharacterImpl<char16_t>(result, stringImpl, separatorCharacter, limit);
if (status == SplitStatus::OutOfMemory) [[unlikely]]
return throwOutOfMemory();
if (status == SplitStatus::ReachedLimit)
RELEASE_AND_RETURN(scope, cacheAndCreateArray());
} else {
// 13. Let q = p.
size_t matchPosition;
Expand All @@ -1226,7 +1238,8 @@ JSCell* stringSplitFast(JSGlobalObject* globalObject, JSString* thisString, JSSt
// 1. Let T be a String value equal to the substring of S consisting of the characters at positions p (inclusive)
// through q (exclusive).
// 2. Call CreateDataProperty(A, ToString(lengthA), T).
result.append(matchPosition);
if (!result.tryAppend(matchPosition)) [[unlikely]]
return throwOutOfMemory();
// 3. Increment lengthA by 1.
// 4. If lengthA == lim, return A.
if (result.size() == limit)
Expand All @@ -1241,7 +1254,8 @@ JSCell* stringSplitFast(JSGlobalObject* globalObject, JSString* thisString, JSSt
// 15. Let T be a String value equal to the substring of S consisting of the characters at positions p (inclusive)
// through s (exclusive).
// 16. Call CreateDataProperty(A, ToString(lengthA), T).
result.append(input->length());
if (!result.tryAppend(input->length())) [[unlikely]]
return throwOutOfMemory();
RELEASE_AND_RETURN(scope, cacheAndCreateArray());
}

Expand Down
Loading
Loading