Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
5f743ef789 Use BufferedWriter and events array in BUN_WATCHER_TRACE
- Replace FileDescriptor with bun.sys.File
- Use std.io.bufferedWriter with defer flush() instead of fixed buffer
- Change JSON format from boolean fields to events array
- Update tests to validate new events array format

This addresses review feedback to improve I/O efficiency and JSON structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 16:06:30 +00:00
Claude Bot
481cabcdfb Use bun.fmt for JSON escaping instead of manual implementation
Addresses CodeRabbit review comment about incomplete JSON escaping.
Uses the existing bun.fmt.formatJSONStringUTF8 utility which properly
handles all control characters and edge cases.
2025-10-12 15:44:44 +00:00
autofix-ci[bot]
54465eeb20 [autofix.ci] apply automated fixes 2025-10-12 15:26:14 +00:00
Claude Bot
18ff16dcc7 Add BUN_WATCHER_TRACE environment variable for debugging file watcher events
Implements BUN_WATCHER_TRACE env var that logs all file watcher events to a file in JSON format. When set, the watcher appends detailed event information including timestamp, file path, and operation flags (write, delete, rename, metadata, move_to) plus any changed filenames.

The trace file is opened once during watcher initialization in append mode, allowing events to persist across multiple runs of bun --watch or bun --hot. This is useful for debugging watch-related issues.

Example usage:
  BUN_WATCHER_TRACE=/tmp/watch.log bun --watch script.js

Each line in the trace file is a JSON object with:
- timestamp: millisecond timestamp
- index: watch item index
- path: absolute file path being watched
- delete/write/rename/metadata/move_to: boolean operation flags
- changed_files: array of filenames that changed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:23:22 +00:00
6 changed files with 370 additions and 0 deletions

View File

