From cffec97a0b51b6234eb42467e8c0eb5bf9aade4f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:17:03 +0000 Subject: [PATCH 1/5] fetch: reject file: URLs whose path cannot be read fetch("file:///missing") resolved with a 200 Response wrapping a lazily opened file blob, so ENOENT (or EISDIR for a directory) only surfaced once the body was read. Stat the path in the file: branch and reject the fetch() promise with the system error instead. Byte-backed blobs (files embedded in a standalone executable) are left alone. The url string is now created after the check so the early return has no +1 ref to release; the leak test for it fetches a file that exists since a missing one no longer reaches that code. --- src/runtime/webcore/fetch.rs | 50 +++++++++++++++++++++-- test/bundler/bundler_compile.test.ts | 15 +++++++ test/js/web/fetch/fetch-leak.test.ts | 43 ++++++++++++-------- test/js/web/fetch/fetch.test.ts | 60 ++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 2a41b66e2cc4..95689130d7a5 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -224,6 +224,42 @@ fn data_url_response(data_url_: DataURL, global_this: &JSGlobalObject) -> JSValu ) } +// ────────────────────────────────────────────────────────────────────────── +// file: URLs +// ────────────────────────────────────────────────────────────────────────── + +/// The `Response` for a `file:` URL wraps a blob that opens the file lazily, so +/// `fetch()` itself has to check the path; otherwise a path that cannot be read +/// still gets a 200 and the error only surfaces from the body reader. Returns +/// the JS error for `fetch()` to reject with. +/// +/// Only conditions under which the read is certain to fail are checked (the +/// path does not stat, or is a directory). `stat` rather than an eager `open`: +/// opening a FIFO or a device has side effects, and nothing here needs the fd. +/// Files embedded in a standalone executable come back as byte-backed blobs, +/// which have nothing on disk to check. +fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> Option { + let store = file_blob.store()?; + let blob::store::Data::File(file) = &store.data else { + return None; + }; + let PathOrFileDescriptor::Path(path) = &file.pathlike else { + return None; + }; + + let mut path_buf = bun_paths::path_buffer_pool::get(); + let err = match bun_sys::stat(path.slice_z(&mut path_buf)) { + Ok(stat) if bun_sys::S::ISDIR(stat.st_mode as bun_sys::Mode) => { + bun_sys::Error::from_code(bun_sys::E::EISDIR, bun_sys::Tag::read) + } + Ok(_) => return None, + Err(err) => err, + }; + // `stat` attached the scratch copy of the path (`\\?\`-prefixed on + // Windows); report the path as the blob holds it. + Some(err.with_path(path.slice()).to_js(global_this)) +} + // ────────────────────────────────────────────────────────────────────────── // Bun__fetchPreconnect // ────────────────────────────────────────────────────────────────────────── @@ -1553,8 +1589,6 @@ fn fetch_impl( } }; - url_string = jsc::URL::file_url_from_string(BunString::borrow_utf8(temp_file_path)); - // `find_or_create_file_from_path` is typed against the // `crate::webcore::node_types` stub (until it's swapped to a // re-export of `crate::node::types`); construct that variant here. @@ -1564,7 +1598,17 @@ fn fetch_impl( )), ); - break 'blob Blob::find_or_create_file_from_path(&mut pathlike, global_this, true); + let file_blob = Blob::find_or_create_file_from_path(&mut pathlike, global_this, true); + + if let Some(err) = file_url_unreadable_error(&file_blob, global_this) { + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); + } + + // A bare +1 ref that only `Response::init` below releases, so it is + // created after the early return above. + url_string = jsc::URL::file_url_from_string(BunString::borrow_utf8(temp_file_path)); + + break 'blob file_blob; }; let response = bun_core::heap::into_raw(Box::new(Response::init( diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index cdc71b4a9eeb..9d991af28fa5 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -316,6 +316,21 @@ describe("bundler", () => { outfile: "dist/out", run: { stdout: "Hello, world!" }, }); + // fetch() rejects a file: URL whose path is not on disk. An embedded file + // only exists inside the executable, so it has to keep resolving. + itBundled("compile/FetchFileURLOfEmbeddedFile", { + compile: true, + files: { + "/entry.ts": /* js */ ` + import { pathToFileURL } from "node:url"; + import embedded from './foo.file' with {type: "file"}; + const response = await fetch(pathToFileURL(embedded)); + console.log(response.status, (await response.text()).trim()); + `, + "/foo.file": `abcd`, + }, + run: { stdout: "200 abcd" }, + }); itBundled("compile/WorkerRelativePathNoExtension", { backend: "cli", compile: true, diff --git a/test/js/web/fetch/fetch-leak.test.ts b/test/js/web/fetch/fetch-leak.test.ts index 5c9a9cf290d1..d21e41ec481b 100644 --- a/test/js/web/fetch/fetch-leak.test.ts +++ b/test/js/web/fetch/fetch-leak.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tls as COMMON_CERT, gc, isASAN, isCI, isDebug } from "harness"; +import { bunEnv, bunExe, tls as COMMON_CERT, gc, isASAN, isCI, isDebug, tempDir } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; import net from "node:net"; @@ -526,28 +526,36 @@ test.concurrent( async () => { // The leaked impl is "file://", and fetch_impl decodes // url.path into a stack PathBuffer that is 1024 bytes on macOS/BSD, 4096 on - // Linux, ~98 KiB on Windows. Use a ~900-byte path so decode_into succeeds on - // every platform and url_string is actually assigned, with enough iterations - // for the small per-call leak to show in RSS. + // Linux, ~98 KiB on Windows. Use a ~850-byte path so decode_into succeeds on + // every platform, with enough iterations for the small per-call leak to + // show in RSS. The file has to exist: fetch() rejects for a path that does + // not stat before url_string is ever created. + using dir = tempDir("fetch-file-url-leak", {}); const script = /* js */ ` - const pad = Buffer.alloc(900, "a").toString(); - // Windows strips the leading "/" then asserts is_absolute_windows() in - // PosixToWinNormalizer under debug_assertions, which needs a drive letter. - const prefix = process.platform === "win32" ? "file:///C:/" : "file:///"; + import { mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { pathToFileURL } from "node:url"; + + const component = Buffer.alloc(200, "a").toString(); + const parent = join(process.cwd(), component, component, component, component); + mkdirSync(parent, { recursive: true }); + const file = join(parent, "file.txt"); + writeFileSync(file, "hello"); + const url = pathToFileURL(file).href; + const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; - async function hit(i) { - // Fresh path per iteration so each leaked ref pins a distinct impl. - // The file does not exist; the Response is created (with url_string set) - // and the lazy Blob body is never read, so no fs I/O happens. - await fetch(prefix + i + pad); + async function hit() { + // Every call builds a fresh url_string impl for the Response. The lazy + // Blob body is never read, so the Response is the only thing created. + await fetch(url); } - for (let i = 0; i < 200; i++) { try { await hit(-i); } catch {} } + for (let i = 0; i < 200; i++) await hit(); Bun.gc(true); const baseline = rss(); const ITERS = 20000; for (let i = 0; i < ITERS; i++) { - try { await hit(i); } catch {} + await hit(); if ((i & 1023) === 0) Bun.gc(true); } Bun.gc(true); @@ -559,8 +567,8 @@ test.concurrent( finalMB: (final / 1024 / 1024) | 0, deltaMB: Math.round(deltaMB * 10) / 10, })); - // ~0.9 KiB × 20000 ≈ 18 MiB raw leak (measured ~32 MiB on debug+ASAN) - // when the extra ref is dropped on the floor; ~12 MiB noise with the fix. + // ~0.85 KiB × 20000 ≈ 17 MiB raw leak (measured ~26 MiB on debug+ASAN) + // when the extra ref is dropped on the floor; ~11 MiB noise with the fix. if (deltaMB > 20) { throw new Error("fetch(file://) leaked " + deltaMB.toFixed(1) + " MB over " + ITERS + " iterations"); } @@ -568,6 +576,7 @@ test.concurrent( await using proc = Bun.spawn({ cmd: [bunExe(), "--smol", "-e", script], + cwd: String(dir), env: { ...bunEnv, ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":"), diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index c0b1327b1fc4..7cb64459ad56 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -16,6 +16,7 @@ import { isWindows, rss, runFixtureMaxRSS, + tempDir, tls, tmpdirSync, withoutAggressiveGC, @@ -27,6 +28,7 @@ import type { AddressInfo } from "net"; import net from "net"; import { join } from "path"; import { Readable } from "stream"; +import { pathToFileURL } from "url"; import { gzipSync } from "zlib"; const tmp_dir = tmpdirSync(); @@ -1789,6 +1791,64 @@ it("fetch() file:// works", async () => { expect(fileResponseText).toEqual(bunFileText); gc(true); }); + +describe.concurrent("fetch() file:// that cannot be read", () => { + // The file: branch keeps the URL's forward slashes in the path it reports, + // which only differs from path.join() on Windows. + const reportedPath = (p: string) => (isWindows ? p.replaceAll("\\", "/") : p); + + it("rejects with ENOENT when the file does not exist", async () => { + using dir = tempDir("fetch-file-url", { "exists.txt": "exists" }); + const missing = join(String(dir), "missing.txt"); + const url = pathToFileURL(missing); + + for (const input of [url.href, url, new Request(url)]) { + await expect(fetch(input)).rejects.toMatchObject({ + code: "ENOENT", + syscall: "stat", + path: reportedPath(missing), + }); + } + + // A sibling that does exist is unaffected. + const response = await fetch(pathToFileURL(join(String(dir), "exists.txt"))); + expect([response.status, await response.text()]).toEqual([200, "exists"]); + }); + + it("rejects with EISDIR when the path is a directory", async () => { + using dir = tempDir("fetch-file-url-dir", {}); + + await expect(fetch(pathToFileURL(String(dir)))).rejects.toMatchObject({ + code: "EISDIR", + syscall: "read", + path: reportedPath(String(dir)), + }); + }); + + it.skipIf(isWindows)("still resolves for special files such as /dev/null", async () => { + const response = await fetch("file:///dev/null"); + expect([response.status, await response.text()]).toEqual([200, ""]); + }); + + it("reports the rejection as unhandled when nothing catches it", async () => { + using dir = tempDir("fetch-file-url-unhandled", {}); + const missing = join(String(dir), "missing.txt"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `fetch(${JSON.stringify(pathToFileURL(missing).href)});`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("ENOENT"); + expect(stderr).toContain(reportedPath(missing)); + expect(exitCode).toBe(1); + }); +}); + it("cloned response headers are independent before accessing", () => { const response = new Response("hello", { headers: { From 8aa40d1d4e99638a31f2dafa0742a51399a66642 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:26:32 +0000 Subject: [PATCH 2/5] fetch: reject unreadable file: URLs as TypeError; pin the stat boundary in tests Materialize the rejection with to_type_error_instance so it has the same shape as every other fetch() rejection (ValueError::SystemTypeError), with the system error fields unchanged. Tests now assert the class on every rejection and cover the edges of the check: EACCES reported by stat on an unsearchable parent rejects, a mode 000 file still resolves and fails from the body read, and a FIFO with no writer resolves without being opened. --- src/runtime/webcore/fetch.rs | 6 ++- test/js/web/fetch/fetch.test.ts | 68 +++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 95689130d7a5..e80d37672e53 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -231,7 +231,8 @@ fn data_url_response(data_url_: DataURL, global_this: &JSGlobalObject) -> JSValu /// The `Response` for a `file:` URL wraps a blob that opens the file lazily, so /// `fetch()` itself has to check the path; otherwise a path that cannot be read /// still gets a 200 and the error only surfaces from the body reader. Returns -/// the JS error for `fetch()` to reject with. +/// the error for `fetch()` to reject with: the system error as a `TypeError`, +/// the shape every other `fetch()` rejection has (see `ValueError::SystemTypeError`). /// /// Only conditions under which the read is certain to fail are checked (the /// path does not stat, or is a directory). `stat` rather than an eager `open`: @@ -257,7 +258,8 @@ fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> }; // `stat` attached the scratch copy of the path (`\\?\`-prefixed on // Windows); report the path as the blob holds it. - Some(err.with_path(path.slice()).to_js(global_this)) + let system_error: jsc::SystemError = err.with_path(path.slice()).to_system_error().into(); + Some(system_error.to_type_error_instance(global_this)) } // ────────────────────────────────────────────────────────────────────────── diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 7cb64459ad56..5029fb749205 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -1793,17 +1793,31 @@ it("fetch() file:// works", async () => { }); describe.concurrent("fetch() file:// that cannot be read", () => { + const isRoot = !isWindows && process.getuid?.() === 0; // The file: branch keeps the URL's forward slashes in the path it reports, // which only differs from path.join() on Windows. const reportedPath = (p: string) => (isWindows ? p.replaceAll("\\", "/") : p); + async function rejection(input: string | URL | Request): Promise { + const error = await fetch(input).then( + () => { + throw new Error("fetch() resolved"); + }, + (error: unknown) => error, + ); + // Every fetch() failure is a TypeError (a network error in fetch spec + // terms); the system error fields ride along on it. + expect(error).toBeInstanceOf(TypeError); + return error; + } + it("rejects with ENOENT when the file does not exist", async () => { using dir = tempDir("fetch-file-url", { "exists.txt": "exists" }); const missing = join(String(dir), "missing.txt"); const url = pathToFileURL(missing); for (const input of [url.href, url, new Request(url)]) { - await expect(fetch(input)).rejects.toMatchObject({ + expect(await rejection(input)).toMatchObject({ code: "ENOENT", syscall: "stat", path: reportedPath(missing), @@ -1818,14 +1832,60 @@ describe.concurrent("fetch() file:// that cannot be read", () => { it("rejects with EISDIR when the path is a directory", async () => { using dir = tempDir("fetch-file-url-dir", {}); - await expect(fetch(pathToFileURL(String(dir)))).rejects.toMatchObject({ + expect(await rejection(pathToFileURL(String(dir)))).toMatchObject({ code: "EISDIR", syscall: "read", path: reportedPath(String(dir)), }); }); - it.skipIf(isWindows)("still resolves for special files such as /dev/null", async () => { + it.skipIf(isWindows || isRoot)("rejects with EACCES when a parent directory cannot be searched", async () => { + using dir = tempDir("fetch-file-url-eacces", { "locked/file.txt": "secret" }); + const locked = join(String(dir), "locked"); + const file = join(locked, "file.txt"); + + chmodSync(locked, 0o000); + try { + expect(await rejection(pathToFileURL(file))).toMatchObject({ + code: "EACCES", + syscall: "stat", + path: reportedPath(file), + }); + } finally { + chmodSync(locked, 0o755); + } + }); + + it.skipIf(isWindows || isRoot)("leaves a permission check on the file itself to the body read", async () => { + // Only what stat() reports is checked before the Response is created. + // access(2) can disagree with open(2), so a file that stats but cannot be + // opened still resolves and the open error comes from the body, as before. + using dir = tempDir("fetch-file-url-mode", { "file.txt": "secret" }); + const file = join(String(dir), "file.txt"); + chmodSync(file, 0o000); + + const response = await fetch(pathToFileURL(file)); + expect(response.status).toBe(200); + await expect(response.text()).rejects.toMatchObject({ + code: "EACCES", + syscall: "open", + path: reportedPath(file), + }); + }); + + it.skipIf(isWindows)("resolves for a FIFO without opening it", async () => { + using dir = tempDir("fetch-file-url-fifo", {}); + const fifo = join(String(dir), "pipe"); + mkfifo(fifo); + + // Nothing ever writes to the pipe, so an implementation that opened the + // path up front instead of stat()ing it would block here. The body is + // deliberately left unread. + const response = await fetch(pathToFileURL(fifo)); + expect(response.status).toBe(200); + }); + + it.skipIf(isWindows)("resolves for a character device", async () => { const response = await fetch("file:///dev/null"); expect([response.status, await response.text()]).toEqual([200, ""]); }); @@ -1843,7 +1903,7 @@ describe.concurrent("fetch() file:// that cannot be read", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout).toBe(""); - expect(stderr).toContain("ENOENT"); + expect(stderr).toContain("TypeError: ENOENT"); expect(stderr).toContain(reportedPath(missing)); expect(exitCode).toBe(1); }); From f4596e9fd13b694c47b75a5c11ac1298d84dfd16 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:33:19 +0000 Subject: [PATCH 3/5] fetch: trim comments on the file: URL check --- src/runtime/webcore/fetch.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index e80d37672e53..98f8fc1406a0 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -228,17 +228,13 @@ fn data_url_response(data_url_: DataURL, global_this: &JSGlobalObject) -> JSValu // file: URLs // ────────────────────────────────────────────────────────────────────────── -/// The `Response` for a `file:` URL wraps a blob that opens the file lazily, so -/// `fetch()` itself has to check the path; otherwise a path that cannot be read -/// still gets a 200 and the error only surfaces from the body reader. Returns -/// the error for `fetch()` to reject with: the system error as a `TypeError`, -/// the shape every other `fetch()` rejection has (see `ValueError::SystemTypeError`). +/// The body blob opens the file lazily, so this is where `fetch("file:...")` +/// learns that the path cannot be read. Returns the rejection value, a +/// `TypeError` like every other `fetch()` failure (`ValueError::SystemTypeError`). /// -/// Only conditions under which the read is certain to fail are checked (the -/// path does not stat, or is a directory). `stat` rather than an eager `open`: -/// opening a FIFO or a device has side effects, and nothing here needs the fd. -/// Files embedded in a standalone executable come back as byte-backed blobs, -/// which have nothing on disk to check. +/// `stat`, not `open`: opening a FIFO or a device has side effects and the fd +/// is not needed. Non-file stores (files embedded in a standalone executable) +/// have nothing on disk to check. fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> Option { let store = file_blob.store()?; let blob::store::Data::File(file) = &store.data else { @@ -256,8 +252,7 @@ fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> Ok(_) => return None, Err(err) => err, }; - // `stat` attached the scratch copy of the path (`\\?\`-prefixed on - // Windows); report the path as the blob holds it. + // Report the blob's path, not the `\\?\`-prefixed scratch copy `stat` attached. let system_error: jsc::SystemError = err.with_path(path.slice()).to_system_error().into(); Some(system_error.to_type_error_instance(global_this)) } @@ -1606,8 +1601,7 @@ fn fetch_impl( return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } - // A bare +1 ref that only `Response::init` below releases, so it is - // created after the early return above. + // +1 ref released only by `Response::init`: must stay after the early return. url_string = jsc::URL::file_url_from_string(BunString::borrow_utf8(temp_file_path)); break 'blob file_blob; From 96f1f2554c8662095cb9a7f407437a61e458d327 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:38:33 +0000 Subject: [PATCH 4/5] fetch: single-line comments on the file: URL check --- src/runtime/webcore/fetch.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 98f8fc1406a0..fd9d0ff0e8a8 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -224,19 +224,10 @@ fn data_url_response(data_url_: DataURL, global_this: &JSGlobalObject) -> JSValu ) } -// ────────────────────────────────────────────────────────────────────────── -// file: URLs -// ────────────────────────────────────────────────────────────────────────── - -/// The body blob opens the file lazily, so this is where `fetch("file:...")` -/// learns that the path cannot be read. Returns the rejection value, a -/// `TypeError` like every other `fetch()` failure (`ValueError::SystemTypeError`). -/// -/// `stat`, not `open`: opening a FIFO or a device has side effects and the fd -/// is not needed. Non-file stores (files embedded in a standalone executable) -/// have nothing on disk to check. +/// The `TypeError` for `fetch("file:...")` to reject with when the blob's path cannot be read. fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> Option { let store = file_blob.store()?; + // A file embedded in a standalone executable comes back as a byte store; nothing to check. let blob::store::Data::File(file) = &store.data else { return None; }; @@ -245,6 +236,7 @@ fn file_url_unreadable_error(file_blob: &Blob, global_this: &JSGlobalObject) -> }; let mut path_buf = bun_paths::path_buffer_pool::get(); + // `stat`, not `open`: opening a FIFO or a device has side effects, and the fd is not needed. let err = match bun_sys::stat(path.slice_z(&mut path_buf)) { Ok(stat) if bun_sys::S::ISDIR(stat.st_mode as bun_sys::Mode) => { bun_sys::Error::from_code(bun_sys::E::EISDIR, bun_sys::Tag::read) From 0ca5f6383c68012a6cd976adee7b1e5aa181f364 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:22:48 +0000 Subject: [PATCH 5/5] test: widen the fetch(file://) leak test window; fold the embedded-file fetch into the existing compile test The leak test kept a flat 20 MiB bound above a ~17 MiB raw signal, so on non-ASAN builds it only tripped thanks to noise. Warm up longer, collect more often, and branch the bound on isASAN like the other tests in the file (leaking vs fixed: 26-27 vs 8-10 MiB on debug+ASAN, 19-21 vs ~2 MiB on release). compile/EmbeddedFileOutfile already builds the fixture the new compile test needed, so assert the fetch() there instead of building a second executable per lane. --- test/bundler/bundler_compile.test.ts | 21 ++++++--------------- test/js/web/fetch/fetch-leak.test.ts | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 9d991af28fa5..7d76c2a2dbcb 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -301,12 +301,18 @@ describe("bundler", () => { }, }); // https://github.com/oven-sh/bun/issues/8697 + // Also covers fetch() of the embedded file's file: URL: fetch() rejects a + // file: URL whose path is not on disk, and an embedded file only exists + // inside the executable, so it has to keep resolving. itBundled("compile/EmbeddedFileOutfile", { compile: true, files: { "/entry.ts": /* js */ ` + import { pathToFileURL } from "node:url"; import bar from './foo.file' with {type: "file"}; if ((await Bun.file(bar).text()).trim() !== "abcd") throw "fail"; + const response = await fetch(pathToFileURL(bar)); + if (response.status !== 200 || (await response.text()).trim() !== "abcd") throw "fetch fail"; console.log("Hello, world!"); `, "/foo.file": /* js */ ` @@ -316,21 +322,6 @@ describe("bundler", () => { outfile: "dist/out", run: { stdout: "Hello, world!" }, }); - // fetch() rejects a file: URL whose path is not on disk. An embedded file - // only exists inside the executable, so it has to keep resolving. - itBundled("compile/FetchFileURLOfEmbeddedFile", { - compile: true, - files: { - "/entry.ts": /* js */ ` - import { pathToFileURL } from "node:url"; - import embedded from './foo.file' with {type: "file"}; - const response = await fetch(pathToFileURL(embedded)); - console.log(response.status, (await response.text()).trim()); - `, - "/foo.file": `abcd`, - }, - run: { stdout: "200 abcd" }, - }); itBundled("compile/WorkerRelativePathNoExtension", { backend: "cli", compile: true, diff --git a/test/js/web/fetch/fetch-leak.test.ts b/test/js/web/fetch/fetch-leak.test.ts index d21e41ec481b..d4302147d981 100644 --- a/test/js/web/fetch/fetch-leak.test.ts +++ b/test/js/web/fetch/fetch-leak.test.ts @@ -549,14 +549,19 @@ test.concurrent( // Blob body is never read, so the Response is the only thing created. await fetch(url); } - for (let i = 0; i < 200; i++) await hit(); + // Warm up until the heap and allocator have reached their steady state, so + // the baseline is not taken while they are still growing. + for (let i = 0; i < 2000; i++) { + await hit(); + if ((i & 255) === 0) Bun.gc(true); + } Bun.gc(true); const baseline = rss(); const ITERS = 20000; for (let i = 0; i < ITERS; i++) { await hit(); - if ((i & 1023) === 0) Bun.gc(true); + if ((i & 255) === 0) Bun.gc(true); } Bun.gc(true); const final = rss(); @@ -567,9 +572,11 @@ test.concurrent( finalMB: (final / 1024 / 1024) | 0, deltaMB: Math.round(deltaMB * 10) / 10, })); - // ~0.85 KiB × 20000 ≈ 17 MiB raw leak (measured ~26 MiB on debug+ASAN) - // when the extra ref is dropped on the floor; ~11 MiB noise with the fix. - if (deltaMB > 20) { + // Each leaked impl is ~0.9 KiB, so 20000 of them are ~17 MiB raw. Leaking vs + // fixed: 26-27 vs 8-10 MiB on debug+ASAN (extra ref re-added), 19-21 vs ~2 MiB + // on a release build (leak approximated by retaining one equal-sized string + // per call). + if (deltaMB > ${isASAN ? 18 : 12}) { throw new Error("fetch(file://) leaked " + deltaMB.toFixed(1) + " MB over " + ITERS + " iterations"); } `;