From c82a41f81e2896d965eba154cedb32d8f2911ca4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:39:12 +0000 Subject: [PATCH 1/2] Bump WebKit: a transition from a dictionary structure takes its property table instead of copying it A delete, an attribute change, a prototype change, seal, freeze or a private brand on an object with more than 128 named properties copied the object's property table. The loop of #44583 (delete and re-add one key of a 1000 key object) ran about 100 times slower than node. WEBKIT_VERSION points at the preview build of oven-sh/WebKit#803. --- scripts/build/deps/webkit.ts | 2 +- .../object-delete-property-dictionary.test.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/jsc/object-delete-property-dictionary.test.ts diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 00f04ad65913..eb66d985f28f 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "87ee10da087b63e011bd1ada64d1814a850a4395"; +export const WEBKIT_VERSION = "autobuild-preview-pr-803-cf96a639"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/jsc/object-delete-property-dictionary.test.ts b/test/js/bun/jsc/object-delete-property-dictionary.test.ts new file mode 100644 index 000000000000..074d1e0ca560 --- /dev/null +++ b/test/js/bun/jsc/object-delete-property-dictionary.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// An object with more than 128 named properties gets a dictionary structure in JavaScriptCore, and a +// delete, an attribute change or a prototype change moves the object to a new structure. That transition +// used to copy the dictionary's property table: O(n) time and a table of n entries allocated per delete, +// which made the delete and re-add loop of issue #44583 about 100x slower than node. The transition now +// takes the table. `heapStats().objectTypeCounts` counts the cells in the heap, the unswept ones included, +// so the count of PropertyTable cells says how many tables a loop allocated. A loop that only adds or only +// changes attributes allocates 8 to 12 tables per 100 iterations on any build (the table grows, and the +// inline caches flatten the dictionary once). Before the fix each loop below allocated one more per iteration. +const tableBound = 30; +async function propertyTablesAllocatedBy( + loopBody: string, + { object = 'const object = {}; for (let i = 0; i < 1000; i++) object["key" + i] = i;', env = bunEnv } = {}, +): Promise<{ propertyTables: number; keys: number; value: unknown }> { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { heapStats, fullGC } = require("bun:jsc"); + ${object} + const body = (object, i) => { ${loopBody} }; + for (let i = 0; i < 8; i++) body(object, i); + fullGC(); + const before = heapStats().objectTypeCounts.PropertyTable ?? 0; + for (let i = 0; i < 100; i++) body(object, i); + const after = heapStats().objectTypeCounts.PropertyTable ?? 0; + console.log(JSON.stringify({ propertyTables: after - before, keys: Object.keys(object).length, value: object.key0 })); + `, + ], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout); +} + +test("deleting and re-adding a property on a large object does not copy its property table", async () => { + const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = i;"); + // Before the fix: 109 for 100 iterations. + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1000, value: 99 }); +}); + +test("changing the attributes of a property on a large object does not copy its property table", async () => { + // An add pins the table in place. The attribute change after it used to copy the table. + const result = await propertyTablesAllocatedBy( + 'object["added" + i] = i; Object.defineProperty(object, "key0", { value: i, writable: i % 2 === 0, enumerable: true, configurable: true });', + ); + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1100, value: 99 }); +}); + +test("changing the prototype of a large object does not copy its property table", async () => { + // A prototype change pins the table in the new structure. It took a copy of the dictionary's table. + const result = await propertyTablesAllocatedBy("Object.setPrototypeOf(object, prototypes[i & 1]);", { + object: + 'const object = {}; for (let i = 0; i < 1000; i++) object["key" + i] = i; const prototypes = [{ a: 1 }, { b: 2 }];', + }); + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1000, value: 0 }); +}); + +test("deleting and re-adding a variable in a large process.env does not copy its property table", async () => { + // process.env is the same kind of object once the environment has more than 128 variables. + const env = { ...bunEnv }; + for (let i = 0; i < 200; i++) env[`ROBOBUN_VAR_${i}`] = String(i); + const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = String(i);", { + object: "const object = process.env;", + env, + }); + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ value: "99" }); + expect(result.keys).toBeGreaterThan(200); +}); From 040e23085358ed1a76e9ff635281a653df3f4778 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:21:18 +0000 Subject: [PATCH 2/2] test: run the dictionary cases concurrently, check the final attributes and prototype, own bound for process.env --- .../object-delete-property-dictionary.test.ts | 80 +++++++++++-------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/test/js/bun/jsc/object-delete-property-dictionary.test.ts b/test/js/bun/jsc/object-delete-property-dictionary.test.ts index 074d1e0ca560..7d81b7df1d7a 100644 --- a/test/js/bun/jsc/object-delete-property-dictionary.test.ts +++ b/test/js/bun/jsc/object-delete-property-dictionary.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; // An object with more than 128 named properties gets a dictionary structure in JavaScriptCore, and a @@ -13,7 +13,7 @@ const tableBound = 30; async function propertyTablesAllocatedBy( loopBody: string, { object = 'const object = {}; for (let i = 0; i < 1000; i++) object["key" + i] = i;', env = bunEnv } = {}, -): Promise<{ propertyTables: number; keys: number; value: unknown }> { +): Promise<{ propertyTables: number; keys: number; value: unknown; writable: boolean; prototypeMarker: unknown }> { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -27,7 +27,13 @@ async function propertyTablesAllocatedBy( const before = heapStats().objectTypeCounts.PropertyTable ?? 0; for (let i = 0; i < 100; i++) body(object, i); const after = heapStats().objectTypeCounts.PropertyTable ?? 0; - console.log(JSON.stringify({ propertyTables: after - before, keys: Object.keys(object).length, value: object.key0 })); + console.log(JSON.stringify({ + propertyTables: after - before, + keys: Object.keys(object).length, + value: object.key0, + writable: Object.getOwnPropertyDescriptor(object, "key0").writable, + prototypeMarker: Object.getPrototypeOf(object)?.b, + })); `, ], env, @@ -40,41 +46,45 @@ async function propertyTablesAllocatedBy( return JSON.parse(stdout); } -test("deleting and re-adding a property on a large object does not copy its property table", async () => { - const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = i;"); - // Before the fix: 109 for 100 iterations. - expect(result.propertyTables).toBeLessThan(tableBound); - expect(result).toMatchObject({ keys: 1000, value: 99 }); -}); +describe.concurrent("a transition away from a dictionary structure takes its property table", () => { + test("deleting and re-adding a property on a large object does not copy its property table", async () => { + const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = i;"); + // Before the fix: 109 for 100 iterations. + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1000, value: 99 }); + }); -test("changing the attributes of a property on a large object does not copy its property table", async () => { - // An add pins the table in place. The attribute change after it used to copy the table. - const result = await propertyTablesAllocatedBy( - 'object["added" + i] = i; Object.defineProperty(object, "key0", { value: i, writable: i % 2 === 0, enumerable: true, configurable: true });', - ); - expect(result.propertyTables).toBeLessThan(tableBound); - expect(result).toMatchObject({ keys: 1100, value: 99 }); -}); + test("changing the attributes of a property on a large object does not copy its property table", async () => { + // An add pins the table in place. The attribute change after it used to copy the table. + const result = await propertyTablesAllocatedBy( + 'object["added" + i] = i; Object.defineProperty(object, "key0", { value: i, writable: i % 2 === 0, enumerable: true, configurable: true });', + ); + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1100, value: 99, writable: false }); + }); -test("changing the prototype of a large object does not copy its property table", async () => { - // A prototype change pins the table in the new structure. It took a copy of the dictionary's table. - const result = await propertyTablesAllocatedBy("Object.setPrototypeOf(object, prototypes[i & 1]);", { - object: - 'const object = {}; for (let i = 0; i < 1000; i++) object["key" + i] = i; const prototypes = [{ a: 1 }, { b: 2 }];', + test("changing the prototype of a large object does not copy its property table", async () => { + // A prototype change pins the table in the new structure. It took a copy of the dictionary's table. + const result = await propertyTablesAllocatedBy("Object.setPrototypeOf(object, prototypes[i & 1]);", { + object: + 'const object = {}; for (let i = 0; i < 1000; i++) object["key" + i] = i; const prototypes = [{ a: 1 }, { b: 2 }];', + }); + expect(result.propertyTables).toBeLessThan(tableBound); + expect(result).toMatchObject({ keys: 1000, value: 0, prototypeMarker: 2 }); }); - expect(result.propertyTables).toBeLessThan(tableBound); - expect(result).toMatchObject({ keys: 1000, value: 0 }); -}); -test("deleting and re-adding a variable in a large process.env does not copy its property table", async () => { - // process.env is the same kind of object once the environment has more than 128 variables. - const env = { ...bunEnv }; - for (let i = 0; i < 200; i++) env[`ROBOBUN_VAR_${i}`] = String(i); - const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = String(i);", { - object: "const object = process.env;", - env, + test("deleting and re-adding a variable in a large process.env does not copy its property table", async () => { + // process.env is the same kind of object once the environment has more than 128 variables. + const env = { ...bunEnv }; + for (let i = 0; i < 200; i++) env[`ROBOBUN_VAR_${i}`] = String(i); + // Before the fix: 36 tables, with any number of iterations over 36 (after that the object is in a state the + // engine edits in place). After: 10. + const result = await propertyTablesAllocatedBy("delete object.key0; object.key0 = String(i);", { + object: "const object = process.env;", + env, + }); + expect(result.propertyTables).toBeLessThan(20); + expect(result).toMatchObject({ value: "99" }); + expect(result.keys).toBeGreaterThan(200); }); - expect(result.propertyTables).toBeLessThan(tableBound); - expect(result).toMatchObject({ value: "99" }); - expect(result.keys).toBeGreaterThan(200); });