From 2b25cf9ad6c3049206ce7d5cb6995fa0ec899ff1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:20:56 +0000 Subject: [PATCH 1/2] test(aot): name a leak record by the function that allocated test/aot/no-leak-records-at-exit.test.ts gave every leak record of a `Malloc=1` run the name `bmalloc::SystemHeap::malloc`, and every record of Rust the name `alloc`. An executable passes when `bun run` has each of its names, so a record that only the executable has passed. A record now has the name of the first called function that is neither an allocator nor a container. The frames of inlined functions are left out. `bmalloc::`, `__rustc::`, a class `operator new` and a method of a trait `Allocator` are allocators. A method of Rust is a container frame when the language, hashbrown or smallvec has its code. A `requires` clause, a qualifier and the location are not part of a name. Tests of written text hold the parser against records that a symbolizer wrote. They need no compiler and run on every Bun. This is the change of #47443 on top of main after #47942. --- test/aot/no-leak-records-at-exit.test.ts | 367 +++++++++++++++++++++-- 1 file changed, 348 insertions(+), 19 deletions(-) diff --git a/test/aot/no-leak-records-at-exit.test.ts b/test/aot/no-leak-records-at-exit.test.ts index f733506b9767..f8073b5915fb 100644 --- a/test/aot/no-leak-records-at-exit.test.ts +++ b/test/aot/no-leak-records-at-exit.test.ts @@ -1,4 +1,4 @@ -import { expect } from "bun:test"; +import { expect, test as testAnywhere } from "bun:test"; import { bunEnv, bunExe, isASAN, tempDir } from "harness"; import { existsSync } from "node:fs"; import { join } from "node:path"; @@ -110,28 +110,99 @@ const envWithSystemMalloc = { ...env, Malloc: "1", LSAN_OPTIONS: "malloc_context /** Without the line that names the port of a default export that is served. */ const getLines = (stdout: string) => stdout.replace(/^Started (development )?server: .*\n/gm, ""); +/** + * The name of a function, without its return type, its arguments and what follows them. A function that is written in another + * has that one's name in front: the name of `Text::measure() const::$_0::operator()() const` is `Text::measure::$_0::operator`. + */ function getFunctionName(frame: string): string { if (frame.startsWith("<")) return frame; let depth = 0; let name = ""; - for (const character of frame) { - if (character === "<" || character === "(") depth++; - else if (character === ">" || character === ")") depth--; - else if (depth === 0) name += character; + for (let index = 0; index < frame.length; index++) { + if (frame[index] === "<" || frame[index] === "(") depth++; + // The `>` of `->` closes nothing: `fn(u8) -> u8`. + else if ((frame[index] === ">" && frame[index - 1] !== "-") || frame[index] === ")") depth--; + else if (depth === 0) name += frame[index]; } - return name - .replace(/ const$/, "") + const words = name + // The constraint of a function ends where a lambda that is written in the function begins. + .replace(/ requires .*?(?=::['$]|$)/, "") + .replace(/(?: (?:const|volatile|&&?))+(?=::|$)/g, "") .trim() - .split(" ") - .at(-1)!; + .split(" "); + // `operator new`, and a conversion to a type of several words: `operator unsigned long`. + const operator = words.findIndex(word => /(?:^|::)operator$/.test(word)); + return words.slice(operator < 0 ? -1 : operator).join(" "); } -const isAllocator = (frame: string) => - /^(malloc|calloc|realloc|posix_memalign|aligned_alloc|operator new|pas_|bmalloc_|__rust_|__rdl_|<(alloc|core|std)::)/.test( - frame, - ) || /^(WTF|std|alloc|core|hashbrown)::/.test(getFunctionName(frame)); +// The namespaces and the crates of allocators and containers: of what allocates for the function that called it. +const containers = /^(WTF|bmalloc|std|alloc|core|hashbrown|smallvec|__rustc)$/; -/** Each leak record of a report, by the first function on its stack that is neither an allocator nor a container. */ +/** + * The crate that has the code of a method of Rust, which a symbolizer writes `::method` or `::method`. + * `core` stands for the language and for an allocator. + * + * A method of a trait `Allocator` is an allocator's, in whatever crate its type is. Any other trait says whose the code is + * when it is written in a method, or when its crate is none of `containers`: the language has + * `::to_vec_in::ConvertVec>::to_vec` for every `T`, and Bun has `<&[u8] as bun_js_printer::PrintArg>::print_into`. + * A trait of the language does not say it, for a crate may give one to its own type. Then the type says it. A reference and a + * `dyn` are of the crate of what they are of. A slice, a tuple, a pointer and a number are the language's. + */ +function getCrateOfMethod(method: string): string { + // Where the first `<` closes, and where ` as ` is in no other `<`. + let depth = 0; + let end = 0; + let cut = -1; + for (; end < method.length; end++) { + if (method[end] === "<") depth++; + else if (method[end] === ">" && method[end - 1] !== "-" && --depth === 0) break; + else if (depth === 1 && method.startsWith(" as ", end)) cut = end; + } + const getCrate = (path: string) => + path.startsWith("<") ? getCrateOfMethod(path) : (/^(\w+)::/.exec(path)?.[1] ?? "core"); + const trait = cut < 0 ? "" : method.slice(cut + 4, end); + if (trait.endsWith("::Allocator")) return "core"; + if (trait.startsWith("<") || (trait && !containers.test(getCrate(trait)))) return getCrate(trait); + return getCrate(method.slice(1, cut < 0 ? end : cut).replace(/^(?:&(?:mut )?|dyn )+/, "")); +} + +/** Whether the function of a frame is an allocator or a container. The namespace or the crate of its code says it. */ +function isAllocator(frame: string): boolean { + const name = getFunctionName(frame); + if (/^(malloc|calloc|realloc|posix_memalign|aligned_alloc|pas_|bmalloc_|__rust_|__rdl_)/.test(name)) return true; + if (/\boperator new(\[\])?$/.test(name)) return true; + return containers.test(name.startsWith("<") ? getCrateOfMethod(name) : (/^(\w+)::/.exec(name)?.[1] ?? "")); +} + +// Where a frame is, behind its function: an executable and an offset, or a file and a line. The path of the executable may have +// a space in it, and so may the path of a file that starts at the root. Any other path of a file is taken to have none. +const frameLocation = / \([^()]*\+0x[0-9a-f]+\)(?: \(BuildId: [0-9a-f]+\))?$| \/\S.*$| \S+$/; + +/** + * The functions that were called on the stack of a leak record, from the allocator outwards, as the symbolizer names them. + * + * A symbolizer that has the debug information of the executable writes a frame for each inlined function too, before the frame + * of the function that it was inlined into and at the same address. Those frames are left out. The name of a function that was + * called is the same with the debug information and without it. The name of an inlined one may be its last word alone: a Bun + * built for release has `alloc` for `std::sys::alloc::unix::alloc`, which no list of allocators can tell from another `alloc`. + * What a failure prints, and what LeakSanitizer holds a list of suppressions against, still has every frame. + */ +function getCalledFunctions(record: string): string[] { + const functions: string[] = []; + let address: string | undefined; + for (const [, at, frame] of record.matchAll(/^ +#\d+ (0x[0-9a-f]+) in (.+)$/gm)) { + if (at === address) functions.pop(); + address = at; + functions.push(frame.replace(frameLocation, "")); + } + return functions; +} + +/** The first function that was called on the stack of a leak record that is neither an allocator nor a container. */ +const getAllocatedBy = (record: string) => + getFunctionName(getCalledFunctions(record).find(frame => !isAllocator(frame)) ?? record); + +/** The leak records of a report, by the function that allocated. */ function getLeakRecords(stderr: string): Map { const report = /\n=+\n==\d+==ERROR: LeakSanitizer: detected memory leaks\n[^]*?\nSUMMARY: AddressSanitizer: .*\n/.exec( @@ -140,15 +211,273 @@ function getLeakRecords(stderr: string): Map { expect(stderr.replace(report, "")).toBe(""); const records = new Map(); for (const record of report.split(/\n(?=(?:Direct|Indirect) leak of )/).slice(1)) { - const frames = Array.from( - record.matchAll(/^ +#\d+ 0x[0-9a-f]+ in (.+?)(?: \/\S+| \(\S+\+0x[0-9a-f]+\).*)?$/gm), - match => match[1], - ); - records.set(getFunctionName(frames.find(frame => !isAllocator(frame)) ?? record), record); + const allocatedBy = getAllocatedBy(record); + records.set(allocatedBy, (records.get(allocatedBy) ?? "") + record); } return records; } +// What reads a report is held against text that a symbolizer wrote. That needs no compiler, so these tests run on every Bun. + +// The records of the report of a small program, built in a folder with a space in its name. One function allocated the first +// and the last of them. +const recordsOfTwoFunctions = [ + `Direct leak of 64 byte(s) in 1 object(s) allocated from: + #0 0x55cb5bcc04f8 in malloc (/tmp/with space/leak+0xde4f8) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) + #1 0x55cb5bd04cd5 in app::List::makePair() const /tmp/with space/leak.cpp:7:9 + #2 0x55cb5bd04c64 in main /tmp/with space/leak.cpp:17:10 + #3 0x7fe37e235ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 +`, + `Direct leak of 32 byte(s) in 1 object(s) allocated from: + #0 0x55cb5bcc04f8 in malloc (/tmp/with space/leak+0xde4f8) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) + #1 0x55cb5bd04d05 in app::List::makeLabel() const /tmp/with space/leak.cpp:10:56 + #2 0x55cb5bd04c6d in main /tmp/with space/leak.cpp:18:10 + #3 0x7fe37e235ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 +`, + `Direct leak of 16 byte(s) in 1 object(s) allocated from: + #0 0x55cb5bcc04f8 in malloc (/tmp/with space/leak+0xde4f8) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) + #1 0x55cb5bd04cdf in app::List::makePair() const /tmp/with space/leak.cpp:8:9 + #2 0x55cb5bd04c64 in main /tmp/with space/leak.cpp:17:10 + #3 0x7fe37e235ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 + +SUMMARY: AddressSanitizer: 112 byte(s) leaked in 3 allocation(s). +`, +]; + +testAnywhere("the records of one function stay together", () => { + const [first, between, last] = recordsOfTwoFunctions; + const report = ` +================================================================= +==23888==ERROR: LeakSanitizer: detected memory leaks + +${recordsOfTwoFunctions.join("\n")}`; + expect(getLeakRecords(report)).toEqual( + new Map([ + ["app::List::makePair", first + last], + ["app::List::makeLabel", between], + ]), + ); +}); + +// Leak records as a symbolizer wrote them, cut down to the frames that decide the name. The first two are one record of a Bun +// built for release: of `bun run`, with debug information, and of an executable, without. +const writtenRecords: [what: string, allocatedBy: string, record: string][] = [ + [ + "functions were inlined and have frames", + "WebCore::eventNames", + `Direct leak of 416 byte(s) in 4 object(s) allocated from: + #0 0x00000662ddc8 in malloc (/tmp/lane-asan/bun-asan-stripped+0x662ddc8) (BuildId: 39e2ae531a0ca5a60c21898cc251d33ea7fbc7f1) + #1 0x00001ab9da8f in bmalloc::SystemHeap::malloc(unsigned long, bmalloc::FailureAction) /webkitbuild/vendor/WebKit/Source/bmalloc/bmalloc/SystemHeap.cpp:170:20 + #2 0x00001ab9da8f in pas_system_heap_malloc_untagged /webkitbuild/vendor/WebKit/Source/bmalloc/bmalloc/SystemHeap.cpp:319:24 + #8 0x0000114869e9 in WTF::fastMalloc(unsigned long) /webkitbuild/vendor/WebKit/Source/WTF/wtf/FastMalloc.cpp:345:20 + #9 0x000006fb77b7 in WebCore::EventNames::operator new(unsigned long) /workspace/bun/build/release-asan/unified/../../../src/jsc/bindings/webcore/EventNames.h:46:5 + #10 0x000006fb77b7 in std::unique_ptr> WebCore::EventNames::create<>() /workspace/bun/build/release-asan/unified/../../../src/jsc/bindings/webcore/EventNames.h:64:44 + #11 0x000006fb77b7 in WebCore::eventNames() /workspace/bun/build/release-asan/unified/../../../src/jsc/bindings/webcore/EventNames.cpp:42:23 + #12 0x000007a99bfb in Zig::setGlobalOnMessage(JSC::JSGlobalObject*, long, long, JSC::PropertyName) /workspace/bun/build/release-asan/../../src/jsc/bindings/ZigGlobalObject.cpp:1531:74 +`, + ], + [ + "functions were inlined and have no frames", + "WebCore::eventNames", + `Direct leak of 416 byte(s) in 4 object(s) allocated from: + #0 0x00000662ddc8 in malloc (/tmp/lane-asan/cap/workers/program+0x662ddc8) (BuildId: 39e2ae531a0ca5a60c21898cc251d33eae107c53) + #1 0x00001ab9da8f in pas_system_heap_malloc_untagged (/tmp/lane-asan/cap/workers/program+0x1ab9da8f) (BuildId: 39e2ae531a0ca5a60c21898cc251d33eae107c53) + #4 0x0000114869e9 in WTF::fastMalloc(unsigned long) (/tmp/lane-asan/cap/workers/program+0x114869e9) (BuildId: 39e2ae531a0ca5a60c21898cc251d33eae107c53) + #5 0x000006fb77b7 in WebCore::eventNames() (/tmp/lane-asan/cap/workers/program+0x6fb77b7) (BuildId: 39e2ae531a0ca5a60c21898cc251d33eae107c53) + #6 0x000007a99bfb in Zig::setGlobalOnMessage(JSC::JSGlobalObject*, long, long, JSC::PropertyName) (/tmp/lane-asan/cap/workers/program+0x7a99bfb) (BuildId: 39e2ae531a0ca5a60c21898cc251d33eae107c53) +`, + ], + [ + "an allocator of bmalloc and one of a class were called, as in a debug build", + "Zig::GlobalObject::setConsole", + `Indirect leak of 40 byte(s) in 1 object(s) allocated from: + #0 0x00000dbec2a8 in malloc (/tmp/lane/p4/program+0xdbec2a8) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) + #1 0x00002f2494fb in bmalloc::SystemHeap::malloc(unsigned long, bmalloc::FailureAction) (/tmp/lane/p4/program+0x2f2494fb) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) + #2 0x00002f24a21a in pas_system_heap_malloc_untagged (/tmp/lane/p4/program+0x2f24a21a) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) + #8 0x0000243df418 in WTF::fastMalloc(unsigned long) (/tmp/lane/p4/program+0x243df418) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) + #9 0x0000100c84f4 in Bun::ConsoleObject::operator new(unsigned long) (/tmp/lane/p4/program+0x100c84f4) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) + #10 0x0000100af03f in Zig::GlobalObject::setConsole(void*) (/tmp/lane/p4/program+0x100af03f) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) +`, + ], + [ + "a container has a constraint", + "Zig::GlobalObject::setConsole", + `Indirect leak of 32 byte(s) in 1 object(s) allocated from: + #7 0x000011486f50 in WTF::fastCompactMalloc(unsigned long) (/tmp/lane-asan/cap/line/program+0x11486f50) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) + #8 0x000007ad78cb in WTF::WeakPtrFactory::initializeIfNeeded(JSC::ConsoleClient const&) const (/tmp/lane-asan/cap/line/program+0x7ad78cb) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) + #9 0x000007a992c7 in WTF::WeakPtr>::WeakPtr(JSC::ConsoleClient const*, WTF::EnableWeakPtrThreadingAssertions) requires !IsSmartPtr::value (/tmp/lane-asan/cap/line/program+0x7a992c7) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) + #10 0x000007a838d9 in Zig::GlobalObject::setConsole(void*) (/tmp/lane-asan/cap/line/program+0x7a838d9) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) +`, + ], + ["a file is in a folder with a space in its name", "app::List::makePair", recordsOfTwoFunctions[0]], + [ + "an executable is in a folder with a space in its name", + "app::List::makePair", + `Direct leak of 64 byte(s) in 1 object(s) allocated from: + #0 0x55894de5c4f8 in malloc (/tmp/with space/leak-stripped+0xde4f8) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) + #1 0x55894dea0cd5 in app::List::makePair() const (/tmp/with space/leak-stripped+0x122cd5) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) + #2 0x55894dea0c64 in main (/tmp/with space/leak-stripped+0x122c64) (BuildId: afca4ac47c124a34d173acca8497565626d6c484) +`, + ], + [ + "the path of a file does not start at the root", + "duplocale", + `Direct leak of 232 byte(s) in 1 object(s) allocated from: + #0 0x55810dcf64e8 in malloc (/tmp/rel/locale+0xdd4e8) (BuildId: 7e949b90a30865a9828466a03b83560269adca36) + #1 0x7f195d66ff89 in duplocale locale/duplocale.c:57:12 +`, + ], + [ + "the function is a member for what is const and volatile", + "app::Text::peek", + `Direct leak of 32 byte(s) in 1 object(s) allocated from: + #0 0x55b8dc5954f8 in malloc (/tmp/quals/text+0xde4f8) (BuildId: b70713946b9f6a9f254a1a73de5b0ee8189e4a16) + #1 0x55b8dc5d9d45 in app::Text::peek() const volatile /tmp/quals/text.cpp:7:87 + #2 0x55b8dc5d9c96 in main /tmp/quals/text.cpp:16:10 +`, + ], + [ + "the function is a member for rvalues", + "app::Text::take", + `Direct leak of 24 byte(s) in 1 object(s) allocated from: + #0 0x55b8dc5954f8 in malloc (/tmp/quals/text+0xde4f8) (BuildId: b70713946b9f6a9f254a1a73de5b0ee8189e4a16) + #1 0x55b8dc5d9d25 in app::Text::take() && /tmp/quals/text.cpp:6:75 + #2 0x55b8dc5d9c82 in main /tmp/quals/text.cpp:15:17 +`, + ], + [ + "the function is a member for lvalues that are const", + "app::Text::copy", + `Direct leak of 16 byte(s) in 1 object(s) allocated from: + #0 0x55b8dc5954f8 in malloc (/tmp/quals/text+0xde4f8) (BuildId: b70713946b9f6a9f254a1a73de5b0ee8189e4a16) + #1 0x55b8dc5d9d05 in app::Text::copy() const & /tmp/quals/text.cpp:5:80 + #2 0x55b8dc5d9c6e in main /tmp/quals/text.cpp:14:10 +`, + ], + [ + "the function converts to a type of two words", + "app::Id::operator unsigned long", + `Direct leak of 24 byte(s) in 1 object(s) allocated from: + #0 0x55f0dd7f34f8 in malloc (/tmp/conv/id+0xde4f8) (BuildId: caab9c535e49b890b36b56634b306bec21bc68af) + #1 0x55f0dd837ce5 in app::Id::operator unsigned long() const /tmp/conv/id.cpp:5:103 + #2 0x55f0dd837c60 in main /tmp/conv/id.cpp:11:39 +`, + ], + [ + "the function is written in a member for what is const", + "app::Text::measure::$_0::operator", + `Direct leak of 48 byte(s) in 1 object(s) allocated from: + #0 0x55af84abd4f8 in malloc (/tmp/local/text+0xde4f8) (BuildId: 5e5e7a9e662d967fc3f52f2336c5ba7e5708fbac) + #1 0x55af84b01cd8 in app::Text::measure() const::$_0::operator()(unsigned long) const /tmp/local/text.cpp:10:92 + #2 0x55af84b01c62 in app::Text::measure() const /tmp/local/text.cpp:11:10 +`, + ], + [ + "the function is written in one that has a constraint", + "app::fill::'lambda'::operator", + `Direct leak of 49 byte(s) in 1 object(s) allocated from: + #0 0x55bea8aba4f8 in malloc (/tmp/constrained/fill+0xde4f8) (BuildId: 31f08aa8b9ad162b9fef3596516bb8665727d648) + #1 0x55bea8aff068 in char* app::fill(int) requires std::integral::'lambda'(unsigned long)::operator()(unsigned long) const /tmp/constrained/fill.cpp:11:92 + #2 0x55bea8afee42 in char* app::fill(int) requires std::integral /tmp/constrained/fill.cpp:14:10 +`, + ], + [ + "inlined functions of Rust have their last word for a name", + ">::init", + `Direct leak of 3080704 byte(s) in 2 object(s) allocated from: + #0 0x000006a15ac8 in malloc crtstuff.c + #1 0x00000ccd40ac in alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:31:18 + #4 0x00000ccd40ac in ::alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/alloc/global.rs:361:20 + #15 0x00000bf87591 in request_pool /workspace/bun/src/runtime/server/mod.rs:3516:22 + #16 0x00000bf87591 in >::init /workspace/bun/src/runtime/server/mod.rs:2205:27 + #17 0x00000c7501f9 in serve /workspace/bun/src/runtime/api/BunObject.rs:1142:26 +`, + ], + [ + "the allocator of Rust was called, as in a debug build", + ", false, true, false>, 2048>>::new_boxed", + `Direct leak of 1540352 byte(s) in 1 object(s) allocated from: + #0 0x00000e3f43a8 in malloc crtstuff.c + #4 0x0000167814a2 in ::alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/alloc/global.rs:361:20 + #5 0x00001a0f3ec0 in __rustc::__rust_alloc /workspace/bun/src/runtime/bin_entry/mod.rs:53:15 + #6 0x000016779f77 in alloc::alloc::alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:131:9 + #10 0x000019af2e77 in alloc::boxed::box_new_uninit /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:251:18 + #12 0x0000191a2836 in , false, true, false>, 2048>>::new_boxed /workspace/bun/src/collections/hive_array.rs:594:25 + #13 0x000019d0318c in as bun_runtime::server::ServerPools>::request_pool::{closure#0} /workspace/bun/src/runtime/server/mod.rs:3522:29 +`, + ], + [ + "a hash table of Rust grew", + "::remember", + `Direct leak of 1072 byte(s) in 1 object(s) allocated from: + #0 0x0000002e7664 in malloc (/tmp/rust-app/target/x86_64-unknown-linux-gnu/release/app+0x2e7664) (BuildId: 34af1d1e14c722d4d62bc837dd2ac23a882020f1) + #2 0x00000033828a in __rustc::__rdl_alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/alloc.rs:472:13 + #12 0x000000315b4f in reserve_rehash_inner /opt/rust/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.17.1/src/raw.rs:2784:22 + #13 0x000000315b4f in >::reserve_rehash::::{closure#0}> /opt/rust/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.17.1/src/raw.rs:962:24 + #17 0x0000003178a2 in insert /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/collections/hash/map.rs:1333:19 + #18 0x0000003178a2 in ::remember /tmp/rust-app/src/main.rs:14:18 + #19 0x000000316fd7 in app::leak /tmp/rust-app/src/main.rs:33:15 +`, + ], + [ + "a small vector of Rust copied a slice, as in a debug build", + "::copy", + `Direct leak of 32 byte(s) in 1 object(s) allocated from: + #0 0x0000003a0644 in malloc (/tmp/rust-app/target/x86_64-unknown-linux-gnu/debug/app+0x3a0644) (BuildId: 6253325cdff4c53eff796a61540cc055eb0492fd) + #2 0x0000004f2471 in __rustc::__rdl_alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/alloc.rs:472:13 + #10 0x0000003d2503 in >::with_capacity_in /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:973:20 + #11 0x0000003d1a4f in ::to_vec_in::ConvertVec>::to_vec:: /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/slice.rs:448:29 + #13 0x0000003d1b8b in <[u32]>::to_vec /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/slice.rs:376:14 + #14 0x0000003da83e in >::from_slice /opt/rust/registry/src/index.crates.io-1949cf8c6b5b557f/smallvec-1.15.1/src/lib.rs:1759:31 + #15 0x0000003de114 in ::copy /tmp/rust-app/src/main.rs:25:25 + #16 0x0000003ddc89 in app::leak /tmp/rust-app/src/main.rs:35:17 +`, + ], + [ + "a function of Rust has a function pointer among its arguments", + "app::collect_with::", + `Direct leak of 37 byte(s) in 1 object(s) allocated from: + #0 0x0000002e4a04 in malloc (/tmp/rust-methods/target/x86_64-unknown-linux-gnu/release/app+0x2e4a04) (BuildId: 5f553fe5966a1991bd5def08e2a0d660400714a5) + #2 0x0000003358ba in __rustc::__rdl_alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/alloc.rs:472:13 + #11 0x000000312d4c in with_capacity /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:525:9 + #12 0x000000312d4c in app::collect_with:: u8> /tmp/rust-methods/src/main.rs:57:23 + #13 0x00000031373a in app::leak_collected /tmp/rust-methods/src/main.rs:166:22 +`, + ], +]; + +testAnywhere.each(writtenRecords)( + "a record has the name of the function that allocated where %s", + (_, allocatedBy, record) => { + expect(getAllocatedBy(record)).toBe(allocatedBy); + }, +); + +// Methods of Rust as a symbolizer wrote them: for a small program, `app`, and the last two for a debug build of Bun. The crate +// has the code of the first ones. The others allocate for the function that called them: the language has their code, and +// names a type or a closure of the crate in them, or they are what a type does as an allocator. +const methodsOfACrate = [ + "<[u8] as app::Checksum>::sums", + " as app::Checksum>::sums", + " u8> as app::Checksum>::sums", + "<&app::Names as core::iter::traits::collect::IntoIterator>::into_iter", + "<&mut app::fill::{closure#0} as core::ops::function::FnMut<(usize,)>>::call_mut", + "::describe", + "<::later::{closure#0} as core::ops::function::FnOnce<()>>::call_once::{shim:vtable#0}", +]; +const methodsOfAllocatorsAndContainers = [ + "<[app::Thing]>::to_vec", + "::to_vec_in::ConvertVec>::to_vec::", + "> as alloc::vec::spec_extend::SpecExtend, core::iter::adapters::map::Map, <[u8] as app::Checksum>::parts::{closure#0}>>>::spec_extend", + "::allocate", + "::allocate", +]; + +testAnywhere("a method of Rust is an allocator's or a container's where no other crate has its code", () => { + expect(methodsOfACrate.filter(isAllocator)).toEqual([]); + expect(methodsOfAllocatorsAndContainers.filter(method => !isAllocator(method))).toEqual([]); +}); + test.skipIf(!isASAN).each(Object.keys(programs))( "%s", async name => { From 512f58071c6453903c8abe6c8786379a4f99d92c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:14:13 +0000 Subject: [PATCH 2/2] test(aot): give a stable name to more leak records - Take `test` for the tests of written text from `./harness/default-timeout`. The guard in `the-default-time-limit-never-lowers-what-the-runner-gave.test.ts` lists a file that imports `test` from `bun:test` and has a time limit. - A record whose stack ends in allocators and containers has the name of the last function on it. Its name came from the text of the record, which is different with and without debug information. - A record that names no function keeps its text for a name, so that it is like no other record. - An operator that is written with `<` or `>` keeps its signs. `operator>` had a piece of its arguments for a name. - An operator for a literal keeps its whole name, so those of WTF are containers. - A function of Rust with generic arguments has no `::` at its end. - `bun_alloc` and the `OPENSSL_` functions are allocators: BoringSSL asks Bun for its memory. --- test/aot/no-leak-records-at-exit.test.ts | 120 ++++++++++++++++++++--- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/test/aot/no-leak-records-at-exit.test.ts b/test/aot/no-leak-records-at-exit.test.ts index f8073b5915fb..36f0850867e4 100644 --- a/test/aot/no-leak-records-at-exit.test.ts +++ b/test/aot/no-leak-records-at-exit.test.ts @@ -1,7 +1,8 @@ -import { expect, test as testAnywhere } from "bun:test"; +import { expect } from "bun:test"; import { bunEnv, bunExe, isASAN, tempDir } from "harness"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { test as testAnywhere } from "./harness/default-timeout"; import { run as runCommand } from "./harness/run"; import { test } from "./supported"; @@ -110,6 +111,9 @@ const envWithSystemMalloc = { ...env, Malloc: "1", LSAN_OPTIONS: "malloc_context /** Without the line that names the port of a default export that is served. */ const getLines = (stdout: string) => stdout.replace(/^Started (development )?server: .*\n/gm, ""); +// An operator that is written with `<` or `>`, up to where its arguments begin: with `(`, or with `<` for those of a template. +const operatorWithAngles = /\boperator(?:<=>|<<=?|>>=?|[<>]=?)(?=[(<])/y; + /** * The name of a function, without its return type, its arguments and what follows them. A function that is written in another * has that one's name in front: the name of `Text::measure() const::$_0::operator()() const` is `Text::measure::$_0::operator`. @@ -119,24 +123,32 @@ function getFunctionName(frame: string): string { let depth = 0; let name = ""; for (let index = 0; index < frame.length; index++) { - if (frame[index] === "<" || frame[index] === "(") depth++; + operatorWithAngles.lastIndex = index; + const operator = operatorWithAngles.exec(frame)?.[0]; + // The signs of an operator open and close nothing: `operator>`, `operator<<`. + if (operator) { + if (depth === 0) name += operator; + index += operator.length - 1; + } else if (frame[index] === "<" || frame[index] === "(") depth++; // The `>` of `->` closes nothing: `fn(u8) -> u8`. else if ((frame[index] === ">" && frame[index - 1] !== "-") || frame[index] === ")") depth--; else if (depth === 0) name += frame[index]; } const words = name // The constraint of a function ends where a lambda that is written in the function begins. - .replace(/ requires .*?(?=::['$]|$)/, "") + .replace(/ requires .*?(?=::'|$)/, "") .replace(/(?: (?:const|volatile|&&?))+(?=::|$)/g, "") .trim() + // The arguments of a function of Rust stood behind `::`: `collect::`. + .replace(/::$/, "") .split(" "); - // `operator new`, and a conversion to a type of several words: `operator unsigned long`. - const operator = words.findIndex(word => /(?:^|::)operator$/.test(word)); + // `operator new`, `operator"" _s`, and a conversion to a type of several words: `operator unsigned long`. + const operator = words.findIndex(word => /(?:^|::)operator(?:"")?$/.test(word)); return words.slice(operator < 0 ? -1 : operator).join(" "); } // The namespaces and the crates of allocators and containers: of what allocates for the function that called it. -const containers = /^(WTF|bmalloc|std|alloc|core|hashbrown|smallvec|__rustc)$/; +const containers = /^(WTF|bmalloc|std|alloc|core|hashbrown|smallvec|__rustc|bun_alloc)$/; /** * The crate that has the code of a method of Rust, which a symbolizer writes `::method` or `::method`. @@ -169,7 +181,8 @@ function getCrateOfMethod(method: string): string { /** Whether the function of a frame is an allocator or a container. The namespace or the crate of its code says it. */ function isAllocator(frame: string): boolean { const name = getFunctionName(frame); - if (/^(malloc|calloc|realloc|posix_memalign|aligned_alloc|pas_|bmalloc_|__rust_|__rdl_)/.test(name)) return true; + if (/^(malloc|calloc|realloc|posix_memalign|aligned_alloc|pas_|bmalloc_|__rust_|__rdl_|OPENSSL_)/.test(name)) + return true; if (/\boperator new(\[\])?$/.test(name)) return true; return containers.test(name.startsWith("<") ? getCrateOfMethod(name) : (/^(\w+)::/.exec(name)?.[1] ?? "")); } @@ -198,9 +211,15 @@ function getCalledFunctions(record: string): string[] { return functions; } -/** The first function that was called on the stack of a leak record that is neither an allocator nor a container. */ -const getAllocatedBy = (record: string) => - getFunctionName(getCalledFunctions(record).find(frame => !isAllocator(frame)) ?? record); +/** + * The first function that was called on the stack of a leak record that is neither an allocator nor a container. Where the + * stack ends before one, the last function on it. A record that no symbolizer wrote names no function, and is like no other. + */ +function getAllocatedBy(record: string): string { + const functions = getCalledFunctions(record); + const allocatedBy = functions.find(frame => !isAllocator(frame)) ?? functions.at(-1); + return allocatedBy === undefined ? record : getFunctionName(allocatedBy); +} /** The leak records of a report, by the function that allocated. */ function getLeakRecords(stderr: string): Map { @@ -297,6 +316,18 @@ const writtenRecords: [what: string, allocatedBy: string, record: string][] = [ #8 0x0000243df418 in WTF::fastMalloc(unsigned long) (/tmp/lane/p4/program+0x243df418) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) #9 0x0000100c84f4 in Bun::ConsoleObject::operator new(unsigned long) (/tmp/lane/p4/program+0x100c84f4) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) #10 0x0000100af03f in Zig::GlobalObject::setConsole(void*) (/tmp/lane/p4/program+0x100af03f) (BuildId: 69fe649e153fc7867a6c4e30ba315fc6330868fb) +`, + ], + [ + "BoringSSL asked Bun for the memory, as in a debug build", + "BUF_MEM_reserve", + `Indirect leak of 404 byte(s) in 1 object(s) allocated from: + #0 0x0000082769a8 in malloc crtstuff.c + #1 0x00001038cdb0 in bun_alloc::default_alloc::malloc /workspace/bun/src/bun_alloc/lib.rs:199:22 + #2 0x000010385e20 in OPENSSL_memory_alloc /workspace/bun/src/boringssl/lib.rs:115:5 + #3 0x00000bf7c38d in OPENSSL_malloc /workspace/bun/build/debug/../../vendor/boringssl/crypto/mem.cc:212:18 + #4 0x00000bf7c64f in OPENSSL_realloc /workspace/bun/build/debug/../../vendor/boringssl/crypto/mem.cc:293:12 + #5 0x00000be8d686 in BUF_MEM_reserve /workspace/bun/build/debug/../../vendor/boringssl/crypto/buf/buf.cc:56:32 `, ], [ @@ -307,6 +338,15 @@ const writtenRecords: [what: string, allocatedBy: string, record: string][] = [ #8 0x000007ad78cb in WTF::WeakPtrFactory::initializeIfNeeded(JSC::ConsoleClient const&) const (/tmp/lane-asan/cap/line/program+0x7ad78cb) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) #9 0x000007a992c7 in WTF::WeakPtr>::WeakPtr(JSC::ConsoleClient const*, WTF::EnableWeakPtrThreadingAssertions) requires !IsSmartPtr::value (/tmp/lane-asan/cap/line/program+0x7a992c7) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) #10 0x000007a838d9 in Zig::GlobalObject::setConsole(void*) (/tmp/lane-asan/cap/line/program+0x7a838d9) (BuildId: 39e2ae531a0ca5a60c21898cc251d33e44d419e4) +`, + ], + [ + "the allocator is `operator new`", + "app::fill", + `Direct leak of 24 byte(s) in 1 object(s) allocated from: + #0 0x5610ff066781 in operator new(unsigned long) (/tmp/cut/cut+0x121781) (BuildId: b27b2079b3e3aa84bb4ca3bf718851ea48582610) + #1 0x5610ff067be1 in app::fill() /tmp/cut/cut.cpp:12:20 + #2 0x5610ff067d83 in main /tmp/cut/cut.cpp:24:5 `, ], ["a file is in a folder with a space in its name", "app::List::makePair", recordsOfTwoFunctions[0]], @@ -435,7 +475,7 @@ const writtenRecords: [what: string, allocatedBy: string, record: string][] = [ ], [ "a function of Rust has a function pointer among its arguments", - "app::collect_with::", + "app::collect_with", `Direct leak of 37 byte(s) in 1 object(s) allocated from: #0 0x0000002e4a04 in malloc (/tmp/rust-methods/target/x86_64-unknown-linux-gnu/release/app+0x2e4a04) (BuildId: 5f553fe5966a1991bd5def08e2a0d660400714a5) #2 0x0000003358ba in __rustc::__rdl_alloc /opt/rust/toolchains/nightly-2026-09-15-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/alloc.rs:472:13 @@ -453,6 +493,64 @@ testAnywhere.each(writtenRecords)( }, ); +// One record of a Bun built for debugging, cut down: with the debug information, and without. LeakSanitizer kept thirty +// functions of its stack, and all of them are allocators and containers. +const recordsOfAStackThatEndsInContainers = [ + `Indirect leak of 724 byte(s) in 24 object(s) allocated from: + #0 0x0000082769a8 in malloc crtstuff.c + #1 0x00001870f27b in bmalloc::SystemHeap::malloc(unsigned long, bmalloc::FailureAction) /webkitbuild/vendor/WebKit/Source/bmalloc/bmalloc/SystemHeap.cpp:170:20 + #15 0x000012a27e7c in WTF::fastCompactMalloc(unsigned long) /webkitbuild/vendor/WebKit/Source/WTF/wtf/FastMalloc.cpp:495:20 + #26 0x000012fd96e3 in WTF::AtomStringImpl::add(std::span) /webkitbuild/vendor/WebKit/Source/WTF/wtf/text/AtomStringImpl.cpp:298:12 + #30 0x0000091e6c05 in WTF::AtomString WTF::makeAtomString(WTF::ASCIILiteral...) /root/.bun/build-cache/webkit-000c48997255272c-debug-asan/include/wtf/text/MakeString.h:156:19 +`, + `Indirect leak of 724 byte(s) in 24 object(s) allocated from: + #0 0x0000082769a8 in malloc (/tmp/olddebug/bun-debug-stripped+0x82769a8) (BuildId: e677207dab6959e8cd9ec489999ab35d3c180510) + #1 0x00001870f27b in bmalloc::SystemHeap::malloc(unsigned long, bmalloc::FailureAction) (/tmp/olddebug/bun-debug-stripped+0x1870f27b) (BuildId: e677207dab6959e8cd9ec489999ab35d3c180510) + #14 0x000012a27e7c in WTF::fastCompactMalloc(unsigned long) (/tmp/olddebug/bun-debug-stripped+0x12a27e7c) (BuildId: e677207dab6959e8cd9ec489999ab35d3c180510) + #25 0x000012fd96e3 in WTF::AtomStringImpl::add(std::span) (/tmp/olddebug/bun-debug-stripped+0x12fd96e3) (BuildId: e677207dab6959e8cd9ec489999ab35d3c180510) + #29 0x0000091e6c05 in WTF::AtomString WTF::makeAtomString(WTF::ASCIILiteral...) (/tmp/olddebug/bun-debug-stripped+0x91e6c05) (BuildId: e677207dab6959e8cd9ec489999ab35d3c180510) +`, +]; + +testAnywhere("a record whose stack ends in the containers has the name of the last function on it", () => { + expect(recordsOfAStackThatEndsInContainers.map(getAllocatedBy)).toEqual([ + "WTF::makeAtomString", + "WTF::makeAtomString", + ]); +}); + +// A record of a small program that ran with `symbolize=0`. +const recordWithoutFunctions = `Direct leak of 24 byte(s) in 1 object(s) allocated from: + #0 0x55a2c9ab3781 (/tmp/cut/cut-stripped+0x121781) (BuildId: b27b2079b3e3aa84bb4ca3bf718851ea48582610) + #1 0x55a2c9ab4be1 (/tmp/cut/cut-stripped+0x122be1) (BuildId: b27b2079b3e3aa84bb4ca3bf718851ea48582610) + #2 0x55a2c9ab4d83 (/tmp/cut/cut-stripped+0x122d83) (BuildId: b27b2079b3e3aa84bb4ca3bf718851ea48582610) +`; + +testAnywhere("a record that names no function is like no other record", () => { + expect(getAllocatedBy(recordWithoutFunctions)).toBe(recordWithoutFunctions); +}); + +// Functions of small programs as a symbolizer wrote them on the stacks of leak records, and their names. The arguments of a +// template stand right behind the signs of an operator. The last three are not written with `<` or `>`: an operator for a +// literal, a template that has a lambda of `operator>` among its arguments, and a function whose name ends in `operator`. +const namesOfFunctions: Record = { + "app::Version::operator>(app::Version const&) const": "app::Version::operator>", + "app::Version::operator>=(app::Version const&) const": "app::Version::operator>=", + "app::Version::operator>>=(int)": "app::Version::operator>>=", + "app::Version::operator<<=(int)": "app::Version::operator<<=", + "app::Version::operator<=>(app::Version const&) const": "app::Version::operator<=>", + "bool app::operator<(int const&, app::Version const&)": "app::operator<", + "int app::operator<<(int const&, app::Version const&)": "app::operator<<", + 'app::operator"" _leak(unsigned long long)': 'app::operator"" _leak', + "int app::apply(app::Version const&) const::'lambda'(unsigned long)>(app::Version::operator>(app::Version const&) const::'lambda'(unsigned long)&&)::'lambda'(unsigned long)::operator()(unsigned long) const": + "app::apply::'lambda'::operator", + "int app::cooperator<(app::Mode)1>()": "app::cooperator", +}; + +testAnywhere("the signs of an operator are in its name, and open and close nothing", () => { + expect(Object.keys(namesOfFunctions).map(getFunctionName)).toEqual(Object.values(namesOfFunctions)); +}); + // Methods of Rust as a symbolizer wrote them: for a small program, `app`, and the last two for a debug build of Bun. The crate // has the code of the first ones. The others allocate for the function that called them: the language has their code, and // names a type or a closure of the crate in them, or they are what a type does as an allocator.