Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/watcher/INotifyWatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment thread
robobun marked this conversation as resolved.
this.watch_events[event_id] = watch_event_from_inotify_event(event, idx);

// Safely handle event names with bounds checking
Expand Down
5 changes: 5 additions & 0 deletions src/watcher/Watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<platform::EventListIndex>,
/// 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<WatchItemIndex>,

pub(crate) ctx: *mut (),
pub(crate) on_file_update: fn(*mut (), &mut [WatchEvent], &[ChangedFilePath], &WatchList),
Expand Down Expand Up @@ -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(),
});

Expand Down
102 changes: 92 additions & 10 deletions src/watcher/WatcherTrace.rs
Original file line number Diff line number Diff line change
@@ -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<Option<File>> = Guarded::new(None);
static TRACE_FILE: Guarded<Option<Trace>> = 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.
Expand All @@ -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::<platform::Auto>(
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<WatchItemIndex>,
) {
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.
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
}
72 changes: 69 additions & 3 deletions test/cli/watch/watcher-trace.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -149,6 +149,72 @@ test("BUN_WATCHER_TRACE with --watch flag", async () => {
expect(foundScriptEvent).toBe(true);
}, 10000);

// 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.skipIf(isWindows)("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).toHaveLength(0);
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");`,
Expand Down
Loading