From 1eb67851e67440e078977f39c7b6916d458f6f1c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:58:55 +0000 Subject: [PATCH 1/3] Add regression tests for three string leaks fixed by #40238 #40238 made bun_core::String own its WTF ref and release it on Drop. That closed three leaks that had open fix PRs with tests but no test on main: - SocketAddress: options.address on validation-error paths (#35230) - worker_threads: the cached Bun.main path at VM teardown (#38178) - bun:test: the bound test/describe function name (#38208) This adds only the tests from those branches. The LSan tests run on the ASAN lanes. The SocketAddress test measures RSS growth. --- test/js/bun/test/bun-test.test.ts | 85 ++++++++++++++++++- test/js/node/net/socketaddress.spec.ts | 60 ++++++++++++- .../worker-shutdown-post-leak.test.ts | 54 +++++++++++- 3 files changed, 196 insertions(+), 3 deletions(-) diff --git a/test/js/bun/test/bun-test.test.ts b/test/js/bun/test/bun-test.test.ts index 25b583c4c752..ba3d2238ad65 100644 --- a/test/js/bun/test/bun-test.test.ts +++ b/test/js/bun/test/bun-test.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { join } from "path"; test("Bun.version", () => { expect(process.versions.bun).toBe(Bun.version); @@ -41,3 +42,85 @@ console.log("OK");`, expect(stdout).toBe("OK\n"); expect(exitCode).toBe(0); }); + +// Every test/describe function handed to user code (the four created with the +// bun:test module object plus one per modifier call) is a ScopeFunctions bound +// through ScopeFunctions.rs `bind`, which used to leak a WTF copy of the +// function's name each time. LSan only sees WTF allocations when Malloc=1 +// routes bmalloc through the system allocator. +test.skipIf(!isASAN || isWindows)( + "test/describe modifiers do not leak the bound function name", + async () => { + using dir = tempDir("scope-functions-name-leak", { + "modifiers.test.ts": ` + import { describe, expect, test, xdescribe, xtest } from "bun:test"; + + const fails = () => { + throw new Error("expected failure"); + }; + + test("test", () => {}); + xtest("xtest", () => {}); + test.skip("test.skip", () => {}); + test.todo("test.todo"); + test.failing("test.failing", fails); + test.concurrent("test.concurrent", () => {}); + test.serial("test.serial", () => {}); + test.if(true)("test.if(true)", () => {}); + test.if(false)("test.if(false)", () => {}); + test.skipIf(true)("test.skipIf(true)", () => {}); + test.skipIf(false)("test.skipIf(false)", () => {}); + test.todoIf(true)("test.todoIf(true)", () => {}); + test.todoIf(false)("test.todoIf(false)", () => {}); + test.failingIf(true)("test.failingIf(true)", fails); + test.failingIf(false)("test.failingIf(false)", () => {}); + test.concurrentIf(true)("test.concurrentIf(true)", () => {}); + test.concurrentIf(false)("test.concurrentIf(false)", () => {}); + test.serialIf(true)("test.serialIf(true)", () => {}); + test.serialIf(false)("test.serialIf(false)", () => {}); + test.each([1, 2])("test.each %i", i => expect(i).toBeNumber()); + test.skip.each([1])("test.skip.each %i", () => {}); + test.each([1]).skipIf(false)("test.each().skipIf(false) %i", () => {}); + + describe("describe", () => test("inner", () => {})); + xdescribe("xdescribe", () => test("inner", () => {})); + describe.skip("describe.skip", () => test("inner", () => {})); + describe.todo("describe.todo", () => test("inner", () => {})); + describe.concurrent("describe.concurrent", () => test("inner", () => {})); + describe.serial("describe.serial", () => test("inner", () => {})); + describe.if(true)("describe.if(true)", () => test("inner", () => {})); + describe.skipIf(true)("describe.skipIf(true)", () => test("inner", () => {})); + describe.skipIf(false)("describe.skipIf(false)", () => test("inner", () => {})); + describe.todoIf(false)("describe.todoIf(false)", () => test("inner", () => {})); + describe.concurrentIf(true)("describe.concurrentIf(true)", () => test("inner", () => {})); + describe.serialIf(true)("describe.serialIf(true)", () => test("inner", () => {})); + describe.each([1, 2])("describe.each %i", i => test("inner", () => expect(i).toBeNumber())); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "modifiers.test.ts"], + cwd: String(dir), + env: { + ...bunEnv, + Malloc: "1", + BUN_DESTRUCT_VM_ON_EXIT: "1", + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The result counts prove every modifier above registered; a leak report + // adds LeakSanitizer lines after them and fails the exit code. + const report = stderr + .split("\n") + .filter(line => /^ \d+ (pass|skip|todo|fail)$/.test(line) || /Sanitizer|leak of /.test(line)); + expect({ report, exitCode }).toEqual({ report: [" 26 pass", " 8 skip", " 3 todo", " 0 fail"], exitCode: 0 }); + }, + // LSan symbolizes stacks against the debug binary: about 5s even for a clean + // run (the suppressed leaks still get symbolized), much longer on failure. + 90_000, +); diff --git a/test/js/node/net/socketaddress.spec.ts b/test/js/node/net/socketaddress.spec.ts index f330b218f325..3c45482e6d76 100644 --- a/test/js/node/net/socketaddress.spec.ts +++ b/test/js/node/net/socketaddress.spec.ts @@ -1,7 +1,7 @@ /** * @see https://nodejs.org/api/net.html#class-netsocketaddress */ -import { rss } from "harness"; +import { bunEnv, bunExe, rss } from "harness"; import { SocketAddress, SocketAddressInitOptions } from "node:net"; let v4: SocketAddress; @@ -132,6 +132,64 @@ describe("SocketAddress constructor", () => { expect(after).toBeLessThanOrEqual(before * growthFactor); }); + + it("does not leak the address string on validation-error paths", async () => { + // Each case populates the address BunString (+1 WTFStringImpl ref) then + // throws from a later validator. With a 128KB address and 500 iterations, + // a leaked ref pins ~64MB per case; the threshold is well below that and + // well above ASAN/GC noise. + const code = /* js */ ` + const net = require("node:net"); + const big = Buffer.alloc(128 * 1024, "a").toString(); + const cases = { + // Options::from_js: later option validators throw after address_str is set + bad_family: i => new net.SocketAddress({ address: big + (i & 0xff), family: "bad!" }), + bad_port: i => new net.SocketAddress({ address: big + (i & 0xff), port: NaN }), + bad_flow_type: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv6", flowlabel: "x" }), + bad_flow_range: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv6", flowlabel: -1 }), + // init_js: pton rejects an invalid IP after options were accepted + pton_reject: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv4" }), + // init_from_addr_family: AF::from_js throws after the address was read + blocklist_bad_family: i => new net.BlockList().addAddress(big + (i & 0xff), "bad!"), + }; + for (const fn of Object.values(cases)) + for (let i = 0; i < 150; i++) try { fn(i); } catch {} + Bun.gc(true); Bun.gc(true); + const out = {}; + for (const [name, fn] of Object.entries(cases)) { + Bun.gc(true); + const before = process.memoryUsage.rss(); + for (let i = 0; i < 500; i++) try { fn(i); } catch {} + Bun.gc(true); Bun.gc(true); Bun.gc(true); + out[name] = (process.memoryUsage.rss() - before) / 1024 / 1024; + } + process.stdout.write(JSON.stringify(out)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--smol", "-e", code], + env: { + ...bunEnv, + // Disable ASAN's free-quarantine so RSS reflects live allocations; + // harmless on non-ASAN builds. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + let growth: Record; + try { + growth = JSON.parse(stdout); + } catch { + throw new Error(`subprocess did not report growth\nstdout: ${stdout}\nstderr: ${stderr}\nexit: ${exitCode}`); + } + expect(stderr).toBe(""); + for (const [name, mb] of Object.entries(growth)) { + expect(mb, `RSS growth for '${name}' error path: ${mb.toFixed(2)} MB`).toBeLessThan(20); + } + expect(Object.keys(growth).length).toBe(6); + expect(exitCode).toBe(0); + }, 30_000); }); // describe("SocketAddress.isSocketAddress", () => { diff --git a/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts b/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts index b6b03722c6be..4f65f30f5877 100644 --- a/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts +++ b/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows } from "harness"; +import { readFileSync } from "fs"; +import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; import { join } from "path"; // A worker's shutdown used to drain its concurrent queue and only then mark @@ -47,3 +48,54 @@ test.skipIf(!isASAN || isWindows)( expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); }, ); + +// The Bun.main getter caches the worker's resolved entry path in its +// VirtualMachine (an atom when the path is ASCII, a plain WTF string copy +// otherwise). A node-kind worker reads Bun.main during bootstrap, when +// process.mainModule is set up, so every Worker started from a file populates +// it, and the worker's VM teardown used to skip releasing it: one string +// leaked per Worker. Eval workers have no file to resolve and never hit this. +// +// The string lives in WTF's allocator, which LSan cannot see through bmalloc; +// Malloc=1 routes it through the system allocator. That also exposes the +// worker thread's EventNames table, a separate per-thread leak (#38164) that +// is not what this test checks, so it is tolerated on top of the shared +// suppressions until that fix lands. +test.concurrent.skipIf(!isASAN || isWindows).each([ + ["ascii", "sub"], + ["non-ascii", "s\u00fcb"], +])( + "a worker_threads Worker started from a file (%s path) does not leak its resolved Bun.main path", + async (_, subdir) => { + using dir = tempDir("worker-main-path-leak", { + "main.mjs": ` + import { Worker } from "node:worker_threads"; + import { join } from "node:path"; + new Worker(join(import.meta.dirname, ${JSON.stringify(subdir)}, "worker.js")).on("exit", code => + console.log("exit", code), + ); + `, + [`${subdir}/worker.js`]: "", + "leaksan.supp": + readFileSync(join(import.meta.dirname, "../../../leaksan.supp"), "utf8") + "\nleak:WebCore::eventNames\n", + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + cwd: String(dir), + env: { + ...bunEnv, + BUN_DESTRUCT_VM_ON_EXIT: "1", + Malloc: "1", + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), + LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(String(dir), "leaksan.supp")}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "exit 0\n", stderr: "", exitCode: 0 }); + }, + // A clean run takes several seconds under debug+ASAN. A failing run spends + // tens of seconds more in LSan's report symbolization. + 90_000, +); From 3cd36e6e5f8104ca245653bf9c9d8305c4a7da55 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:07:30 +0000 Subject: [PATCH 2/3] Keep only the SocketAddress leak test and use expectRssDeltaBelow The worker test was vacuous on current main: #39536 removed the bootstrap read of Bun.main, so a worker with an empty body never populates main_resolved_path. The bun:test modifier test measured a path that creates no WTF string since bind() takes a &'static str. The SocketAddress test now goes through the shared RSS helper and branches its bound on debug/ASAN builds. Fewer iterations with a larger string keep the leaked signal at about 50 MiB per path while the test runs in about 2.5 s under a debug ASAN build. --- test/js/bun/test/bun-test.test.ts | 85 +------------------ test/js/node/net/socketaddress.spec.ts | 68 +++++---------- .../worker-shutdown-post-leak.test.ts | 54 +----------- 3 files changed, 25 insertions(+), 182 deletions(-) diff --git a/test/js/bun/test/bun-test.test.ts b/test/js/bun/test/bun-test.test.ts index ba3d2238ad65..25b583c4c752 100644 --- a/test/js/bun/test/bun-test.test.ts +++ b/test/js/bun/test/bun-test.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; -import { join } from "path"; +import { bunEnv, bunExe } from "harness"; test("Bun.version", () => { expect(process.versions.bun).toBe(Bun.version); @@ -42,85 +41,3 @@ console.log("OK");`, expect(stdout).toBe("OK\n"); expect(exitCode).toBe(0); }); - -// Every test/describe function handed to user code (the four created with the -// bun:test module object plus one per modifier call) is a ScopeFunctions bound -// through ScopeFunctions.rs `bind`, which used to leak a WTF copy of the -// function's name each time. LSan only sees WTF allocations when Malloc=1 -// routes bmalloc through the system allocator. -test.skipIf(!isASAN || isWindows)( - "test/describe modifiers do not leak the bound function name", - async () => { - using dir = tempDir("scope-functions-name-leak", { - "modifiers.test.ts": ` - import { describe, expect, test, xdescribe, xtest } from "bun:test"; - - const fails = () => { - throw new Error("expected failure"); - }; - - test("test", () => {}); - xtest("xtest", () => {}); - test.skip("test.skip", () => {}); - test.todo("test.todo"); - test.failing("test.failing", fails); - test.concurrent("test.concurrent", () => {}); - test.serial("test.serial", () => {}); - test.if(true)("test.if(true)", () => {}); - test.if(false)("test.if(false)", () => {}); - test.skipIf(true)("test.skipIf(true)", () => {}); - test.skipIf(false)("test.skipIf(false)", () => {}); - test.todoIf(true)("test.todoIf(true)", () => {}); - test.todoIf(false)("test.todoIf(false)", () => {}); - test.failingIf(true)("test.failingIf(true)", fails); - test.failingIf(false)("test.failingIf(false)", () => {}); - test.concurrentIf(true)("test.concurrentIf(true)", () => {}); - test.concurrentIf(false)("test.concurrentIf(false)", () => {}); - test.serialIf(true)("test.serialIf(true)", () => {}); - test.serialIf(false)("test.serialIf(false)", () => {}); - test.each([1, 2])("test.each %i", i => expect(i).toBeNumber()); - test.skip.each([1])("test.skip.each %i", () => {}); - test.each([1]).skipIf(false)("test.each().skipIf(false) %i", () => {}); - - describe("describe", () => test("inner", () => {})); - xdescribe("xdescribe", () => test("inner", () => {})); - describe.skip("describe.skip", () => test("inner", () => {})); - describe.todo("describe.todo", () => test("inner", () => {})); - describe.concurrent("describe.concurrent", () => test("inner", () => {})); - describe.serial("describe.serial", () => test("inner", () => {})); - describe.if(true)("describe.if(true)", () => test("inner", () => {})); - describe.skipIf(true)("describe.skipIf(true)", () => test("inner", () => {})); - describe.skipIf(false)("describe.skipIf(false)", () => test("inner", () => {})); - describe.todoIf(false)("describe.todoIf(false)", () => test("inner", () => {})); - describe.concurrentIf(true)("describe.concurrentIf(true)", () => test("inner", () => {})); - describe.serialIf(true)("describe.serialIf(true)", () => test("inner", () => {})); - describe.each([1, 2])("describe.each %i", i => test("inner", () => expect(i).toBeNumber())); - `, - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "modifiers.test.ts"], - cwd: String(dir), - env: { - ...bunEnv, - Malloc: "1", - BUN_DESTRUCT_VM_ON_EXIT: "1", - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), - LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../../leaksan.supp")}`, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - // The result counts prove every modifier above registered; a leak report - // adds LeakSanitizer lines after them and fails the exit code. - const report = stderr - .split("\n") - .filter(line => /^ \d+ (pass|skip|todo|fail)$/.test(line) || /Sanitizer|leak of /.test(line)); - expect({ report, exitCode }).toEqual({ report: [" 26 pass", " 8 skip", " 3 todo", " 0 fail"], exitCode: 0 }); - }, - // LSan symbolizes stacks against the debug binary: about 5s even for a clean - // run (the suppressed leaks still get symbolized), much longer on failure. - 90_000, -); diff --git a/test/js/node/net/socketaddress.spec.ts b/test/js/node/net/socketaddress.spec.ts index 3c45482e6d76..2a28b76790af 100644 --- a/test/js/node/net/socketaddress.spec.ts +++ b/test/js/node/net/socketaddress.spec.ts @@ -1,7 +1,7 @@ /** * @see https://nodejs.org/api/net.html#class-netsocketaddress */ -import { bunEnv, bunExe, rss } from "harness"; +import { expectRssDeltaBelow, rss } from "harness"; import { SocketAddress, SocketAddressInitOptions } from "node:net"; let v4: SocketAddress; @@ -134,62 +134,40 @@ describe("SocketAddress constructor", () => { }); it("does not leak the address string on validation-error paths", async () => { - // Each case populates the address BunString (+1 WTFStringImpl ref) then - // throws from a later validator. With a 128KB address and 500 iterations, - // a leaked ref pins ~64MB per case; the threshold is well below that and - // well above ASAN/GC noise. + // Each case reads options.address into a native string and then throws + // from a later validator. The string is 512 KiB, so a leaked ref pins + // about 50 MiB per case over 100 iterations. const code = /* js */ ` const net = require("node:net"); - const big = Buffer.alloc(128 * 1024, "a").toString(); + const big = Buffer.alloc(512 * 1024, "a").toString(); const cases = { - // Options::from_js: later option validators throw after address_str is set - bad_family: i => new net.SocketAddress({ address: big + (i & 0xff), family: "bad!" }), - bad_port: i => new net.SocketAddress({ address: big + (i & 0xff), port: NaN }), - bad_flow_type: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv6", flowlabel: "x" }), - bad_flow_range: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv6", flowlabel: -1 }), - // init_js: pton rejects an invalid IP after options were accepted - pton_reject: i => new net.SocketAddress({ address: big + (i & 0xff), family: "ipv4" }), + // Options::from_js: a later option validator throws after the address was read + bad_family: i => new net.SocketAddress({ address: big + i, family: "bad!" }), + bad_port: i => new net.SocketAddress({ address: big + i, port: NaN }), + bad_flow_type: i => new net.SocketAddress({ address: big + i, family: "ipv6", flowlabel: "x" }), + bad_flow_range: i => new net.SocketAddress({ address: big + i, family: "ipv6", flowlabel: -1 }), + // init_js: pton rejects an invalid IP after the options were accepted + pton_reject: i => new net.SocketAddress({ address: big + i, family: "ipv4" }), // init_from_addr_family: AF::from_js throws after the address was read - blocklist_bad_family: i => new net.BlockList().addAddress(big + (i & 0xff), "bad!"), + blocklist_bad_family: i => new net.BlockList().addAddress(big + i, "bad!"), }; for (const fn of Object.values(cases)) - for (let i = 0; i < 150; i++) try { fn(i); } catch {} - Bun.gc(true); Bun.gc(true); + for (let i = 0; i < 20; i++) try { fn(i); } catch {} + Bun.gc(true); const out = {}; for (const [name, fn] of Object.entries(cases)) { - Bun.gc(true); const before = process.memoryUsage.rss(); - for (let i = 0; i < 500; i++) try { fn(i); } catch {} - Bun.gc(true); Bun.gc(true); Bun.gc(true); + for (let i = 0; i < 100; i++) try { fn(i); } catch {} + Bun.gc(true); out[name] = (process.memoryUsage.rss() - before) / 1024 / 1024; } - process.stdout.write(JSON.stringify(out)); + console.log(JSON.stringify(out)); + console.log(JSON.stringify({ deltaMiB: Math.max(...Object.values(out)) })); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "--smol", "-e", code], - env: { - ...bunEnv, - // Disable ASAN's free-quarantine so RSS reflects live allocations; - // harmless on non-ASAN builds. - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":"), - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - let growth: Record; - try { - growth = JSON.parse(stdout); - } catch { - throw new Error(`subprocess did not report growth\nstdout: ${stdout}\nstderr: ${stderr}\nexit: ${exitCode}`); - } - expect(stderr).toBe(""); - for (const [name, mb] of Object.entries(growth)) { - expect(mb, `RSS growth for '${name}' error path: ${mb.toFixed(2)} MB`).toBeLessThan(20); - } - expect(Object.keys(growth).length).toBe(6); - expect(exitCode).toBe(0); - }, 30_000); + + // Unfixed: about 50 MiB on every path. Fixed: allocator slack only. + await expectRssDeltaBelow(["--smol", "-e", code], { release: 20, debug: 30 }); + }); }); // describe("SocketAddress.isSocketAddress", () => { diff --git a/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts b/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts index 4f65f30f5877..b6b03722c6be 100644 --- a/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts +++ b/test/js/node/worker_threads/worker-shutdown-post-leak.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; -import { readFileSync } from "fs"; -import { bunEnv, bunExe, isASAN, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows } from "harness"; import { join } from "path"; // A worker's shutdown used to drain its concurrent queue and only then mark @@ -48,54 +47,3 @@ test.skipIf(!isASAN || isWindows)( expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); }, ); - -// The Bun.main getter caches the worker's resolved entry path in its -// VirtualMachine (an atom when the path is ASCII, a plain WTF string copy -// otherwise). A node-kind worker reads Bun.main during bootstrap, when -// process.mainModule is set up, so every Worker started from a file populates -// it, and the worker's VM teardown used to skip releasing it: one string -// leaked per Worker. Eval workers have no file to resolve and never hit this. -// -// The string lives in WTF's allocator, which LSan cannot see through bmalloc; -// Malloc=1 routes it through the system allocator. That also exposes the -// worker thread's EventNames table, a separate per-thread leak (#38164) that -// is not what this test checks, so it is tolerated on top of the shared -// suppressions until that fix lands. -test.concurrent.skipIf(!isASAN || isWindows).each([ - ["ascii", "sub"], - ["non-ascii", "s\u00fcb"], -])( - "a worker_threads Worker started from a file (%s path) does not leak its resolved Bun.main path", - async (_, subdir) => { - using dir = tempDir("worker-main-path-leak", { - "main.mjs": ` - import { Worker } from "node:worker_threads"; - import { join } from "node:path"; - new Worker(join(import.meta.dirname, ${JSON.stringify(subdir)}, "worker.js")).on("exit", code => - console.log("exit", code), - ); - `, - [`${subdir}/worker.js`]: "", - "leaksan.supp": - readFileSync(join(import.meta.dirname, "../../../leaksan.supp"), "utf8") + "\nleak:WebCore::eventNames\n", - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "main.mjs"], - cwd: String(dir), - env: { - ...bunEnv, - BUN_DESTRUCT_VM_ON_EXIT: "1", - Malloc: "1", - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"), - LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(String(dir), "leaksan.supp")}`, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr, exitCode }).toEqual({ stdout: "exit 0\n", stderr: "", exitCode: 0 }); - }, - // A clean run takes several seconds under debug+ASAN. A failing run spends - // tens of seconds more in LSan's report symbolization. - 90_000, -); From fd4a62cf7fd9d432cc99db164ad6f0bbc204836c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:23:40 +0000 Subject: [PATCH 3/3] ci: retrigger