@@ -95,9 +95,18 @@ pub fn init(comptime T: type, ctx: *T, fs: *bun.fs.FileSystem, allocator: std.me
try Platform.init(&watcher.platform, fs.top_level_dir);
// Initialize trace file if BUN_WATCHER_TRACE env var is set
WatcherTrace.init();
return watcher;
}
/// Write trace events to the trace file if enabled.
/// This runs on the watcher thread, so no locking is needed.
pub fn writeTraceEvents(this: *Watcher, events: []WatchEvent, changed_files: []?[:0]u8) void {
WatcherTrace.writeEvents(this, events, changed_files);
}
pub fn start(this: *Watcher) !void {
bun.assert(this.watchloop_handle == null);
this.thread = try std.Thread.spawn(.{}, threadMain, .{this});
@@ -244,6 +253,9 @@ fn threadMain(this: *Watcher) !void {
}
this.watchlist.deinit(this.allocator);
// Close trace file if open
WatcherTrace.deinit();
const allocator = this.allocator;
allocator.destroy(this);
}
@@ -676,6 +688,7 @@ pub fn onMaybeWatchDirectory(watch: *Watcher, file_path: string, dir_fd: bun.Sto
const string = []const u8;
const WatcherTrace = @import("./watcher/WatcherTrace.zig");
const WindowsWatcher = @import("./watcher/WindowsWatcher.zig");
const options = @import("./options.zig");
const std = @import("std");

View File

@@ -352,6 +352,7 @@ fn processINotifyEventBatch(this: *bun.Watcher, event_count: usize, temp_name_li
defer this.mutex.unlock();
if (this.running) {
// all_events.len == 0 is checked above, so last_event_index + 1 is safe
this.writeTraceEvents(all_events[0 .. last_event_index + 1], this.changed_filepaths[0..name_off]);
this.onFileUpdate(this.ctx, all_events[0 .. last_event_index + 1], this.changed_filepaths[0..name_off], this.watchlist);
}

View File

@@ -93,6 +93,7 @@ pub fn watchLoopCycle(this: *Watcher) bun.sys.Maybe(void) {
this.mutex.lock();
defer this.mutex.unlock();
if (this.running) {
this.writeTraceEvents(watchevents, this.changed_filepaths[0..watchevents.len]);
this.onFileUpdate(this.ctx, watchevents, this.changed_filepaths[0..watchevents.len], this.watchlist);
}

View File

@@ -0,0 +1,105 @@
const std = @import("std");
const bun = @import("../bun.zig");
const Watcher = @import("../Watcher.zig");
/// Optional trace file for debugging watcher events
var trace_file: ?bun.sys.File = null;
/// Initialize trace file if BUN_WATCHER_TRACE env var is set.
/// Only checks once on first call.
pub fn init() void {
if (trace_file != null) return;
if (bun.getenvZ("BUN_WATCHER_TRACE")) |trace_path| {
if (trace_path.len > 0) {
const flags = bun.O.WRONLY | bun.O.CREAT | bun.O.APPEND;
const mode = 0o644;
switch (bun.sys.openA(trace_path, flags, mode)) {
.result => |fd| {
trace_file = bun.sys.File{ .handle = fd };
},
.err => {
// Silently ignore errors opening trace file
},
}
}
}
}
/// Write trace events to the trace file if enabled.
/// This is called from the watcher thread, so no locking is needed.
pub fn writeEvents(watcher: *Watcher, events: []Watcher.WatchEvent, changed_files: []?[:0]u8) void {
const file = trace_file orelse return;
var buffered = std.io.bufferedWriter(file.writer());
defer buffered.flush() catch {};
const writer = buffered.writer();
// Get current timestamp
const timestamp = std.time.milliTimestamp();
for (events) |event| {
const watchlist_slice = watcher.watchlist.slice();
const file_paths = watchlist_slice.items(.file_path);
const file_path = if (event.index < file_paths.len) file_paths[event.index] else "(unknown)";
// Build array of operation types
const names = event.names(changed_files);
// Write JSON for each event
writer.writeAll("{\"timestamp\":") catch continue;
writer.print("{d}", .{timestamp}) catch continue;
writer.writeAll(",\"index\":") catch continue;
writer.print("{d}", .{event.index}) catch continue;
writer.writeAll(",\"path\":") catch continue;
writer.print("{}", .{bun.fmt.formatJSONStringUTF8(file_path, .{})}) catch continue;
writer.writeAll(",\"events\":[") catch continue;
// Write array of event types that occurred
var first = true;
if (event.op.delete) {
if (!first) writer.writeAll(",") catch continue;
writer.writeAll("\"delete\"") catch continue;
first = false;
}
if (event.op.write) {
if (!first) writer.writeAll(",") catch continue;
writer.writeAll("\"write\"") catch continue;
first = false;
}
if (event.op.rename) {
if (!first) writer.writeAll(",") catch continue;
writer.writeAll("\"rename\"") catch continue;
first = false;
}
if (event.op.metadata) {
if (!first) writer.writeAll(",") catch continue;
writer.writeAll("\"metadata\"") catch continue;
first = false;
}
if (event.op.move_to) {
if (!first) writer.writeAll(",") catch continue;
writer.writeAll("\"move_to\"") catch continue;
first = false;
}
writer.writeAll("],\"changed_files\":[") catch continue;
first = true;
for (names) |name_opt| {
if (name_opt) |name| {
if (!first) writer.writeAll(",") catch continue;
first = false;
writer.print("{}", .{bun.fmt.formatJSONStringUTF8(name, .{})}) catch continue;
}
}
writer.writeAll("]}\n") catch continue;
}
}
/// Close the trace file if open
pub fn deinit() void {
if (trace_file) |file| {
file.close();
trace_file = null;
}
}

View File

@@ -293,6 +293,7 @@ fn processWatchEventBatch(this: *bun.Watcher, event_count: usize) bun.sys.Maybe(
log("calling onFileUpdate (all_events.len = {d})", .{all_events.len});
this.writeTraceEvents(all_events, this.changed_filepaths[0 .. last_event_index + 1]);
this.onFileUpdate(this.ctx, all_events, this.changed_filepaths[0 .. last_event_index + 1], this.watchlist);
return .success;

View File

@@ -0,0 +1,249 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
test("BUN_WATCHER_TRACE creates trace file with watch events", async () => {
using dir = tempDir("watcher-trace", {
"script.js": `console.log("ready");`,
});
const traceFile = join(String(dir), "trace.log");
const env = { ...bunEnv, BUN_WATCHER_TRACE: traceFile };
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "script.js"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "inherit",
stdin: "ignore",
});
// Wait for first run, then trigger a change
for await (const line of proc.stdout) {
const str = new TextDecoder().decode(line);
if (str.includes("ready")) {
await Bun.write(join(String(dir), "script.js"), `console.log("modified");`);
break;
}
}
proc.kill();
await proc.exited;
// Check that trace file was created
expect(existsSync(traceFile)).toBe(true);
const traceContent = readFileSync(traceFile, "utf-8");
const lines = traceContent
.trim()
.split("\n")
.filter(l => l.trim());
// Should have at least one event
expect(lines.length).toBeGreaterThan(0);
// Parse and validate JSON structure
for (const line of lines) {
const event = JSON.parse(line);
// Check required fields exist
expect(event).toHaveProperty("timestamp");
expect(event).toHaveProperty("index");
expect(event).toHaveProperty("path");
expect(event).toHaveProperty("events");
expect(event).toHaveProperty("changed_files");
// Validate types
expect(typeof event.timestamp).toBe("number");
expect(typeof event.index).toBe("number");
expect(typeof event.path).toBe("string");
expect(Array.isArray(event.events)).toBe(true);
expect(Array.isArray(event.changed_files)).toBe(true);
}
}, 10000);
test("BUN_WATCHER_TRACE with --watch flag", async () => {
using dir = tempDir("watcher-trace-watch", {
"script.js": `console.log("run", 0);`,
});
const traceFile = join(String(dir), "watch-trace.log");
const env = { ...bunEnv, BUN_WATCHER_TRACE: traceFile };
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "script.js"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "inherit",
stdin: "ignore",
});
let i = 0;
for await (const line of proc.stdout) {
const str = new TextDecoder().decode(line);
if (str.includes(`run ${i}`)) {
i++;
if (i === 3) break; // Stop after 3 runs
await Bun.write(join(String(dir), "script.js"), `console.log("run", ${i});`);
}
}
proc.kill();
await proc.exited;
// Check that trace file was created
expect(existsSync(traceFile)).toBe(true);
const traceContent = readFileSync(traceFile, "utf-8");
const lines = traceContent
.trim()
.split("\n")
.filter(l => l.trim());
// Should have events from watching script.js
expect(lines.length).toBeGreaterThan(0);
// Validate JSON structure and find script.js events
let foundScriptEvent = false;
for (const line of lines) {
const event = JSON.parse(line);
// Check required fields exist
expect(event).toHaveProperty("timestamp");
expect(event).toHaveProperty("index");
expect(event).toHaveProperty("path");
expect(event).toHaveProperty("events");
expect(event).toHaveProperty("changed_files");
// Validate types
expect(typeof event.timestamp).toBe("number");
expect(typeof event.index).toBe("number");
expect(typeof event.path).toBe("string");
expect(Array.isArray(event.events)).toBe(true);
expect(Array.isArray(event.changed_files)).toBe(true);
if (event.path.includes("script.js") || event.changed_files.some((f: string) => f?.includes("script.js"))) {
foundScriptEvent = true;
// Should have write event
expect(event.events).toContain("write");
}
}
expect(foundScriptEvent).toBe(true);
}, 10000);
test("BUN_WATCHER_TRACE with empty path does not create trace", async () => {
using dir = tempDir("watcher-trace-empty", {
"test.js": `console.log("ready");`,
});
const env = { ...bunEnv, BUN_WATCHER_TRACE: "" };
const proc = Bun.spawn({
cmd: [bunExe(), "--watch", "test.js"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "inherit",
stdin: "ignore",
});
// Wait for first run, then exit
for await (const line of proc.stdout) {
const str = new TextDecoder().decode(line);
if (str.includes("ready")) {
break;
}
}
proc.kill();
await proc.exited;
// Should not create any trace file in the directory
const files = Array.from(new Bun.Glob("*.log").scanSync({ cwd: String(dir) }));
expect(files.length).toBe(0);
});
test("BUN_WATCHER_TRACE appends across reloads", async () => {
using dir = tempDir("watcher-trace-append", {
"app.js": `console.log("first-0");`,
});
const traceFile = join(String(dir), "append-trace.log");
const env = { ...bunEnv, BUN_WATCHER_TRACE: traceFile };
// First run
const proc1 = Bun.spawn({
cmd: [bunExe(), "--watch", "app.js"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "inherit",
stdin: "ignore",
});
let i = 0;
for await (const line of proc1.stdout) {
const str = new TextDecoder().decode(line);
if (str.includes(`first-${i}`)) {
i++;
if (i === 2) break; // Stop after 2 runs
await Bun.write(join(String(dir), "app.js"), `console.log("first-${i}");`);
}
}
proc1.kill();
await proc1.exited;
const firstContent = readFileSync(traceFile, "utf-8");
const firstLines = firstContent
.trim()
.split("\n")
.filter(l => l.trim());
expect(firstLines.length).toBeGreaterThan(0);
// Second run - should append to the same file
const proc2 = Bun.spawn({
cmd: [bunExe(), "--watch", "app.js"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "inherit",
stdin: "ignore",
});
let j = 0;
for await (const line of proc2.stdout) {
const str = new TextDecoder().decode(line);
if (str.includes(`second-${j}`)) {
j++;
if (j === 2) break; // Stop after 2 runs
await Bun.write(join(String(dir), "app.js"), `console.log("second-${j}");`);
} else if (str.includes("first-1")) {
// Initial run, start modifying
await Bun.write(join(String(dir), "app.js"), `console.log("second-0");`);
}
}
proc2.kill();
await proc2.exited;
const secondContent = readFileSync(traceFile, "utf-8");
const secondLines = secondContent
.trim()
.split("\n")
.filter(l => l.trim());
// Should have more lines after second run
expect(secondLines.length).toBeGreaterThan(firstLines.length);
// All lines should be valid JSON
for (const line of secondLines) {
const event = JSON.parse(line);
expect(event).toHaveProperty("timestamp");
expect(event).toHaveProperty("path");
}
}, 10000);