From 81519b2d036b33a03791a9de3d4099c713eb6a6c Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:00:01 +0000
Subject: [PATCH 1/8] paths: size join_abs_string/join output buffers to
MAX_PATH_BYTES on Windows
The thread-local output buffers behind join_abs_string / join_abs_string_z /
join / join_z were a platform-agnostic [u8; 4096]. On Windows MAX_PATH_BYTES
is 98302, so any caller of FileSystem::abs() (or a direct join_abs_string
call) with a path whose normalized length landed in [4096, 98302) aborted the
process with a slice-index panic in normalize_string_generic_tz.
Size the buffers to max(MAX_PATH_BYTES, 4096) and back them with a lazily
heap-allocated pointer (the existing LazyPathBuf pattern) so the Windows .tls
section stays at 8 bytes per buffer instead of ~96KB of zeros.
Also guard the unbounded FileSystem::abs() fallback in bun:ffi dlopen, which
reached the same primitive with no upstream length check on any platform.
---
src/paths/resolve_path.rs | 97 ++++++++++++++++---
src/runtime/ffi/ffi_body.rs | 54 +++++------
test/js/bun/ffi/ffi-error-messages.test.ts | 32 +++++-
.../bun/http/bun-serve-html-manifest.test.ts | 29 ++++++
4 files changed, 170 insertions(+), 42 deletions(-)
diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs
index 416ff9096931..48beddd1ec20 100644
--- a/src/paths/resolve_path.rs
+++ b/src/paths/resolve_path.rs
@@ -12,13 +12,59 @@ use bun_core::{ZStr, strings};
// SAFETY invariant: each buffer has at most one live mutable borrow per thread;
// callers must not re-enter the accessor while a previous borrow is alive.
thread_local! {
- static PARSER_JOIN_INPUT_BUFFER: UnsafeCell<[u8; 4096]> = const { UnsafeCell::new([0u8; 4096]) };
+ static PARSER_JOIN_INPUT_BUFFER: LazyJoinBuf = const { LazyJoinBuf::NEW };
static PARSER_BUFFER: UnsafeCell<[u8; 1024]> = const { UnsafeCell::new([0u8; 1024]) };
}
+/// Output capacity of [`join_abs_string`] / [`join`]. Must hold any valid host
+/// path: on Windows `MAX_PATH_BYTES` is 98 302, so a hard-coded 4096 rejects
+/// paths in `[4096, MAX_PATH_BYTES)` with a slice-index panic. On POSIX
+/// `MAX_PATH_BYTES <= 4096`; keep 4096 there so callers that pre-date per-part
+/// length validation see no behaviour change.
+pub(crate) const TL_JOIN_BUF_LEN: usize = if MAX_PATH_BYTES > 4096 {
+ MAX_PATH_BYTES
+} else {
+ 4096
+};
+
+/// Lazily heap-backed `[u8; TL_JOIN_BUF_LEN]` thread-local. 8 bytes in `.tls`
+/// instead of `TL_JOIN_BUF_LEN` zeros (PE/COFF has no TLS-BSS; see
+/// [`LazyPathBuf`]).
+struct LazyJoinBuf(core::cell::Cell<*mut [u8; TL_JOIN_BUF_LEN]>);
+
+impl LazyJoinBuf {
+ const NEW: Self = Self(core::cell::Cell::new(core::ptr::null_mut()));
+
+ /// Borrow the thread-local buffer, allocating on first use. Same
+ /// single-live-borrow-per-thread contract as [`tl_buf_mut`].
+ #[inline]
+ fn get(&self) -> &'static mut [u8; TL_JOIN_BUF_LEN] {
+ let mut p = self.0.get();
+ if p.is_null() {
+ // SAFETY: `new_zeroed` writes every byte to `0`, a valid `u8`, so
+ // the value is fully initialized before `assume_init`.
+ p = bun_core::heap::into_raw(unsafe {
+ Box::<[u8; TL_JOIN_BUF_LEN]>::new_zeroed().assume_init()
+ });
+ self.0.set(p);
+ }
+ // SAFETY: non-null after the init branch; thread-local ⇒ sole accessor.
+ unsafe { &mut *p }
+ }
+}
+
+impl Drop for LazyJoinBuf {
+ fn drop(&mut self) {
+ let p = self.0.get();
+ if !p.is_null() {
+ // SAFETY: `p` came from `heap::into_raw` in `get()`; sole accessor.
+ unsafe { drop(bun_core::heap::take(p)) };
+ }
+ }
+}
+
/// Project `&'static mut` into a thread-local `UnsafeCell<[u8; N]>` scratch
-/// buffer. One `unsafe` site for all `PARSER_BUFFER` / `PARSER_JOIN_INPUT_BUFFER`
-/// / `JOIN_BUF` accessors (nonnull-asref reduction: 6 sites → 1).
+/// buffer (`PARSER_BUFFER`).
///
/// The `'static` output lifetime is the honest contract: the buffer is
/// thread-local storage that lives for the thread's lifetime, and the returned
@@ -1354,7 +1400,7 @@ pub fn join_abs<'a, P: PlatformT>(cwd: &'a [u8], part: &[u8]) -> &'a [u8] {
// result borrows the thread-local buffer ('static) OR returns `cwd`
// directly when `parts.is_empty()`. Return tied to `cwd`'s lifetime ('static: 'a).
pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a [u8] {
- PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::
(cwd, tl_buf_mut(b), parts))
+ PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::
(cwd, b.get(), parts))
}
/// Convert parts of potentially invalid file paths into a single valid filpeath
@@ -1363,22 +1409,19 @@ pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a
///
/// Returned path is stored in a temporary buffer. It must be copied if it needs to be stored.
pub fn join_abs_string_z<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a ZStr {
- PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::
(cwd, tl_buf_mut(b), parts))
+ PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::
(cwd, b.get(), parts))
}
-const JOIN_BUF_LEN: usize = 4096;
-
thread_local! {
- pub(crate) static JOIN_BUF: UnsafeCell<[u8; JOIN_BUF_LEN]> =
- const { UnsafeCell::new([0u8; JOIN_BUF_LEN]) };
+ static JOIN_BUF: LazyJoinBuf = const { LazyJoinBuf::NEW };
}
pub fn join(parts: &[&[u8]]) -> &'static [u8] {
- JOIN_BUF.with(|b| join_string_buf::(tl_buf_mut(b), parts))
+ JOIN_BUF.with(|b| join_string_buf::
(b.get(), parts))
}
pub fn join_z(parts: &[&[u8]]) -> &'static ZStr {
- JOIN_BUF.with(|b| join_z_buf::(tl_buf_mut(b), parts))
+ JOIN_BUF.with(|b| join_z_buf::
(b.get(), parts))
}
#[inline]
@@ -1409,7 +1452,7 @@ pub fn join_z_buf_spill<'a, P: PlatformT>(
/// `spill` (grown as needed). `spill` is untouched in the common case.
pub fn join_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) -> &'a [u8] {
let needed = join_needed(parts);
- if needed <= JOIN_BUF_LEN {
+ if needed <= TL_JOIN_BUF_LEN {
return join::(parts);
}
if spill.len() < needed {
@@ -1422,7 +1465,7 @@ pub fn join_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) ->
/// `spill` (grown as needed). `spill` is untouched in the common case.
pub fn join_z_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) -> &'a ZStr {
let needed = join_needed(parts);
- if needed <= JOIN_BUF_LEN {
+ if needed <= TL_JOIN_BUF_LEN {
return join_z::(parts);
}
if spill.len() < needed {
@@ -2434,3 +2477,31 @@ pub fn posix_to_platform_in_place(path_buffer: &mut [T]) {
// `PathChar` is now canonical at `crate::path_char`; re-export for callers
// that still path through `resolve_path::PathChar`.
pub use crate::PathChar;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tl_join_buf_len_holds_max_path_bytes() {
+ assert!(TL_JOIN_BUF_LEN >= MAX_PATH_BYTES);
+ assert!(TL_JOIN_BUF_LEN >= 4096);
+ }
+
+ #[test]
+ fn join_abs_string_accepts_path_buffer_length() {
+ // A part just under MAX_PATH_BYTES must round-trip through the
+ // thread-local output buffer without panicking. This is the Windows
+ // `[4096, MAX_PATH_BYTES)` window: valid host path, previously aborted.
+ let long = vec![b'a'; MAX_PATH_BYTES - 8];
+ let cwd: &[u8] = if cfg!(windows) { b"C:\\d" } else { b"/d" };
+ let abs: &[u8] = if cfg!(windows) { b"C:\\" } else { b"/" };
+ let r = join_abs_string::(cwd, &[abs, &long]);
+ assert_eq!(r.len(), abs.len() + long.len());
+ let r = join_abs_string_z::(cwd, &[abs, &long]);
+ assert_eq!(r.as_bytes().len(), abs.len() + long.len());
+ // And the non-absolute `join` sibling backed by `JOIN_BUF`.
+ let r = join::(&[abs, &long]);
+ assert_eq!(r.len(), abs.len() + long.len());
+ }
+}
diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs
index 72dc9e03d333..d5139b4b9a18 100644
--- a/src/runtime/ffi/ffi_body.rs
+++ b/src/runtime/ffi/ffi_body.rs
@@ -1493,36 +1493,34 @@ impl FFI {
let dylib: bun_sys::DynLib = 'brk: {
// First try using the name directly
- match bun_sys::DynLib::open(name) {
- Ok(d) => break 'brk d,
- Err(_) => {
- let backup_name = Fs::FileSystem::instance().abs(&[name]);
- // if that fails, try resolving the filepath relative to the current working directory
- match bun_sys::DynLib::open(backup_name) {
- Ok(d) => break 'brk d,
- Err(_) => {
- // Then, if that fails, report an error with the library name and system error
- let dlerror_msg = get_dl_error();
-
- let mut msg = Vec::new();
- write!(
- &mut msg,
- "Failed to open library \"{}\": {}",
- BStr::new(name),
- BStr::new(&dlerror_msg)
- )
- .ok();
- let system_error = SystemError {
- code: bun_core::String::clone_utf8(b"ERR_DLOPEN_FAILED").into(),
- message: bun_core::String::clone_utf8(&msg).into(),
- syscall: bun_core::String::clone_utf8(b"dlopen").into(),
- ..Default::default()
- };
- return system_error.to_error_instance(global);
- }
- }
+ if let Ok(d) = bun_sys::DynLib::open(name) {
+ break 'brk d;
+ }
+ // if that fails, try resolving the filepath relative to the current working directory
+ if name.len() < bun_paths::MAX_PATH_BYTES {
+ let backup_name = Fs::FileSystem::instance().abs(&[name]);
+ if let Ok(d) = bun_sys::DynLib::open(backup_name) {
+ break 'brk d;
}
}
+ // Then, if that fails, report an error with the library name and system error
+ let dlerror_msg = get_dl_error();
+
+ let mut msg = Vec::new();
+ write!(
+ &mut msg,
+ "Failed to open library \"{}\": {}",
+ BStr::new(name),
+ BStr::new(&dlerror_msg)
+ )
+ .ok();
+ let system_error = SystemError {
+ code: bun_core::String::clone_utf8(b"ERR_DLOPEN_FAILED").into(),
+ message: bun_core::String::clone_utf8(&msg).into(),
+ syscall: bun_core::String::clone_utf8(b"dlopen").into(),
+ ..Default::default()
+ };
+ return system_error.to_error_instance(global);
};
let mut size = symbols.values().len();
diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts
index 23cf35c76454..b83f41c920ac 100644
--- a/test/js/bun/ffi/ffi-error-messages.test.ts
+++ b/test/js/bun/ffi/ffi-error-messages.test.ts
@@ -1,6 +1,6 @@
import { dlopen, linkSymbols } from "bun:ffi";
import { describe, expect, test } from "bun:test";
-import { isMusl } from "harness";
+import { bunEnv, bunExe, isMusl } from "harness";
describe("FFI error messages", () => {
test("dlopen shows library name when library cannot be opened", () => {
@@ -20,6 +20,36 @@ describe("FFI error messages", () => {
}
});
+ // dlopen falls back to FileSystem::abs() when the direct open fails; abs()
+ // writes into a thread-local buffer that was 4096 bytes on every platform.
+ // A library path longer than that used to abort with
+ // panic: range end index 5003 out of range for slice of length 4095
+ // instead of reporting the ordinary dlopen error.
+ test.concurrent.each([5000, 100_000])(
+ "dlopen with a %d-byte library path reports an error instead of aborting",
+ async len => {
+ const prefix = process.platform === "win32" ? "C:\\" : "/";
+ await using proc = Bun.spawn({
+ cmd: [
+ bunExe(),
+ "-e",
+ `const { dlopen } = require("bun:ffi");` +
+ `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` +
+ `try { dlopen(name, { f: { args: [], returns: "void" } }); }` +
+ `catch (e) { console.log("CAUGHT", e.code || e.name, String(e.message).slice(0, 30)); }`,
+ ],
+ env: bunEnv,
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect({ stdout: stdout.trim(), stderr }).toEqual({
+ stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/),
+ stderr: "",
+ });
+ expect(exitCode).toBe(0);
+ },
+ );
+
test("dlopen shows which symbol is missing when symbol not found", () => {
// Use appropriate system library for the platform
const libName =
diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts
index a1e5dbe29720..853859ac4ea8 100644
--- a/test/js/bun/http/bun-serve-html-manifest.test.ts
+++ b/test/js/bun/http/bun-serve-html-manifest.test.ts
@@ -266,6 +266,35 @@ describe("Bun.serve HTML manifest", () => {
expect(out).toContain("SUCCESS: Manifest validation failed as expected");
});
+ it("rejects a manifest file path longer than the join buffer without aborting", async () => {
+ // Windows MAX_PATH_BYTES (98302) >> 4096, so a 5000-byte path passes the
+ // ENAMETOOLONG guard and reaches FileSystem::abs() whose output buffer was
+ // 4096 bytes: process used to abort with a slice-index panic. On POSIX
+ // MAX_PATH_BYTES <= 4096, so the guard rejects it with ENAMETOOLONG first.
+ await using proc = Bun.spawn({
+ cmd: [
+ bunExe(),
+ "-e",
+ `const long = (process.platform === "win32" ? "C:\\\\" : "/") + Buffer.alloc(5000, "a").toString();` +
+ `try {` +
+ ` const s = Bun.serve({ port: 0, routes: { "/": {` +
+ ` index: "./index.html",` +
+ ` files: [{ input: "index.html", path: long, loader: "html", isEntry: true,` +
+ ` headers: { etag: "x", "content-type": "text/html" } }],` +
+ ` } } });` +
+ ` s.stop();` +
+ ` console.log("CAUGHT no-throw");` +
+ `} catch (e) { console.log("CAUGHT", e.code || e.name); }`,
+ ],
+ env: bunEnv,
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect(stderr).toBe("");
+ expect(stdout.trim()).toMatch(/^CAUGHT (ENOENT|ENAMETOOLONG|ERR_FILE_NOT_FOUND|no-throw)$/);
+ expect(exitCode).toBe(0);
+ });
+
it("serves manifest with proper headers", async () => {
await using dir = tempDir("serve-html-headers", {
"server.ts": `
From 8883df202247a6bf3862cb50594173cc4319f637 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:06:39 +0000
Subject: [PATCH 2/8] test: accept ERR_INVALID_ARG_TYPE on Windows for long
manifest file path
---
test/js/bun/http/bun-serve-html-manifest.test.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts
index 853859ac4ea8..82e84767ebac 100644
--- a/test/js/bun/http/bun-serve-html-manifest.test.ts
+++ b/test/js/bun/http/bun-serve-html-manifest.test.ts
@@ -291,7 +291,9 @@ describe("Bun.serve HTML manifest", () => {
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
- expect(stdout.trim()).toMatch(/^CAUGHT (ENOENT|ENAMETOOLONG|ERR_FILE_NOT_FOUND|no-throw)$/);
+ // ENAMETOOLONG on POSIX (the per-part guard fires); ERR_INVALID_ARG_TYPE
+ // on Windows (abs() succeeds; route setup then rejects the missing file).
+ expect(stdout.trim()).toMatch(/^CAUGHT (ENAMETOOLONG|ERR_INVALID_ARG_TYPE)$/);
expect(exitCode).toBe(0);
});
From a11869f241f35f54311431c425aae8389cad67f0 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:07:53 +0000
Subject: [PATCH 3/8] test: drop stderr assertion for debug_warn noise on
Windows
---
test/js/bun/http/bun-serve-html-manifest.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts
index 82e84767ebac..20734f07881e 100644
--- a/test/js/bun/http/bun-serve-html-manifest.test.ts
+++ b/test/js/bun/http/bun-serve-html-manifest.test.ts
@@ -290,9 +290,9 @@ describe("Bun.serve HTML manifest", () => {
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
- expect(stderr).toBe("");
// ENAMETOOLONG on POSIX (the per-part guard fires); ERR_INVALID_ARG_TYPE
// on Windows (abs() succeeds; route setup then rejects the missing file).
+ if (!stdout.startsWith("CAUGHT")) console.error(stderr);
expect(stdout.trim()).toMatch(/^CAUGHT (ENAMETOOLONG|ERR_INVALID_ARG_TYPE)$/);
expect(exitCode).toBe(0);
});
From 04a8f090a4d93333efa6bee95a8c98e1efcbc62a Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:53:30 +0000
Subject: [PATCH 4/8] ffi: report ENAMETOOLONG instead of stale dlerror when
the fallback is skipped
---
src/runtime/ffi/ffi_body.rs | 22 +++++++++++++++-------
test/js/bun/ffi/ffi-error-messages.test.ts | 7 ++++++-
2 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs
index d5139b4b9a18..1ecd00bb80cd 100644
--- a/src/runtime/ffi/ffi_body.rs
+++ b/src/runtime/ffi/ffi_body.rs
@@ -1493,18 +1493,26 @@ impl FFI {
let dylib: bun_sys::DynLib = 'brk: {
// First try using the name directly
- if let Ok(d) = bun_sys::DynLib::open(name) {
- break 'brk d;
- }
+ let mut last_err = match bun_sys::DynLib::open(name) {
+ Ok(d) => break 'brk d,
+ Err(e) => e,
+ };
// if that fails, try resolving the filepath relative to the current working directory
if name.len() < bun_paths::MAX_PATH_BYTES {
let backup_name = Fs::FileSystem::instance().abs(&[name]);
- if let Ok(d) = bun_sys::DynLib::open(backup_name) {
- break 'brk d;
+ match bun_sys::DynLib::open(backup_name) {
+ Ok(d) => break 'brk d,
+ Err(e) => last_err = e,
}
}
- // Then, if that fails, report an error with the library name and system error
- let dlerror_msg = get_dl_error();
+ // Then, if that fails, report an error with the library name and
+ // system error. `DynLib::open` returns ENAMETOOLONG without calling
+ // the loader, so dlerror()/GetLastError() would be stale there.
+ let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG {
+ Box::<[u8]>::from(b"file name too long".as_slice())
+ } else {
+ get_dl_error()
+ };
let mut msg = Vec::new();
write!(
diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts
index b83f41c920ac..219c23296d7a 100644
--- a/test/js/bun/ffi/ffi-error-messages.test.ts
+++ b/test/js/bun/ffi/ffi-error-messages.test.ts
@@ -36,7 +36,8 @@ describe("FFI error messages", () => {
`const { dlopen } = require("bun:ffi");` +
`const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` +
`try { dlopen(name, { f: { args: [], returns: "void" } }); }` +
- `catch (e) { console.log("CAUGHT", e.code || e.name, String(e.message).slice(0, 30)); }`,
+ `catch (e) { const m = String(e.message);` +
+ ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`,
],
env: bunEnv,
stderr: "pipe",
@@ -46,6 +47,10 @@ describe("FFI error messages", () => {
stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/),
stderr: "",
});
+ // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped
+ // and the reported reason is the ENAMETOOLONG from DynLib::open, not a
+ // stale dlerror()/GetLastError() ("unknown error" / "error code 0").
+ if (len === 100_000) expect(stdout).toContain("file name too long");
expect(exitCode).toBe(0);
},
);
From 6f4498b2d634691b7dd926fb8d484f0dfc778f8c Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:56:35 +0000
Subject: [PATCH 5/8] docs: tighten comments flagged by the comment linter
---
src/runtime/ffi/ffi_body.rs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs
index 1ecd00bb80cd..557955887a0b 100644
--- a/src/runtime/ffi/ffi_body.rs
+++ b/src/runtime/ffi/ffi_body.rs
@@ -1505,9 +1505,7 @@ impl FFI {
Err(e) => last_err = e,
}
}
- // Then, if that fails, report an error with the library name and
- // system error. `DynLib::open` returns ENAMETOOLONG without calling
- // the loader, so dlerror()/GetLastError() would be stale there.
+ // ENAMETOOLONG never reached the loader: dlerror()/GetLastError() is stale.
let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG {
Box::<[u8]>::from(b"file name too long".as_slice())
} else {
From cffce93a31b093eea8221c73b131d3a4641b4aef Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 09:57:22 +0000
Subject: [PATCH 6/8] docs: tighten TL_JOIN_BUF_LEN comment
---
src/paths/resolve_path.rs | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs
index 48beddd1ec20..513fb3a29431 100644
--- a/src/paths/resolve_path.rs
+++ b/src/paths/resolve_path.rs
@@ -16,11 +16,8 @@ thread_local! {
static PARSER_BUFFER: UnsafeCell<[u8; 1024]> = const { UnsafeCell::new([0u8; 1024]) };
}
-/// Output capacity of [`join_abs_string`] / [`join`]. Must hold any valid host
-/// path: on Windows `MAX_PATH_BYTES` is 98 302, so a hard-coded 4096 rejects
-/// paths in `[4096, MAX_PATH_BYTES)` with a slice-index panic. On POSIX
-/// `MAX_PATH_BYTES <= 4096`; keep 4096 there so callers that pre-date per-part
-/// length validation see no behaviour change.
+/// Output capacity of [`join_abs_string`] / [`join`]: at least `MAX_PATH_BYTES`
+/// (98 302 on Windows) so any valid host path fits; floored at 4096 on POSIX.
pub(crate) const TL_JOIN_BUF_LEN: usize = if MAX_PATH_BYTES > 4096 {
MAX_PATH_BYTES
} else {
@@ -2490,9 +2487,7 @@ mod tests {
#[test]
fn join_abs_string_accepts_path_buffer_length() {
- // A part just under MAX_PATH_BYTES must round-trip through the
- // thread-local output buffer without panicking. This is the Windows
- // `[4096, MAX_PATH_BYTES)` window: valid host path, previously aborted.
+ // A valid host path just under MAX_PATH_BYTES round-trips through the TL buffer.
let long = vec![b'a'; MAX_PATH_BYTES - 8];
let cwd: &[u8] = if cfg!(windows) { b"C:\\d" } else { b"/d" };
let abs: &[u8] = if cfg!(windows) { b"C:\\" } else { b"/" };
From a6ff4956012cc360429f784843e1ce0782bd991a Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:03:58 +0000
Subject: [PATCH 7/8] ffi: use abs_buf_checked for the dlopen fallback; cover
relative library names
The previous name.len() guard ignored top_level_dir, so a relative library
name whose cwd+name exceeded the join buffer still reached the slice-index
panic. abs_buf_checked returns None on overflow regardless of how the length
is reached.
Also swap the open-coded Box::new_zeroed().assume_init() for the existing
bun_core::boxed_zeroed helper.
---
src/paths/resolve_path.rs | 11 ++--
src/runtime/ffi/ffi_body.rs | 6 ++-
test/js/bun/ffi/ffi-error-messages.test.ts | 63 +++++++++++-----------
3 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs
index 513fb3a29431..8fbfd5ee54b2 100644
--- a/src/paths/resolve_path.rs
+++ b/src/paths/resolve_path.rs
@@ -32,20 +32,15 @@ struct LazyJoinBuf(core::cell::Cell<*mut [u8; TL_JOIN_BUF_LEN]>);
impl LazyJoinBuf {
const NEW: Self = Self(core::cell::Cell::new(core::ptr::null_mut()));
- /// Borrow the thread-local buffer, allocating on first use. Same
- /// single-live-borrow-per-thread contract as [`tl_buf_mut`].
+ /// Same single-live-borrow-per-thread contract as [`tl_buf_mut`].
#[inline]
fn get(&self) -> &'static mut [u8; TL_JOIN_BUF_LEN] {
let mut p = self.0.get();
if p.is_null() {
- // SAFETY: `new_zeroed` writes every byte to `0`, a valid `u8`, so
- // the value is fully initialized before `assume_init`.
- p = bun_core::heap::into_raw(unsafe {
- Box::<[u8; TL_JOIN_BUF_LEN]>::new_zeroed().assume_init()
- });
+ p = bun_core::heap::into_raw(bun_core::boxed_zeroed::<[u8; TL_JOIN_BUF_LEN]>());
self.0.set(p);
}
- // SAFETY: non-null after the init branch; thread-local ⇒ sole accessor.
+ // SAFETY: non-null after init; thread-local ⇒ sole accessor.
unsafe { &mut *p }
}
}
diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs
index 557955887a0b..59f89503a115 100644
--- a/src/runtime/ffi/ffi_body.rs
+++ b/src/runtime/ffi/ffi_body.rs
@@ -1498,8 +1498,10 @@ impl FFI {
Err(e) => e,
};
// if that fails, try resolving the filepath relative to the current working directory
- if name.len() < bun_paths::MAX_PATH_BYTES {
- let backup_name = Fs::FileSystem::instance().abs(&[name]);
+ let mut backup_buf = bun_paths::path_buffer_pool::get();
+ if let Some(backup_name) =
+ Fs::FileSystem::instance().abs_buf_checked(&[name], &mut backup_buf[..])
+ {
match bun_sys::DynLib::open(backup_name) {
Ok(d) => break 'brk d,
Err(e) => last_err = e,
diff --git a/test/js/bun/ffi/ffi-error-messages.test.ts b/test/js/bun/ffi/ffi-error-messages.test.ts
index 219c23296d7a..61bdce506e29 100644
--- a/test/js/bun/ffi/ffi-error-messages.test.ts
+++ b/test/js/bun/ffi/ffi-error-messages.test.ts
@@ -24,36 +24,39 @@ describe("FFI error messages", () => {
// writes into a thread-local buffer that was 4096 bytes on every platform.
// A library path longer than that used to abort with
// panic: range end index 5003 out of range for slice of length 4095
- // instead of reporting the ordinary dlopen error.
- test.concurrent.each([5000, 100_000])(
- "dlopen with a %d-byte library path reports an error instead of aborting",
- async len => {
- const prefix = process.platform === "win32" ? "C:\\" : "/";
- await using proc = Bun.spawn({
- cmd: [
- bunExe(),
- "-e",
- `const { dlopen } = require("bun:ffi");` +
- `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` +
- `try { dlopen(name, { f: { args: [], returns: "void" } }); }` +
- `catch (e) { const m = String(e.message);` +
- ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`,
- ],
- env: bunEnv,
- stderr: "pipe",
- });
- const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
- expect({ stdout: stdout.trim(), stderr }).toEqual({
- stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/),
- stderr: "",
- });
- // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped
- // and the reported reason is the ENAMETOOLONG from DynLib::open, not a
- // stale dlerror()/GetLastError() ("unknown error" / "error code 0").
- if (len === 100_000) expect(stdout).toContain("file name too long");
- expect(exitCode).toBe(0);
- },
- );
+ // instead of reporting the ordinary dlopen error. The "relative" row joins
+ // against cwd, so the overflow point is roughly MAX_PATH_BYTES - cwd.len().
+ test.concurrent.each([
+ ["absolute", 5000],
+ ["absolute", 100_000],
+ ["relative", 4090],
+ ["relative", 100_000],
+ ] as const)("dlopen with a %s %d-byte library path reports an error instead of aborting", async (kind, len) => {
+ const prefix = kind === "relative" ? "" : process.platform === "win32" ? "C:\\" : "/";
+ await using proc = Bun.spawn({
+ cmd: [
+ bunExe(),
+ "-e",
+ `const { dlopen } = require("bun:ffi");` +
+ `const name = ${JSON.stringify(prefix)} + Buffer.alloc(${len}, "a").toString() + ".so";` +
+ `try { dlopen(name, { f: { args: [], returns: "void" } }); }` +
+ `catch (e) { const m = String(e.message);` +
+ ` console.log("CAUGHT", e.code || e.name, m.slice(0, 30), "|", m.slice(-25)); }`,
+ ],
+ env: bunEnv,
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect({ stdout: stdout.trim(), stderr }).toEqual({
+ stdout: expect.stringMatching(/^CAUGHT ERR_DLOPEN_FAILED Failed to open library/),
+ stderr: "",
+ });
+ // 100k exceeds every platform's MAX_PATH_BYTES: the fallback is skipped
+ // and the reported reason is the ENAMETOOLONG from DynLib::open, not a
+ // stale dlerror()/GetLastError() ("unknown error" / "error code 0").
+ if (len === 100_000) expect(stdout).toContain("file name too long");
+ expect(exitCode).toBe(0);
+ });
test("dlopen shows which symbol is missing when symbol not found", () => {
// Use appropriate system library for the platform
From cd9b94943ca3c7ba00a820b1afda943a8c286532 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:22:15 +0000
Subject: [PATCH 8/8] ffi: key the stale-dlerror fallback on name length, not
last_err
When the first DynLib::open reached the loader (name.len() < MAX_PATH_BYTES),
dlerror()/GetLastError() is fresh regardless of whether the fallback
short-circuited. Keying on last_err could discard a rich dlerror on macOS/BSD
when cwd + a short library name exceeded 1024 bytes.
---
src/runtime/ffi/ffi_body.rs | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs
index 59f89503a115..96744a35fa0c 100644
--- a/src/runtime/ffi/ffi_body.rs
+++ b/src/runtime/ffi/ffi_body.rs
@@ -1493,22 +1493,20 @@ impl FFI {
let dylib: bun_sys::DynLib = 'brk: {
// First try using the name directly
- let mut last_err = match bun_sys::DynLib::open(name) {
- Ok(d) => break 'brk d,
- Err(e) => e,
- };
+ if let Ok(d) = bun_sys::DynLib::open(name) {
+ break 'brk d;
+ }
// if that fails, try resolving the filepath relative to the current working directory
let mut backup_buf = bun_paths::path_buffer_pool::get();
if let Some(backup_name) =
Fs::FileSystem::instance().abs_buf_checked(&[name], &mut backup_buf[..])
+ && let Ok(d) = bun_sys::DynLib::open(backup_name)
{
- match bun_sys::DynLib::open(backup_name) {
- Ok(d) => break 'brk d,
- Err(e) => last_err = e,
- }
+ break 'brk d;
}
- // ENAMETOOLONG never reached the loader: dlerror()/GetLastError() is stale.
- let dlerror_msg = if last_err == bun_errno::SystemErrno::ENAMETOOLONG {
+ // `DynLib::open` short-circuits ENAMETOOLONG without calling the
+ // loader, so dlerror()/GetLastError() is stale iff `name` was too long.
+ let dlerror_msg = if name.len() >= bun_paths::MAX_PATH_BYTES {
Box::<[u8]>::from(b"file name too long".as_slice())
} else {
get_dl_error()