Skip to content
Open
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
65 changes: 65 additions & 0 deletions test/js/bun/gc/gc-controller-cadence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,68 @@ test.skipIf(!isLinux || isASAN)(
expect(exitCode).toBe(0);
},
);

// JSC paces collections on the bytes a program allocates, and it decides between an eden and a full collection by the
// size of the heap after the last one. Both numbers were wrong for typed arrays and ArrayBuffers that die young, which
// is every chunk of a node:fs, node:stream or node:zlib style loop (oven-sh/WebKit#684).
describe("short-lived typed arrays and ArrayBuffers", () => {
// With the concurrent collector off, a collection runs at the allocation that passes the budget. The collections
// then follow from what the heap counted as allocated and from nothing else: 5000 arrays of 32 KB against the 8 MB
// budget of a small heap are 19 eden collections.
async function run(loopBody: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `for (let i = 0; i < 5000; i++) { ${loopBody}; }`],
env: {
...bunEnv,
BUN_GC_TIMER_DISABLE: "1",
BUN_GARBAGE_COLLECTOR_LEVEL: "0",
BUN_JSC_useConcurrentGC: "0",
BUN_JSC_logGC: "1",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const log = stdout + stderr;
expect(exitCode, log).toBe(0);
const eden = (log.match(/=> EdenCollection/g) ?? []).length;
const full = (log.match(/=> FullCollection/g) ?? []).length;
return { eden, full, total: eden + full };
}

// Several tests look at the same loop. Each loop runs once, so the file starts six children and not nine.
const runs = new Map<string, ReturnType<typeof run>>();
function collectionsIn(loopBody: string) {
let result = runs.get(loopBody);
if (!result) runs.set(loopBody, (result = run(loopBody)));
return result;
}

// A typed array of more than 1000 elements owns a malloc'ed vector, and the heap counts that vector when the array
// allocates it. `.buffer`, `subarray()` and Buffer's `slice()` wrap the same vector in an ArrayBuffer. The heap
// counted the whole vector as allocated a second time there, and the loop ran 39 collections. A count well under
// that of the array alone would mean the vector is not counted at all.
test.concurrent.each([
["new Uint8Array(32768).buffer", "new Uint8Array(32768)"],
["new Uint8Array(32768).subarray(0, 10)", "new Uint8Array(32768)"],
["Buffer.allocUnsafe(32768).slice(0, 10)", "Buffer.allocUnsafe(32768)"],
])("the vector of %s counts as allocated once", async (withArrayBuffer, arrayAlone) => {
const [alone, withBuffer] = await Promise.all([collectionsIn(arrayAlone), collectionsIn(withArrayBuffer)]);
expect(alone.total).toBeGreaterThan(10);
expect(withBuffer.total).toBeGreaterThanOrEqual(alone.total * 0.75);
expect(withBuffer.total).toBeLessThanOrEqual(alone.total * 1.25);
});

// An eden collection frees the ArrayBuffers that died young, but the size of the heap kept counting them until the
// next full collection. One eden's worth of dead buffers made the heap look full, so every second collection was a
// full one: 9 of 19 here. What is left is a full collection at exit in the builds that tear the VM down.
test.concurrent.each([
"new ArrayBuffer(32768)",
"new Uint8Array(32768).buffer",
"Buffer.allocUnsafe(32768).slice(0, 10)",
])("%s that die young do not make every second collection a full one", async loopBody => {
const { full, total } = await collectionsIn(loopBody);
expect(full).toBeLessThanOrEqual(2);
expect(total).toBeGreaterThan(10);
});
});
Loading