diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 769b4cb550b0..fb2cbdc1be07 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -94,10 +94,6 @@ pub struct InitOptions { /// reuses the caller's env loader. pub env_loader: Option>, pub graph: Option<&'static dyn bun_resolver::StandaloneModuleGraph>, - /// Must be applied to - /// `transpiler.resolver.store_fd` BEFORE `configure_linker()` reads - /// `top_level_dir`, so it threads through `init_runtime_state`. - pub store_fd: bool, pub smol: bool, pub eval_mode: bool, pub is_main_thread: bool, @@ -122,7 +118,6 @@ impl Default for InitOptions { log: None, env_loader: None, graph: None, - store_fd: false, smol: false, eval_mode: false, is_main_thread: false, @@ -3382,7 +3377,6 @@ pub struct Options { // BORROW_PARAM (`&'a mut bun_dotenv::Loader`) — caller-owned; the loader // outlives the VM, so the inner lifetime is erased to `'static`. pub env_loader: Option>, - pub store_fd: bool, pub smol: bool, // LAYERING: real type is `bun_runtime::dns_jsc::Order` (forward // dep); stored as its `u8` repr. @@ -4173,7 +4167,6 @@ impl VirtualMachine { vm_ref.let_heap_take_initial_module_graph(graph); // Avoid reading from tsconfig.json & package.json when in standalone mode vm_ref.transpiler.configure_linker_with_auto_jsx(false); - vm_ref.transpiler.resolver.store_fd = false; IS_SMOL_MODE.store(opts.smol, core::sync::atomic::Ordering::Relaxed); Ok(vm) } @@ -4191,7 +4184,6 @@ impl VirtualMachine { graph: opts.graph, log: opts.log, env_loader: opts.env_loader, - store_fd: opts.store_fd, smol: opts.smol, eval_mode: opts.eval, is_main_thread: false, @@ -4219,7 +4211,6 @@ impl VirtualMachine { vm_ref.transpiler.resolver.standalone_module_graph = opts.graph; vm_ref.hot_reload = worker.hot_reload(); vm_ref.initial_script_execution_context_identifier = worker.execution_context_id() as i32; - vm_ref.transpiler.resolver.store_fd = opts.store_fd; if opts.graph.is_none() { vm_ref.transpiler.configure_linker(); } else { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 95d4acfeb292..486a196fc8f5 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -75,7 +75,6 @@ pub struct WebWorker { /// Created by `node:worker_threads`' `Worker` (as opposed to the Web `Worker` /// constructor): loads `node:worker_threads` before preloads and the entry point. is_node_worker: bool, - store_fd: bool, /// Borrowed from the proxy's `WorkerOptions` (alive as long as the proxy). argv_ptr: *const WTFStringImpl, argv_len: usize, @@ -351,7 +350,6 @@ impl WebWorker { // its own thread; the worker never dereferences `parent`. // SAFETY: `parent` is the calling thread's live VM. let parent_ref = unsafe { &*parent }; - let store_fd = parent_ref.transpiler.resolver.store_fd; let mut transform_options = (*parent_ref.transpiler.options.transform_options).clone(); if !inherit_exec_argv { let hooks = runtime_hooks().expect("RuntimeHooks not installed"); @@ -406,7 +404,6 @@ impl WebWorker { mini, eval_mode, is_node_worker, - store_fd, argv_ptr, argv_len, exec_argv_ptr, @@ -692,7 +689,6 @@ impl WebWorker { virtual_machine::Options { args: transform_options, env_loader: NonNull::new(loader_ptr), - store_fd: self.store_fd, graph: crate::virtual_machine::standalone_module_graph(), ..Default::default() }, diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index c0a303130aa4..739e78248107 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -972,8 +972,6 @@ Full documentation is available at https://bun.com/docs/cli/run // hand the CLI's vectors over wholesale (process-lifetime, never freed). vm.preload = std::mem::take(&mut ctx.preloads); vm.argv = std::mem::take(&mut ctx.passthrough); - // `InitOptions` has no `store_fd` field, so set it on the resolver directly. - vm.transpiler.resolver.store_fd = ctx.debug.hot_reload != cli::command::HotReload::None; // `vm.dns_result_order` is a `u8` until the b2-cycle widens // it to `bun_dns::Order`; the enum is `#[repr(u8)]` so `as u8` is exact. vm.dns_result_order = diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index f3ae3966e5fb..a0f752a97ca8 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -1925,7 +1925,6 @@ impl TestCommand { debugger: core::mem::take(&mut ctx.runtime_options.debugger), log: core::ptr::NonNull::new(ctx.log), env_loader: core::ptr::NonNull::new(&raw mut *env_loader), - store_fd: ctx.debug.hot_reload != jsc::virtual_machine::HotReload::None, smol: ctx.runtime_options.smol, is_main_thread: true, ..Default::default() diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 2badada656dd..06e2cbcf194b 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -473,7 +473,6 @@ unsafe fn init_runtime_state( unsafe { let t = &mut (*vm).transpiler; t.options.emit_dce_annotations = false; - t.resolver.store_fd = opts.store_fd; t.resolver.prefer_module_field = false; // Propagate `--preserve-symlinks` // from CLI args to the resolver so symlinked node_modules diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index d9315f4994c5..0210ed3762b7 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -1,8 +1,8 @@ import type { Subprocess } from "bun"; import { spawn } from "bun"; import { afterEach, expect, it } from "bun:test"; -import { bunEnv, bunExe, isBroken, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; -import { readdirSync, rmSync } from "node:fs"; +import { bunEnv, bunExe, isBroken, isLinux, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; let watchee: Subprocess; @@ -465,3 +465,90 @@ it.skipIf(isWindows)( }, 30000, ); + +// Paths of the files and directories a process holds open. Linux reads +// /proc, macOS asks lsof (the kernel has no per-process fd listing there). +function openPaths(pid: number): string[] { + if (isLinux) { + const fdDir = `/proc/${pid}/fd`; + return readdirSync(fdDir).flatMap(fd => { + // The process is live, so an fd listed a moment ago can be closed by + // the time it is read. A closed fd is not a held path. + try { + return [readlinkSync(join(fdDir, fd))]; + } catch { + return []; + } + }); + } + const out = Bun.spawnSync({ cmd: ["lsof", "-Fn", "-p", String(pid)], stdout: "pipe", stderr: "ignore" }); + return out.stdout + .toString() + .split("\n") + .filter(line => line.startsWith("n/")) + .map(line => line.slice(1)); +} + +// Watch mode used to make the resolver keep every directory and symlink +// descriptor it opened, most of them under node_modules, which the watcher +// never registers. On macOS each one is an fd for the life of the dev +// server, and a monorepo with the isolated linker reached tens of thousands +// of them (#42702). +for (const [mode, args] of [ + ["--watch", ["--watch", "--no-clear-screen", "main.ts"]], + ["--hot", ["--hot", "--no-clear-screen", "main.ts"]], + ["test --watch", ["test", "--watch", "main.test.ts"]], +] as const) { + it.skipIf(isWindows || (isMacOS && !Bun.which("lsof")))( + `${mode} holds no file descriptors for paths under node_modules`, + async () => { + using dir = tempDir("watch-node-modules-fds", { + "main.ts": `import { x } from "dep"; +import { y } from "dep2"; +console.log("ready", x, y); +setInterval(() => {}, 1e6); +`, + "main.test.ts": `import { test } from "bun:test"; +import { x } from "dep"; +import { y } from "dep2"; +test("ready", () => console.log("ready", x, y)); +`, + "node_modules/dep/package.json": JSON.stringify({ + name: "dep", + type: "module", + exports: { ".": { import: "./dist/esm/index.js" } }, + }), + "node_modules/dep/dist/esm/index.js": "export const x = 1;", + // The layout of the isolated linker: the real package lives under + // node_modules/.bun and node_modules/dep2 is a symlink to it. + "node_modules/.bun/dep2@1.0.0/node_modules/dep2/package.json": JSON.stringify({ + name: "dep2", + type: "module", + main: "./dist/index.js", + }), + "node_modules/.bun/dep2@1.0.0/node_modules/dep2/dist/index.js": "export const y = 2;", + }); + const cwd = realpathSync(String(dir)); + symlinkSync(join(".bun", "dep2@1.0.0", "node_modules", "dep2"), join(cwd, "node_modules", "dep2")); + + watchee = spawn({ + cwd, + cmd: [bunExe(), ...args], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const waiter = stdoutWaiter(watchee); + await waiter.waitFor("ready 1 2"); + + const held = openPaths(watchee.pid); + waiter.release(); + + // stdin, stdout and stderr are always there, so an empty list means the + // listing failed rather than that nothing is held. + expect(held.length).toBeGreaterThan(0); + expect(held.filter(p => p.startsWith(cwd) && p.includes("node_modules"))).toEqual([]); + }, + ); +}