From 7ba611588bdd0d7111f50d390404847f6a0477b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:23:41 +0000 Subject: [PATCH 1/4] watcher: do not trace writes to the BUN_WATCHER_TRACE file itself Each trace append inside the project directory is an IN_MODIFY event on the directory watch. The watcher traced that event, which appended again, so one seed event made the watcher thread write about 5,000 lines per second until the process exited. WatcherTrace now stores the real path of the open trace file. The inotify loop drops a named directory event when the directory holds the trace file and the name is its basename, before the event reaches the trace writer or on_file_update. kqueue and ReadDirectoryChangesW do not report the append through the directory watch, so only inotify filters. --- src/watcher/INotifyWatcher.rs | 13 ++++ src/watcher/Watcher.rs | 5 ++ src/watcher/WatcherTrace.rs | 102 ++++++++++++++++++++++++--- test/cli/watch/watcher-trace.test.ts | 72 ++++++++++++++++++- 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/watcher/INotifyWatcher.rs b/src/watcher/INotifyWatcher.rs index 23637c21ca76..0ff4f4cb4f4b 100644 --- a/src/watcher/INotifyWatcher.rs +++ b/src/watcher/INotifyWatcher.rs @@ -11,6 +11,7 @@ use bun_sys::{self, Fd}; use bun_threading::Futex; use crate::watcher_impl::{MAX_COUNT as max_count, Op, WatchEvent, WatchItemIndex, Watcher}; +use crate::watcher_trace as WatcherTrace; use bun_collections::index_sort; bun_core::declare_scope!(watcher, visible); @@ -400,6 +401,10 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { this.eventlist_index_scratch.clear(); this.eventlist_index_scratch .extend_from_slice(this.watchlist.items_eventlist_index()); + WatcherTrace::collect_dir_indices( + this.watchlist.items_file_path(), + &mut this.trace_dir_index_scratch, + ); } let mut event_id: usize = 0; @@ -451,6 +456,14 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { continue; } }; + // Skip the trace file's own writes, or the trace feeds itself. + if event.name_len > 0 + && this.trace_dir_index_scratch.contains(&idx) + && WatcherTrace::is_trace_file_name(event.name().as_bytes()) + { + events_processed += 1; + continue; + } this.watch_events[event_id] = watch_event_from_inotify_event(event, idx); // Safely handle event names with bounds checking diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 177487c6fe4c..0f7fd78fce7b 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -128,6 +128,9 @@ pub struct Watcher { /// `watch_loop_cycle`; owned by the watcher thread. #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) eventlist_index_scratch: Vec, + /// Watchlist indices of the `BUN_WATCHER_TRACE` directory, snapshotted per cycle. + #[cfg(any(target_os = "linux", target_os = "android"))] + pub(crate) trace_dir_index_scratch: Vec, pub(crate) ctx: *mut (), pub(crate) on_file_update: fn(*mut (), &mut [WatchEvent], &[ChangedFilePath], &WatchList), @@ -205,6 +208,8 @@ impl Watcher { evict_list_i: 0, #[cfg(any(target_os = "linux", target_os = "android"))] eventlist_index_scratch: Vec::new(), + #[cfg(any(target_os = "linux", target_os = "android"))] + trace_dir_index_scratch: Vec::new(), thread_lock: ThreadLock::init_unlocked(), }); diff --git a/src/watcher/WatcherTrace.rs b/src/watcher/WatcherTrace.rs index 3301de3b2521..23a541e24f78 100644 --- a/src/watcher/WatcherTrace.rs +++ b/src/watcher/WatcherTrace.rs @@ -1,15 +1,30 @@ +use core::sync::atomic::{AtomicBool, Ordering}; +#[cfg(any(target_os = "linux", target_os = "android"))] +use std::borrow::Cow; use std::io::Write as _; use bun_core::{env_var, fmt as bun_fmt, output}; +use bun_paths::resolve_path::{join_abs_string_buf_checked, platform}; use bun_sys::{Fd, File, O}; use bun_threading::Guarded; +#[cfg(any(target_os = "linux", target_os = "android"))] +use crate::watcher_impl::WatchItemIndex; use crate::watcher_impl::{ChangedFilePath, WatchEvent, WatchList}; +struct Trace { + file: File, + /// Absolute path, so the inotify loop can drop this file's own write events. + #[cfg(any(target_os = "linux", target_os = "android"))] + path: Box<[u8]>, +} + /// Optional trace file for debugging watcher events. // Wrapped in `bun_threading::Guarded` (cheap when // uncontended; only the watcher thread touches this after init). -static TRACE_FILE: Guarded> = Guarded::new(None); +static TRACE_FILE: Guarded> = Guarded::new(None); +/// Mirrors `TRACE_FILE.is_some()`. Relaxed: only a lock-skip hint, all data access takes the lock. +static TRACE_ENABLED: AtomicBool = AtomicBool::new(false); /// Initialize trace file if BUN_WATCHER_TRACE env var is set. /// Only checks once on first call. @@ -19,16 +34,81 @@ pub(crate) fn init() { return; } - if let Some(trace_path) = env_var::BUN_WATCHER_TRACE.get() { - if !trace_path.is_empty() { - let flags = O::WRONLY | O::CREAT | O::APPEND; - let mode = 0o644; - // Silently ignore errors opening trace file - *slot = File::openat(Fd::cwd(), trace_path, flags, mode).ok(); + let Some(trace_path) = env_var::BUN_WATCHER_TRACE.get() else { + return; + }; + if trace_path.is_empty() { + return; + } + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + let Ok(cwd) = bun_sys::getcwd_z(&mut cwd_buf) else { + return; + }; + let mut path_buf = bun_paths::path_buffer_pool::get(); + let Some(path) = join_abs_string_buf_checked::( + cwd.as_bytes(), + &mut *path_buf, + &[trace_path], + ) else { + return; + }; + let flags = O::WRONLY | O::CREAT | O::APPEND; + let mode = 0o644; + // Silently ignore errors opening trace file + let Ok(file) = File::openat(Fd::cwd(), path, flags, mode) else { + return; + }; + // The watchlist holds realpaths, so store the fd's real path, not the lexical one. + #[cfg(any(target_os = "linux", target_os = "android"))] + let path: Box<[u8]> = { + let mut real_buf = bun_paths::path_buffer_pool::get(); + match bun_sys::get_fd_path(file.handle(), &mut real_buf) { + Ok(real) => (&*real).into(), + Err(_) => path.into(), + } + }; + *slot = Some(Trace { + file, + #[cfg(any(target_os = "linux", target_os = "android"))] + path, + }); + TRACE_ENABLED.store(true, Ordering::Relaxed); +} + +/// Watchlist indices of directories that contain the trace file. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub(crate) fn collect_dir_indices( + file_paths: &[Cow<'static, [u8]>], + out: &mut Vec, +) { + out.clear(); + if !TRACE_ENABLED.load(Ordering::Relaxed) { + return; + } + let guard = TRACE_FILE.lock(); + let Some(trace) = guard.as_ref() else { + return; + }; + let trace_dir = bun_paths::dirname_simple(&trace.path); + for (i, dir_path) in file_paths.iter().enumerate() { + if bun_core::strings::trim_right(dir_path, &[0, b'/']) == trace_dir { + if let Ok(index) = WatchItemIndex::try_from(i) { + out.push(index); + } } } } +/// Whether `name`, an entry of a directory from [`collect_dir_indices`], is the trace file. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub(crate) fn is_trace_file_name(name: &[u8]) -> bool { + let guard = TRACE_FILE.lock(); + match guard.as_ref() { + Some(trace) => bun_paths::basename(&trace.path) == name, + None => false, + } +} + /// Write trace events to the trace file if enabled. /// This is called from the watcher thread, so no locking is needed. /// Events are assumed to be already deduped by path. @@ -39,13 +119,13 @@ pub(crate) fn write_events( ) { use crate::watcher_impl::WatchItemColumns; let guard = TRACE_FILE.lock(); - let Some(file) = guard.as_ref() else { + let Some(trace) = guard.as_ref() else { return; }; // `bun_sys::File::buffered_writer()` wraps `std::io::BufWriter` which // owns its own heap buffer. - let buffered = file.buffered_writer(); + let buffered = trace.file.buffered_writer(); // `defer buffered.flush() catch |err| { Output.err(...) }` let mut writer = scopeguard::guard(buffered, |mut w| { if let Err(err) = w.flush() { @@ -177,5 +257,7 @@ pub(crate) fn write_events( // free-function `deinit` (no `self`), so this stays a plain fn // rather than `impl Drop`. pub(crate) fn deinit() { - let _ = TRACE_FILE.lock().take(); + let mut slot = TRACE_FILE.lock(); + TRACE_ENABLED.store(false, Ordering::Relaxed); + let _ = slot.take(); } diff --git a/test/cli/watch/watcher-trace.test.ts b/test/cli/watch/watcher-trace.test.ts index e3e19daa2a7f..47b47625152a 100644 --- a/test/cli/watch/watcher-trace.test.ts +++ b/test/cli/watch/watcher-trace.test.ts @@ -1,6 +1,6 @@ -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; -import { existsSync, readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { existsSync, readFileSync, symlinkSync } from "node:fs"; import { join } from "node:path"; test("BUN_WATCHER_TRACE creates trace file with watch events", async () => { @@ -149,6 +149,72 @@ test("BUN_WATCHER_TRACE with --watch flag", async () => { expect(foundScriptEvent).toBe(true); }, 10000); +// Windows needs a privilege to create symlinks, and the inotify path under test is Linux only. +const traceDirKinds: [string, boolean][] = isWindows + ? [["its real path", false]] + : [ + ["its real path", false], + ["a symlink", true], + ]; +describe.each(traceDirKinds)("BUN_WATCHER_TRACE inside the watched directory via %s", (_, viaSymlink) => { + test("does not trace its own writes", async () => { + using dir = tempDir("watcher-trace-self", { + "project/script.js": `console.log("run", 0);\nsetInterval(() => {}, 1000);`, + }); + const project = join(String(dir), "project"); + let traceDir = project; + if (viaSymlink) { + traceDir = join(String(dir), "link"); + symlinkSync(project, traceDir, "dir"); + } + + const traceFile = join(traceDir, "trace.log"); + const env = { ...bunEnv, BUN_WATCHER_TRACE: traceFile }; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--hot", "script.js"], + env, + cwd: project, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + + // Each reload is one seed event. Every trace write is itself a write + // event on the directory, so the trace must not report it. + const decoder = new TextDecoder(); + let stdout = ""; + let i = 0; + for await (const chunk of proc.stdout) { + stdout += decoder.decode(chunk, { stream: true }); + if (stdout.includes(`run ${i}`)) { + i++; + if (i === 3) break; + await Bun.write(join(project, "script.js"), `console.log("run", ${i});\nsetInterval(() => {}, 1000);`); + } + } + + proc.kill(); + await proc.exited; + expect(i).toBe(3); + + const lines = readFileSync(traceFile, "utf-8") + .trim() + .split("\n") + .filter(l => l.trim()); + expect(lines.length).toBeGreaterThan(0); + + const selfReferences = lines.filter(line => { + const event = JSON.parse(line); + return Object.values(event.files).some( + (fileEvent: any) => Array.isArray(fileEvent.changed) && fileEvent.changed.includes("trace.log"), + ); + }); + expect(selfReferences.slice(0, 3)).toEqual([]); + expect(lines.length).toBeLessThan(50); + }); +}); + test("BUN_WATCHER_TRACE with empty path does not create trace", async () => { using dir = tempDir("watcher-trace-empty", { "test.js": `console.log("ready");`, From 7ddf3ef41fb0f34744900fa30b4b22e0aa0e010f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:21:56 +0000 Subject: [PATCH 2/4] ci: retrigger From 06d119b323dd004cb1b075f6b8576af362eeaa41 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:54:46 +0000 Subject: [PATCH 3/4] test: run the trace self-feed test on Linux only The cycle exists on inotify only. On Windows the test added no coverage and timed out twice in CI when the first --hot reload after startup was missed. --- test/cli/watch/watcher-trace.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/cli/watch/watcher-trace.test.ts b/test/cli/watch/watcher-trace.test.ts index 47b47625152a..ca1b8a8abfb6 100644 --- a/test/cli/watch/watcher-trace.test.ts +++ b/test/cli/watch/watcher-trace.test.ts @@ -149,15 +149,15 @@ test("BUN_WATCHER_TRACE with --watch flag", async () => { expect(foundScriptEvent).toBe(true); }, 10000); -// Windows needs a privilege to create symlinks, and the inotify path under test is Linux only. -const traceDirKinds: [string, boolean][] = isWindows - ? [["its real path", false]] - : [ - ["its real path", false], - ["a symlink", true], - ]; +// The cycle exists on inotify only: kqueue and ReadDirectoryChangesW do not report +// the trace append through the directory watch. Windows also needs a privilege to +// create symlinks, so the file is Linux coverage. +const traceDirKinds: [string, boolean][] = [ + ["its real path", false], + ["a symlink", true], +]; describe.each(traceDirKinds)("BUN_WATCHER_TRACE inside the watched directory via %s", (_, viaSymlink) => { - test("does not trace its own writes", async () => { + test.skipIf(isWindows)("does not trace its own writes", async () => { using dir = tempDir("watcher-trace-self", { "project/script.js": `console.log("run", 0);\nsetInterval(() => {}, 1000);`, }); From 9c375b6ba9381f93283a8c0257b01cb14bf3dffb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:35:38 +0000 Subject: [PATCH 4/4] test: assert the whole self-reference list is empty --- test/cli/watch/watcher-trace.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/watch/watcher-trace.test.ts b/test/cli/watch/watcher-trace.test.ts index ca1b8a8abfb6..5f7c62ebf3d8 100644 --- a/test/cli/watch/watcher-trace.test.ts +++ b/test/cli/watch/watcher-trace.test.ts @@ -210,7 +210,7 @@ describe.each(traceDirKinds)("BUN_WATCHER_TRACE inside the watched directory via (fileEvent: any) => Array.isArray(fileEvent.changed) && fileEvent.changed.includes("trace.log"), ); }); - expect(selfReferences.slice(0, 3)).toEqual([]); + expect(selfReferences).toHaveLength(0); expect(lines.length).toBeLessThan(50); }); });