From 810e55b50335daaaf6183e25d6d1657aac9abe33 Mon Sep 17 00:00:00 2001 From: wuggy Date: Mon, 7 Sep 2026 13:27:39 -0700 Subject: [PATCH 1/4] Refactor ConsoleAPIStorage event handling and add tests for ring buffer behavior --- dom/console/ConsoleAPIStorage.js | 38 +++++++++---- dom/console/moz.build | 1 + .../tests/test_console_storage_ring.js | 53 +++++++++++++++++++ dom/console/tests/xpcshell.ini | 5 ++ js/src/builtin/Map.js | 7 +-- .../tests/collections/Map-groupBy-lookup.js | 47 ++++++++++++++++ 6 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 dom/console/tests/test_console_storage_ring.js create mode 100644 dom/console/tests/xpcshell.ini create mode 100644 js/src/jit-test/tests/collections/Map-groupBy-lookup.js diff --git a/dom/console/ConsoleAPIStorage.js b/dom/console/ConsoleAPIStorage.js index 31be449e91..b17c6855ec 100644 --- a/dom/console/ConsoleAPIStorage.js +++ b/dom/console/ConsoleAPIStorage.js @@ -94,13 +94,24 @@ ConsoleAPIStorageService.prototype = { getEvents: function CS_getEvents(aId) { if (aId != null) { - return (_consoleStorage.get(aId) || []).slice(0); + let storage = _consoleStorage.get(aId); + if (!storage) { + return []; + } + let { events, next } = storage; + return next === 0 ? events.slice() : + events.slice(next).concat(events.slice(0, next)); } let result = []; - for (let [id, events] of _consoleStorage) { - result.push.apply(result, events); + for (let { events, next } of _consoleStorage.values()) { + for (let i = next; i < events.length; i++) { + result.push(events[i]); + } + for (let i = 0; i < next; i++) { + result.push(events[i]); + } } return result.sort(function(a, b) { @@ -122,16 +133,21 @@ ConsoleAPIStorageService.prototype = { */ recordEvent: function CS_recordEvent(aId, aOuterId, aEvent) { - if (!_consoleStorage.has(aId)) { - _consoleStorage.set(aId, []); + let storage = _consoleStorage.get(aId); + if (!storage) { + storage = { events: [], next: 0 }; + _consoleStorage.set(aId, storage); } - let storage = _consoleStorage.get(aId); - storage.push(aEvent); - - // truncate - if (storage.length > STORAGE_MAX_EVENTS) { - storage.shift(); + // Overwrite the oldest event once full, without moving the other entries. + // Advance before notifying observers, which may read or reenter storage. + if (storage.events.length < STORAGE_MAX_EVENTS) { + storage.events.push(aEvent); + } else { + storage.events[storage.next] = aEvent; + if (++storage.next === STORAGE_MAX_EVENTS) { + storage.next = 0; + } } Services.obs.notifyObservers(aEvent, "console-api-log-event", aOuterId); diff --git a/dom/console/moz.build b/dom/console/moz.build index d18b089626..ce2ae8d054 100644 --- a/dom/console/moz.build +++ b/dom/console/moz.build @@ -40,5 +40,6 @@ LOCAL_INCLUDES += [ MOCHITEST_MANIFESTS += [ 'tests/mochitest.ini' ] MOCHITEST_CHROME_MANIFESTS += [ 'tests/chrome.ini' ] +XPCSHELL_TESTS_MANIFESTS += [ 'tests/xpcshell.ini' ] FINAL_LIBRARY = 'xul' diff --git a/dom/console/tests/test_console_storage_ring.js b/dom/console/tests/test_console_storage_ring.js new file mode 100644 index 0000000000..a4e3dbeee8 --- /dev/null +++ b/dom/console/tests/test_console_storage_ring.js @@ -0,0 +1,53 @@ +function run_test() { + const storage = Components.classes["@mozilla.org/consoleAPI-storage;1"] + .getService(Components.interfaces.nsIConsoleAPIStorage); + Components.utils.import("resource://gre/modules/Services.jsm"); + storage.clearEvents(); + try { + for (let i = 0; i < 3501; i++) { + storage.recordEvent("ring-test", "outer", { timeStamp: i }); + if (i === 998 || i === 999 || i === 1000 || i === 1999 || i === 3500) { + let events = storage.getEvents("ring-test"); + equal(events.length, Math.min(i + 1, 1000)); + for (let j = 0; j < events.length; j++) { + equal(events[j].timeStamp, i + 1 - events.length + j); + } + events.length = 0; + equal(storage.getEvents("ring-test").length, Math.min(i + 1, 1000)); + } + } + storage.recordEvent("other", "outer", { timeStamp: 2500.5 }); + let all = storage.getEvents(); + equal(all.length, 1001); + equal(all[0].timeStamp, 2500.5); + equal(all[1].timeStamp, 2501); + equal(all[1000].timeStamp, 3500); + + let reentered = false; + let observer = { + observe(subject, topic, data) { + if (data === "ring-test" && !reentered) { + equal(storage.getEvents(data)[999].timeStamp, 3501); + reentered = true; + storage.recordEvent("ring-test", "outer", { timeStamp: 3502 }); + } + } + }; + Services.obs.addObserver(observer, "console-storage-cache-event", false); + try { + storage.recordEvent("ring-test", "outer", { timeStamp: 3501 }); + } finally { + Services.obs.removeObserver(observer, "console-storage-cache-event"); + } + equal(reentered, true); + equal(storage.getEvents("ring-test")[999].timeStamp, 3502); + storage.clearEvents("ring-test"); + equal(storage.getEvents("ring-test").length, 0); + equal(storage.getEvents("other").length, 1); + storage.recordEvent("ring-test", "outer", { timeStamp: 4000 }); + equal(storage.getEvents("ring-test")[0].timeStamp, 4000); + } finally { + storage.clearEvents(); + } + equal(storage.getEvents().length, 0); +} diff --git a/dom/console/tests/xpcshell.ini b/dom/console/tests/xpcshell.ini new file mode 100644 index 0000000000..7d3af0c96c --- /dev/null +++ b/dom/console/tests/xpcshell.ini @@ -0,0 +1,5 @@ +[DEFAULT] +head = +tail = + +[test_console_storage_ring.js] diff --git a/js/src/builtin/Map.js b/js/src/builtin/Map.js index bcf0ded02e..8c5afa9f5e 100644 --- a/js/src/builtin/Map.js +++ b/js/src/builtin/Map.js @@ -81,9 +81,10 @@ function MapGroupBy(items, callbackfn) { var key = callContentFunction(callbackfn, undefined, value, k); // Steps 6.c-d. - var elements; - if (callFunction(std_Map_has, groups, key)) { - elements = callFunction(std_Map_get, groups, key); + // Group values are always arrays, so undefined also tells us whether + // this is a new key without a second hash-table lookup. + var elements = callFunction(std_Map_get, groups, key); + if (elements !== undefined) { callFunction(std_Array_push, elements, value); } else { elements = [value]; diff --git a/js/src/jit-test/tests/collections/Map-groupBy-lookup.js b/js/src/jit-test/tests/collections/Map-groupBy-lookup.js new file mode 100644 index 0000000000..945bb28b36 --- /dev/null +++ b/js/src/jit-test/tests/collections/Map-groupBy-lookup.js @@ -0,0 +1,47 @@ +// Repeated keys must reuse their group, including SameValueZero keys. +var objectKey = {}; +var symbolKey = Symbol(); +var keys = [undefined, null, false, 0, -0, NaN, "key", objectKey, symbolKey]; +for (var iteration = 0; iteration < 100; iteration++) { + var input = []; + for (var repeat = 0; repeat < 4; repeat++) { + for (var key of keys) + input.push(key); + } + var calls = 0; + var groups = Map.groupBy(input, function(value, index) { + assertEq(index, calls++); + return value; + }); + assertEq(calls, input.length); + assertEq(groups.size, 8); + for (var key of keys) + assertEq(groups.get(key).length, key === 0 ? 8 : 4); + assertEq(groups.get(objectKey)[0], objectKey); + assertEq(groups.get(symbolKey)[0], symbolKey); + assertEq(groups.get(undefined)[0], undefined); + var order = Array.from(groups.keys()); + assertEq(order[0], undefined); + assertEq(order[3], 0); + assertEq(order[4], NaN); + assertEq(order[7], symbolKey); +} + +// A throwing callback must still close the input iterator. +var closed = false; +function* values() { + try { + yield 1; + yield 2; + } finally { + closed = true; + } +} +var sentinel = {}; +try { + Map.groupBy(values(), function() { throw sentinel; }); + throw new Error("callback did not throw"); +} catch (error) { + assertEq(error, sentinel); +} +assertEq(closed, true); From dfe77607020d7ac41bd976a0b376fd3f03bfb6a8 Mon Sep 17 00:00:00 2001 From: wuggy Date: Mon, 7 Sep 2026 13:38:33 -0700 Subject: [PATCH 2/4] Expand number-to-string cache to retain interleaved results --- js/src/devtools/number-to-string-bench.js | 61 +++++++++++++++++++++++ js/src/jsapi-tests/testIndexToString.cpp | 50 +++++++++++++++++++ js/src/jscompartment.cpp | 4 +- js/src/jscompartment.h | 46 +++++++++++------ 4 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 js/src/devtools/number-to-string-bench.js diff --git a/js/src/devtools/number-to-string-bench.js b/js/src/devtools/number-to-string-bench.js new file mode 100644 index 0000000000..957183d31d --- /dev/null +++ b/js/src/devtools/number-to-string-bench.js @@ -0,0 +1,61 @@ +// Run with: js number-to-string-bench.js +// Also supports: xpcshell -f number-to-string-bench.js +// Compare identical optimized builds, alternating baseline and patched runs. +// Reports milliseconds (lower is better); this is not a browser-suite score. + +(function() { + "use strict"; + var iterations = 500000; + var samples = 7; + var checksum = 0; + + function convert(values, radix, count) { + var total = 0; + for (var i = 0; i < count; i++) + total += values[i % values.length].toString(radix).length; + return total; + } + + function unique(count) { + var total = 0; + for (var i = 0; i < count; i++) + total += (123456.125 + i).toString().length; + return total; + } + + function measure(name, run) { + checksum += run(20000); + var times = []; + var expected = run(iterations); + for (var sample = 0; sample < samples; sample++) { + if (typeof gc === "function") + gc(); + var start = Date.now(); + var result = run(iterations); + times.push(Date.now() - start); + if (result !== expected) + throw new Error("inconsistent conversion: " + name); + checksum += result; + } + times.sort(function(a, b) { return a - b; }); + print(name + ": median=" + times[3] + " ms; samples=" + times.join(",")); + } + + for (var size of [1, 2, 4, 5, 16]) { + var values = []; + for (var i = 0; i < size; i++) + values.push(123456.125 + i); + measure("decimal working set " + size, function(count) { + return convert(values, 10, count); + }); + } + measure("unique decimals", unique); + var integers = [123456, 654321, 123457, 654322]; + measure("integer working set 4", function(count) { + return convert(integers, 10, count); + }); + measure("hexadecimal working set 4", function(count) { + return convert(integers, 16, count); + }); + print("checksum=" + checksum); +})(); diff --git a/js/src/jsapi-tests/testIndexToString.cpp b/js/src/jsapi-tests/testIndexToString.cpp index 0c91d98873..58c854054b 100644 --- a/js/src/jsapi-tests/testIndexToString.cpp +++ b/js/src/jsapi-tests/testIndexToString.cpp @@ -66,6 +66,56 @@ BEGIN_TEST(testIndexToString) } END_TEST(testIndexToString) +BEGIN_TEST(testDtoaCacheInterleaved) +{ + JS::RootedString first(cx, js::NumberToString(cx, 1234.5)); + CHECK(first); + JS::RootedString second(cx, js::NumberToString(cx, 6789.5)); + CHECK(second); + JS::RootedString third(cx, js::IndexToString(cx, 123456)); + CHECK(third); + JS::RootedString fourth(cx, js::IndexToString(cx, 654321)); + CHECK(fourth); + + for (size_t i = 0; i < 10; i++) { + CHECK(js::NumberToString(cx, 1234.5) == first); + CHECK(js::NumberToString(cx, 6789.5) == second); + CHECK(js::IndexToString(cx, 123456) == third); + CHECK(js::IndexToString(cx, 654321) == fourth); + } + + // Every raw string pointer must be invalidated, not just the latest one. + JS_GC(cx); + CHECK(!cx->compartment()->dtoaCache.lookup(10, 1234.5)); + CHECK(!cx->compartment()->dtoaCache.lookup(10, 6789.5)); + CHECK(!cx->compartment()->dtoaCache.lookup(10, 123456)); + CHECK(!cx->compartment()->dtoaCache.lookup(10, 654321)); + + // The radix is part of the key. Signed zero can share its string. + js::DtoaCache cache; + cache.cache(10, 0.0, &first->asFlat()); + cache.cache(16, 0.0, &second->asFlat()); + CHECK(cache.lookup(10, -0.0) == first); + CHECK(cache.lookup(16, -0.0) == second); + CHECK(!cache.lookup(2, 0.0)); + cache.purge(); + CHECK(!cache.lookup(10, 0.0)); + CHECK(!cache.lookup(16, 0.0)); + + for (size_t i = 0; i < 12; i++) + cache.cache(10, double(i), &first->asFlat()); + for (size_t i = 0; i < 8; i++) + CHECK(!cache.lookup(10, double(i))); + for (size_t i = 8; i < 12; i++) + CHECK(cache.lookup(10, double(i)) == first); + + // A failed string allocation must never turn into a cache hit. + cache.cache(10, 12.0, nullptr); + CHECK(!cache.lookup(10, 12.0)); + return true; +} +END_TEST(testDtoaCacheInterleaved) + BEGIN_TEST(testStringIsIndex) { for (size_t i = 0, sz = ArrayLength(tests); i < sz; i++) { diff --git a/js/src/jscompartment.cpp b/js/src/jscompartment.cpp index 926c7e3c86..6778dd2ebe 100644 --- a/js/src/jscompartment.cpp +++ b/js/src/jscompartment.cpp @@ -214,7 +214,9 @@ JSCompartment::ensureJitCompartmentExists(JSContext* cx) void js::DtoaCache::checkCacheAfterMovingGC() { - MOZ_ASSERT(!s || !IsForwarded(s)); + MOZ_ASSERT(!recent.s || !IsForwarded(recent.s)); + for (const auto& entry : previous) + MOZ_ASSERT(!entry.s || !IsForwarded(entry.s)); } namespace { diff --git a/js/src/jscompartment.h b/js/src/jscompartment.h index a02b39301d..dd356aa41a 100644 --- a/js/src/jscompartment.h +++ b/js/src/jscompartment.h @@ -38,29 +38,47 @@ class ScriptSourceObject; struct NativeIterator; /* - * A single-entry cache for some base-10 double-to-string conversions. This - * helps date-format-xparb.js. It also avoids skewing the results for - * v8-splay.js when measured by the SunSpider harness, where the splay tree - * initialization (which includes many repeated double-to-string conversions) - * is erroneously included in the measurement; see bug 562553. + * A small cache for number-to-string conversions, keyed by number and radix. + * Keep several results so interleaved conversions do not evict each other. + * These strings are not traced: every entry must be cleared before GC. */ class DtoaCache { - double d; - int base; - JSFlatString* s; // if s==nullptr, d and base are not valid + struct Entry { + double d; + int base; + JSFlatString* s; // if s == nullptr, d and base are not valid + }; + static const size_t NumPrevious = 3; + Entry recent; + Entry previous[NumPrevious]; + size_t next; public: - DtoaCache() : s(nullptr) {} - void purge() { s = nullptr; } + DtoaCache() { purge(); } + void purge() { + recent.s = nullptr; + for (auto& entry : previous) + entry.s = nullptr; + next = 0; + } JSFlatString* lookup(int base, double d) { - return this->s && base == this->base && d == this->d ? this->s : nullptr; + // Preserve the cheap path for consecutive conversions of one value. + if (recent.s && base == recent.base && d == recent.d) + return recent.s; + for (const auto& entry : previous) { + if (entry.s && base == entry.base && d == entry.d) + return entry.s; + } + return nullptr; } void cache(int base, double d, JSFlatString* s) { - this->base = base; - this->d = d; - this->s = s; + if (recent.s) { + previous[next] = recent; + next = (next + 1) % NumPrevious; + } + recent = { d, base, s }; } #ifdef JSGC_HASH_TABLE_CHECKS From 80cfaf08917c04df62904adfaccf111c56bb10d1 Mon Sep 17 00:00:00 2001 From: wuggy Date: Mon, 7 Sep 2026 14:12:03 -0700 Subject: [PATCH 3/4] Implement class selector fast path and add related tests for performance optimization --- dom/base/nsINode.cpp | 46 +++++++++++++++ dom/base/test/file_class_selector_checks.js | 59 +++++++++++++++++++ dom/base/test/mochitest.ini | 2 + .../test/test_class_selector_fast_path.html | 10 ++++ js/src/devtools/speedometer21-json-bench.js | 38 ++++++++++++ .../tests/basic/json-property-name-cache.js | 43 ++++++++++++++ js/src/vm/JSONParser.cpp | 25 ++++++++ js/src/vm/JSONParser.h | 12 +++- 8 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 dom/base/test/file_class_selector_checks.js create mode 100644 dom/base/test/test_class_selector_fast_path.html create mode 100644 js/src/devtools/speedometer21-json-bench.js create mode 100644 js/src/jit-test/tests/basic/json-property-name-cache.js diff --git a/dom/base/nsINode.cpp b/dom/base/nsINode.cpp index 5218938ca3..740c18417f 100644 --- a/dom/base/nsINode.cpp +++ b/dom/base/nsINode.cpp @@ -2927,6 +2927,35 @@ FindMatchingElementsWithId(const nsAString& aId, nsINode* aRoot, // Actually find elements matching aSelectorList (which must not be // null) and which are descendants of aRoot and put them in aList. If // onlyFirstMatch, then stop once the first one is found. +template +static void +FindMatchingElementsWithClass(nsINode* aRoot, nsIAtom* aClass, + nsCaseTreatment aCaseTreatment, T& aList) +{ + Collector results; + for (nsIContent* cur = aRoot->GetFirstChild(); cur; + cur = cur->GetNextNode(aRoot)) { + if (!cur->IsElement()) { + continue; + } + const nsAttrValue* classes = cur->AsElement()->GetClasses(); + if (classes && classes->Contains(aClass, aCaseTreatment)) { + if (onlyFirstMatch) { + aList.AppendElement(cur->AsElement()); + return; + } + results.AppendElement(cur->AsElement()); + } + } + const uint32_t len = results.Length(); + if (len) { + aList.SetCapacity(len); + for (uint32_t i = 0; i < len; ++i) { + aList.AppendElement(results.ElementAt(i)); + } + } +} + template MOZ_ALWAYS_INLINE static void FindMatchingElements(nsINode* aRoot, nsCSSSelectorList* aSelectorList, T &aList, @@ -2934,6 +2963,23 @@ FindMatchingElements(nsINode* aRoot, nsCSSSelectorList* aSelectorList, T &aList, { nsIDocument* doc = aRoot->OwnerDoc(); + // Parsed selectors are already cached by the document. A lone class needs + // only an atom lookup per element, not the general CSS matching context. + nsCSSSelector* selector = aSelectorList->mSelectors; + if (!aSelectorList->mNext && !selector->mNext && + selector->mClassList && !selector->mClassList->mNext && + !selector->mLowercaseTag && !selector->mIDList && + !selector->mAttrList && !selector->mPseudoClassList && + !selector->mNegations && selector->mNameSpace == kNameSpaceID_Unknown && + selector->IsRestrictedSelector() && !selector->IsHybridPseudoElement()) { + nsCaseTreatment caseTreatment = + doc->GetCompatibilityMode() == eCompatibility_NavQuirks + ? eIgnoreCase : eCaseMatters; + FindMatchingElementsWithClass( + aRoot, selector->mClassList->mAtom, caseTreatment, aList); + return; + } + TreeMatchContext matchingContext(false, nsRuleWalker::eRelevantLinkUnvisited, doc, TreeMatchContext::eNeverMatchVisited); doc->FlushPendingLinkUpdates(); diff --git a/dom/base/test/file_class_selector_checks.js b/dom/base/test/file_class_selector_checks.js new file mode 100644 index 0000000000..0be76bc6ef --- /dev/null +++ b/dom/base/test/file_class_selector_checks.js @@ -0,0 +1,59 @@ +function checkClassSelectors(parse, equal) { + function check(root, selector, expected) { + var all = root.querySelectorAll(selector); + equal(all.length, expected.length, selector + " count"); + for (var i = 0; i < expected.length; i++) { + equal(all[i].id, expected[i], selector + " order " + i); + } + equal(root.querySelector(selector), all.length ? all[0] : null, + selector + " first match"); + } + + var doc = parse('' + + '
' + + '
' + + '
' + + '' + + '
', 'text/html'); + var root = doc.getElementById('root'); + for (var repeat = 0; repeat < 10; repeat++) { + check(root, '.toggle', ['a', 'b', 'd', 'svg']); + check(root, '*|*.toggle', ['a', 'b', 'd', 'svg']); + check(root, '.t\\6f ggle', ['a', 'b', 'd', 'svg']); + check(root, '.TOGGLE', ['c']); + check(root, '.missing', []); + check(root, '.toggle.completed', ['a']); + check(root, 'button.toggle', ['d']); + check(root, '.toggle:not(.completed)', ['b', 'd', 'svg']); + check(root, '.toggle, .other', ['a', 'b', 'd', 'svg']); + } + check(doc, '.toggle', ['root', 'a', 'b', 'd', 'svg']); + + var snapshot = root.querySelectorAll('.toggle'); + doc.getElementById('a').className = ''; + root.removeChild(doc.getElementById('d')); + doc.getElementById('c').className = 'toggle'; + check(root, '.toggle', ['b', 'c', 'svg']); + equal(snapshot.length, 4, 'querySelectorAll remains a static snapshot'); + equal(snapshot[0].id, 'a', 'snapshot retains a changed element'); + equal(snapshot[2].id, 'd', 'snapshot retains a removed element'); + + var fragment = doc.createDocumentFragment(); + fragment.appendChild(root); + check(fragment, '.toggle', ['root', 'b', 'c', 'svg']); + check(root, '.toggle', ['b', 'c', 'svg']); + + var quirks = parse('
' + + '
', 'text/html'); + equal(quirks.compatMode, 'BackCompat', 'quirks document'); + check(quirks, '.toggle', ['lower', 'upper']); + check(quirks, '.TOGGLE', ['lower', 'upper']); + + var xml = parse('' + + '' + + '', + 'application/xml'); + check(xml, '.toggle', ['plain', 'namespaced']); + check(xml, '|*.toggle', ['plain']); + check(xml, '*|*.toggle', ['plain', 'namespaced']); +} diff --git a/dom/base/test/mochitest.ini b/dom/base/test/mochitest.ini index 6f5e0ea835..f903dc5a3d 100644 --- a/dom/base/test/mochitest.ini +++ b/dom/base/test/mochitest.ini @@ -1,5 +1,6 @@ [DEFAULT] support-files = + file_class_selector_checks.js audio.ogg audioEndedDuringPlaying.webm iframe_bug962251.html @@ -615,6 +616,7 @@ skip-if = os == "mac" # Different tab focus behavior on mac [test_caretPositionFromPoint.html] [test_change_policy.html] [test_classList.html] +[test_class_selector_fast_path.html] [test_clearTimeoutIntervalNoArg.html] [test_constructor-assignment.html] [test_constructor.html] diff --git a/dom/base/test/test_class_selector_fast_path.html b/dom/base/test/test_class_selector_fast_path.html new file mode 100644 index 0000000000..3eb87d2cf2 --- /dev/null +++ b/dom/base/test/test_class_selector_fast_path.html @@ -0,0 +1,10 @@ + + +Class selector query fast path + + + diff --git a/js/src/devtools/speedometer21-json-bench.js b/js/src/devtools/speedometer21-json-bench.js new file mode 100644 index 0000000000..264d7d713b --- /dev/null +++ b/js/src/devtools/speedometer21-json-bench.js @@ -0,0 +1,38 @@ +// Focused JSON workloads motivated by Speedometer 2.1's in-memory TodoMVC store. +// Run with a JS shell, or xpcshell -f. Lower times are better. This does not +// measure Speedometer's DOM, layout, event dispatch, or overall suite score. +(function() { + var iterations = 2000; + var checksum = 0; + function measure(name, records) { + var text = JSON.stringify(records); + var samples = []; + for (var i = 0; i < 100; i++) + checksum += JSON.parse(text).length; + for (var sample = 0; sample < 7; sample++) { + if (typeof gc === 'function') + gc(); + var start = Date.now(); + for (var i = 0; i < iterations; i++) + checksum += JSON.parse(text).length; + samples.push(Date.now() - start); + } + samples.sort(function(a, b) { return a - b; }); + print(name + ': median=' + samples[3] + ' ms; samples=' + samples.join(',')); + } + var todos = [], unicode = [], collisions = [], unique = []; + for (var i = 0; i < 100; i++) { + todos.push({ id: i, title: 'Something to do ' + i, completed: false }); + unicode.push({ '\u0101name': i, '\u03bbvalue': 'value', '\u4e2d': false }); + collisions.push({ item: i, identifier: i + 1, index: i + 2 }); + var record = {}; + record['uniqueName' + i] = i; + unique.push(record); + } + measure('TodoMVC-shaped records', todos); + measure('two-byte names', unicode); + measure('cache collisions', collisions); + measure('unique names', unique); + measure('small parse', [todos[0]]); + print('checksum=' + checksum); +})(); diff --git a/js/src/jit-test/tests/basic/json-property-name-cache.js b/js/src/jit-test/tests/basic/json-property-name-cache.js new file mode 100644 index 0000000000..92440c8952 --- /dev/null +++ b/js/src/jit-test/tests/basic/json-property-name-cache.js @@ -0,0 +1,43 @@ +// Repeated names, collisions, encodings, and escaped names must all agree. +var keys = ['id', 'title', 'completed', 'items', 'item', '', 'identifier', + 'i', 'a', 'same', 'samesize', '\u00e9', '\u0101', '\ud800', + 'quote"key', 'slash\\key', 'line\nkey', '__proto__']; +var records = []; +for (var i = 0; i < 100; i++) { + var record = Object.create(null); + for (var k = 0; k < keys.length; k++) + record[keys[k]] = i + k; + records.push(record); +} +var text = JSON.stringify(records); +for (var repeat = 0; repeat < 30; repeat++) { + var parsed = JSON.parse(text); + assertEq(parsed.length, records.length); + for (var i = 0; i < parsed.length; i++) { + for (var k = 0; k < keys.length; k++) + assertEq(parsed[i][keys[k]], records[i][keys[k]]); + } +} +assertEq(JSON.parse('[{"title":1},{"titleLonger":2}]')[1].titleLonger, 2); +assertEq(JSON.parse('[{"titleLonger":1},{"title":2}]')[1].title, 2); +assertEq(JSON.parse('[{"title":1},{"t\\u0069tle":2}]')[1].title, 2); +assertEq(JSON.parse('{"id":1,"id":2}').id, 2); +assertEq(JSON.parse('{"id":1}', function(key, value) { + return key === 'id' ? JSON.parse('{"id":2}').id : value; +}).id, 2); + +for (var bad of ['[{"title":1},{"title', '[{"title":1},{"title"', + '[{"title":1},{"titleX":}]', '[{"title":1},{"ti\ntle":2}]']) { + var threw = false; + try { JSON.parse(bad); } catch (e) { threw = e instanceof SyntaxError; } + assertEq(threw, true); +} + +// Keep cached atoms alive across allocations made while parsing later values. +var large = '[{"uncommonPropertyForGC":0},' + + '{"other":[' + new Array(20000).fill('"allocation"').join(',') + ']},' + + '{"uncommonPropertyForGC":1}]'; +for (var i = 0; i < 3; i++) { + gc(); + assertEq(JSON.parse(large)[2].uncommonPropertyForGC, 1); +} diff --git a/js/src/vm/JSONParser.cpp b/js/src/vm/JSONParser.cpp index 680a34fef0..2a70f9f771 100644 --- a/js/src/vm/JSONParser.cpp +++ b/js/src/vm/JSONParser.cpp @@ -43,6 +43,10 @@ JSONParserBase::~JSONParserBase() void JSONParserBase::trace(JSTracer* trc) { + for (auto& atom : propertyNameCache) { + if (atom) + TraceRoot(trc, &atom, "JSONParser cached property name"); + } for (size_t i = 0; i < stack.length(); i++) { if (stack[i].state == FinishArrayElement) { ElementVector& elements = stack[i].elements(); @@ -123,6 +127,24 @@ JSONParser::readString() return token(Error); } + // Arrays of records repeatedly use the same property names. Verify the + // entire name and closing quote before skipping scanning and atomization. + // Only the unescaped path below populates this cache. + if (ST == JSONParser::PropertyName && *current != '"') { + JSAtom* atom = propertyNameCache[size_t(*current) % PropertyNameCacheSize]; + if (atom && size_t(end - current) > atom->length() && + current[atom->length()] == '"') { + JS::AutoCheckCannotGC nogc; + bool matches = atom->hasLatin1Chars() + ? EqualChars(atom->latin1Chars(nogc), current.get(), atom->length()) + : EqualChars(atom->twoByteChars(nogc), current.get(), atom->length()); + if (matches) { + current += atom->length() + 1; + return stringToken(atom); + } + } + } + /* * Optimization: if the source contains no escaped characters, create the * string directly from the source text. @@ -137,6 +159,9 @@ JSONParser::readString() : NewStringCopyN(cx, start.get(), length); if (!str) return token(OOM); + if (ST == JSONParser::PropertyName && length) { + propertyNameCache[size_t(*start) % PropertyNameCacheSize] = &str->asAtom(); + } return stringToken(str); } diff --git a/js/src/vm/JSONParser.h b/js/src/vm/JSONParser.h index 70ed86f58e..9c3a979c69 100644 --- a/js/src/vm/JSONParser.h +++ b/js/src/vm/JSONParser.h @@ -33,6 +33,11 @@ class MOZ_STACK_CLASS JSONParserBase const ErrorHandling errorHandling; + // Reuse unescaped property names within this parse. The cache is bounded + // and traced with the parser, including during compacting GC. + static const size_t PropertyNameCacheSize = 8; + JSAtom* propertyNameCache[PropertyNameCacheSize]; + enum Token { String, Number, True, False, Null, ArrayOpen, ArrayClose, ObjectOpen, ObjectClose, @@ -109,6 +114,7 @@ class MOZ_STACK_CLASS JSONParserBase JSONParserBase(JSContext* cx, ErrorHandling errorHandling) : cx(cx), errorHandling(errorHandling), + propertyNameCache{}, stack(cx), freeElements(cx), freeProperties(cx) @@ -129,8 +135,10 @@ class MOZ_STACK_CLASS JSONParserBase #ifdef DEBUG , lastToken(mozilla::Move(other.lastToken)) #endif - {} - + { + for (size_t i = 0; i < PropertyNameCacheSize; i++) + propertyNameCache[i] = other.propertyNameCache[i]; + } Value numberValue() const { MOZ_ASSERT(lastToken == Number); From 14b52e7fc8297ffb50d4a8fbb51c490edc39cc19 Mon Sep 17 00:00:00 2001 From: wuggy Date: Mon, 7 Sep 2026 14:19:00 -0700 Subject: [PATCH 4/4] Make readme link to Artemis --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c69459f92..bce6376429 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ Dactyloidae web browser
-Official Discord server +Official Discord server