From ecd4e680ebbe8317e3fa25b98717e34e28a503b6 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 19 Feb 2026 20:15:11 -0800 Subject: [PATCH] fix(router): don't cache file descriptors in Route.parse to prevent stale fd reuse (#27164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `FileSystemRouter.Route.parse()` was caching file descriptors in the global entry cache (`entry.cache.fd`). When `Bun.build()` later closed these fds during `ParseTask`, the cache still referenced them. Subsequent `Bun.build()` calls would find these stale fds, pass them to `readFileWithAllocator`, and `seekTo(0)` would fail with EBADF (errno 9). - The fix ensures `Route.parse` always closes the file it opens for `getFdPath` instead of caching it in the shared entry. The fd was only used to resolve the absolute path via `getFdPath`, so caching was unnecessary and harmful. Closes #18242 ## Test plan - [x] Added regression test `test/regression/issue/18242.test.ts` that creates a `FileSystemRouter` and runs `Bun.build()` three times sequentially - [x] Test passes with `bun bd test test/regression/issue/18242.test.ts` - [x] Test fails with `USE_SYSTEM_BUN=1 bun test test/regression/issue/18242.test.ts` (system bun v1.3.9) - [x] Verified 5 sequential builds work correctly after the fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: Jarred Sumner --- src/router.zig | 7 ++-- test/regression/issue/18242.test.ts | 62 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 test/regression/issue/18242.test.ts diff --git a/src/router.zig b/src/router.zig index 875d7da9be..d3f735bf16 100644 --- a/src/router.zig +++ b/src/router.zig @@ -731,23 +731,22 @@ pub const Route = struct { if (abs_path_str.len == 0) { var file: std.fs.File = undefined; - var needs_close = false; + var needs_close = true; defer if (needs_close) file.close(); if (entry.cache.fd.unwrapValid()) |valid| { file = valid.stdFile(); + needs_close = false; } else { var parts = [_]string{ entry.dir, entry.base() }; abs_path_str = FileSystem.instance.absBuf(&parts, &route_file_buf); route_file_buf[abs_path_str.len] = 0; const buf = route_file_buf[0..abs_path_str.len :0]; file = std.fs.openFileAbsoluteZ(buf, .{ .mode = .read_only }) catch |err| { + needs_close = false; log.addErrorFmt(null, Logger.Loc.Empty, allocator, "{s} opening route: {s}", .{ @errorName(err), abs_path_str }) catch unreachable; return null; }; FileSystem.setMaxFd(file.handle); - - needs_close = FileSystem.instance.fs.needToCloseFiles(); - if (!needs_close) entry.cache.fd = .fromStdFile(file); } const _abs = bun.getFdPath(.fromStdFile(file), &route_file_buf) catch |err| { diff --git a/test/regression/issue/18242.test.ts b/test/regression/issue/18242.test.ts new file mode 100644 index 0000000000..ceefcbd096 --- /dev/null +++ b/test/regression/issue/18242.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("Bun.build works multiple times after FileSystemRouter is created", async () => { + using dir = tempDir("issue-18242", { + "pages/index.ts": `console.log("Hello via Bun!");`, + "build.ts": ` +import path from "path"; + +const PAGES_DIR = path.resolve(process.cwd(), "pages"); + +const srcRouter = new Bun.FileSystemRouter({ + dir: PAGES_DIR, + style: "nextjs", +}); + +const entrypoints = Object.values(srcRouter.routes); + +const result1 = await Bun.build({ + entrypoints, + outdir: "dist/browser", +}); + +const result2 = await Bun.build({ + entrypoints, + outdir: "dist/bun", + target: "bun", +}); + +const result3 = await Bun.build({ + entrypoints, + outdir: "dist/third", +}); + +console.log(JSON.stringify({ + build1: result1.success, + build2: result2.success, + build3: result3.success, + build2Logs: result2.logs.map(String), + build3Logs: result3.logs.map(String), +})); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const result = JSON.parse(stdout.trim()); + + expect(result.build1).toBe(true); + expect(result.build2).toBe(true); + expect(result.build3).toBe(true); + expect(result.build2Logs).toEqual([]); + expect(result.build3Logs).toEqual([]); + expect(exitCode).toBe(0); +});