Files
bun.sh/src/bun.js/node/node_fs_stat_watcher.zig
robobun 46e7a3b3c5 Implement birthtime support on Linux using statx syscall (#23209)
## Summary

- Adds birthtime (file creation time) support on Linux using the `statx`
syscall
- Stores birthtime in architecture-specific unused fields of the kernel
Stat struct (x86_64 and aarch64)
- Falls back to traditional `stat` on kernels < 4.11 that don't support
`statx`
- Includes comprehensive tests validating birthtime behavior

Fixes #6585

## Implementation Details

**src/sys.zig:**
- Added `StatxField` enum for field selection
- Implemented `statxImpl()`, `fstatx()`, `statx()`, and `lstatx()`
functions
- Stores birthtime in unused padding fields (architecture-specific for
x86_64 and aarch64)
- Graceful fallback to traditional stat if statx is not supported

**src/bun.js/node/node_fs.zig:**
- Updated `stat()`, `fstat()`, and `lstat()` to use statx functions on
Linux

**src/bun.js/node/Stat.zig:**
- Added `getBirthtime()` helper to extract birthtime from
architecture-specific storage

**test/js/node/fs/fs-birthtime-linux.test.ts:**
- Tests non-zero birthtime values
- Verifies birthtime immutability across file modifications
- Validates consistency across stat/lstat/fstat
- Tests BigInt stats with nanosecond precision
- Verifies birthtime ordering relative to other timestamps

## Test Plan

- [x] Run `bun bd test test/js/node/fs/fs-birthtime-linux.test.ts` - all
5 tests pass
- [x] Compare behavior with Node.js - identical behavior
- [x] Compare with system Bun - system Bun returns epoch, new
implementation returns real birthtime

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

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-04 04:57:29 -07:00

546 lines
20 KiB
Zig

const log = bun.Output.scoped(.StatWatcher, .visible);
fn statToJSStats(globalThis: *jsc.JSGlobalObject, stats: *const bun.sys.PosixStat, bigint: bool) bun.JSError!jsc.JSValue {
if (bigint) {
return StatsBig.init(stats).toJS(globalThis);
} else {
return StatsSmall.init(stats).toJS(globalThis);
}
}
/// This is a singleton struct that contains the timer used to schedule re-stat calls.
pub const StatWatcherScheduler = struct {
current_interval: std.atomic.Value(i32) = .{ .raw = 0 },
task: jsc.WorkPoolTask = .{ .callback = &workPoolCallback },
main_thread: std.Thread.Id,
vm: *bun.jsc.VirtualMachine,
watchers: WatcherQueue = WatcherQueue{},
event_loop_timer: EventLoopTimer = .{
.next = .epoch,
.tag = .StatWatcherScheduler,
},
ref_count: RefCount,
const RefCount = bun.ptr.ThreadSafeRefCount(StatWatcherScheduler, "ref_count", deinit, .{ .debug_name = "StatWatcherScheduler" });
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
const WatcherQueue = UnboundedQueue(StatWatcher, .next);
pub fn init(vm: *bun.jsc.VirtualMachine) bun.ptr.RefPtr(StatWatcherScheduler) {
return .new(.{
.ref_count = .init(),
.main_thread = std.Thread.getCurrentId(),
.vm = vm,
});
}
fn deinit(this: *StatWatcherScheduler) void {
bun.assertf(this.watchers.isEmpty(), "destroying StatWatcherScheduler while it still has watchers", .{});
bun.destroy(this);
}
pub fn append(this: *StatWatcherScheduler, watcher: *StatWatcher) void {
log("append new watcher {s}", .{watcher.path});
bun.assert(watcher.closed == false);
bun.assert(watcher.next == null);
watcher.ref();
this.watchers.push(watcher);
log("push watcher {x}", .{@intFromPtr(watcher)});
const current = this.getInterval();
if (current == 0 or current > watcher.interval) {
// we are not running or the new watcher has a smaller interval
this.setInterval(watcher.interval);
}
}
fn getInterval(this: *StatWatcherScheduler) i32 {
return this.current_interval.load(.monotonic);
}
/// Update the current interval and set the timer (this function is thread safe)
fn setInterval(this: *StatWatcherScheduler, interval: i32) void {
this.ref();
this.current_interval.store(interval, .monotonic);
if (this.main_thread == std.Thread.getCurrentId()) {
// we are in the main thread we can set the timer
this.setTimer(interval);
return;
}
// we are not in the main thread we need to schedule a task to set the timer
this.scheduleTimerUpdate();
}
/// Set the timer (this function is not thread safe, should be called only from the main thread)
fn setTimer(this: *StatWatcherScheduler, interval: i32) void {
// if the interval is 0 means that we stop the timer
if (interval == 0) {
// if the timer is active we need to remove it
if (this.event_loop_timer.state == .ACTIVE) {
this.vm.timer.remove(&this.event_loop_timer);
}
return;
}
// reschedule the timer
this.vm.timer.update(&this.event_loop_timer, &bun.timespec.msFromNow(interval));
}
/// Schedule a task to set the timer in the main thread
fn scheduleTimerUpdate(this: *StatWatcherScheduler) void {
const Holder = struct {
scheduler: *StatWatcherScheduler,
task: jsc.AnyTask,
pub fn updateTimer(self: *@This()) void {
defer bun.default_allocator.destroy(self);
self.scheduler.setTimer(self.scheduler.getInterval());
}
};
const holder = bun.handleOom(bun.default_allocator.create(Holder));
holder.* = .{
.scheduler = this,
.task = jsc.AnyTask.New(Holder, Holder.updateTimer).init(holder),
};
this.vm.enqueueTaskConcurrent(jsc.ConcurrentTask.create(jsc.Task.init(&holder.task)));
}
pub fn timerCallback(this: *StatWatcherScheduler) EventLoopTimer.Arm {
const has_been_cleared = this.event_loop_timer.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running;
this.event_loop_timer.state = .FIRED;
this.event_loop_timer.heap = .{};
if (has_been_cleared) {
return .disarm;
}
jsc.WorkPool.schedule(&this.task);
return .disarm;
}
pub fn workPoolCallback(task: *jsc.WorkPoolTask) void {
var this: *StatWatcherScheduler = @alignCast(@fieldParentPtr("task", task));
// ref'd when the timer was scheduled
defer this.deref();
// Instant.now will not fail on our target platforms.
const now = std.time.Instant.now() catch unreachable;
var batch = this.watchers.popBatch();
log("pop batch of {d} watchers", .{batch.count});
var iter = batch.iterator();
var min_interval: i32 = std.math.maxInt(i32);
var closest_next_check: u64 = @intCast(min_interval);
var contain_watchers = false;
while (iter.next()) |watcher| {
if (watcher.closed) {
watcher.deref();
continue;
}
contain_watchers = true;
const time_since = now.since(watcher.last_check);
const interval = @as(u64, @intCast(watcher.interval)) * 1_000_000;
if (time_since >= interval -| 500) {
watcher.last_check = now;
watcher.restat();
} else {
closest_next_check = @min(interval - @as(u64, time_since), closest_next_check);
}
min_interval = @min(min_interval, watcher.interval);
this.watchers.push(watcher);
log("reinsert watcher {x}", .{@intFromPtr(watcher)});
}
if (contain_watchers) {
// choose the smallest interval or the closest time to the next check
this.setInterval(@min(min_interval, @as(i32, @intCast(closest_next_check))));
} else {
// we do not have watchers, we can stop the timer
this.setInterval(0);
}
}
};
// TODO: make this a top-level struct
pub const StatWatcher = struct {
pub const Scheduler = StatWatcherScheduler;
next: ?*StatWatcher = null,
ctx: *VirtualMachine,
ref_count: RefCount,
/// Closed is set to true to tell the scheduler to remove from list and deref.
closed: bool,
path: [:0]u8,
persistent: bool,
bigint: bool,
interval: i32,
last_check: std.time.Instant,
globalThis: *jsc.JSGlobalObject,
js_this: jsc.JSValue,
poll_ref: bun.Async.KeepAlive = .{},
last_stat: bun.sys.PosixStat,
last_jsvalue: jsc.Strong.Optional,
scheduler: bun.ptr.RefPtr(StatWatcherScheduler),
const RefCount = bun.ptr.ThreadSafeRefCount(StatWatcher, "ref_count", deinit, .{ .debug_name = "StatWatcher" });
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
pub const js = jsc.Codegen.JSStatWatcher;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
pub fn eventLoop(this: StatWatcher) *EventLoop {
return this.ctx.eventLoop();
}
pub fn enqueueTaskConcurrent(this: StatWatcher, task: *jsc.ConcurrentTask) void {
this.eventLoop().enqueueTaskConcurrent(task);
}
pub fn deinit(this: *StatWatcher) void {
log("deinit {x}", .{@intFromPtr(this)});
if (this.persistent) {
this.persistent = false;
this.poll_ref.unref(this.ctx);
}
this.closed = true;
this.last_jsvalue.deinit();
bun.default_allocator.free(this.path);
bun.default_allocator.destroy(this);
}
pub const Arguments = struct {
path: PathLike,
listener: jsc.JSValue,
persistent: bool,
bigint: bool,
interval: i32,
global_this: *jsc.JSGlobalObject,
pub fn fromJS(global: *jsc.JSGlobalObject, arguments: *ArgumentsSlice) bun.JSError!Arguments {
const path = try PathLike.fromJSWithAllocator(global, arguments, bun.default_allocator) orelse {
return global.throwInvalidArguments("filename must be a string or TypedArray", .{});
};
var listener: jsc.JSValue = .zero;
var persistent: bool = true;
var bigint: bool = false;
var interval: i32 = 5007;
if (arguments.nextEat()) |options_or_callable| {
// options
if (options_or_callable.isObject()) {
// default true
persistent = (try options_or_callable.getBooleanStrict(global, "persistent")) orelse true;
// default false
bigint = (try options_or_callable.getBooleanStrict(global, "bigint")) orelse false;
if (try options_or_callable.get(global, "interval")) |interval_| {
if (!interval_.isNumber() and !interval_.isAnyInt()) {
return global.throwInvalidArguments("interval must be a number", .{});
}
interval = try interval_.coerce(i32, global);
}
}
}
if (arguments.nextEat()) |listener_| {
if (listener_.isCallable()) {
listener = listener_.withAsyncContextIfNeeded(global);
}
}
if (listener == .zero) {
return global.throwInvalidArguments("Expected \"listener\" callback", .{});
}
return Arguments{
.path = path,
.listener = listener,
.persistent = persistent,
.bigint = bigint,
.interval = interval,
.global_this = global,
};
}
pub fn createStatWatcher(this: Arguments) !jsc.JSValue {
const obj = try StatWatcher.init(this);
if (obj.js_this != .zero) {
return obj.js_this;
}
return .js_undefined;
}
};
pub fn doRef(this: *StatWatcher, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (!this.closed and !this.persistent) {
this.persistent = true;
this.poll_ref.ref(this.ctx);
}
return .js_undefined;
}
pub fn doUnref(this: *StatWatcher, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.persistent) {
this.persistent = false;
this.poll_ref.unref(this.ctx);
}
return .js_undefined;
}
/// Stops file watching but does not free the instance.
pub fn close(this: *StatWatcher) void {
if (this.persistent) {
this.persistent = false;
this.poll_ref.unref(this.ctx);
}
this.closed = true;
this.last_jsvalue.clearWithoutDeallocation();
}
pub fn doClose(this: *StatWatcher, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
this.close();
return .js_undefined;
}
/// If the scheduler is not using this, free instantly, otherwise mark for being freed.
pub fn finalize(this: *StatWatcher) void {
log("Finalize\n", .{});
this.closed = true;
this.scheduler.deref();
this.deref(); // but don't deinit until the scheduler drops its reference
}
pub const InitialStatTask = struct {
watcher: *StatWatcher,
task: jsc.WorkPoolTask = .{ .callback = &workPoolCallback },
pub fn createAndSchedule(watcher: *StatWatcher) void {
const task = bun.new(InitialStatTask, .{ .watcher = watcher });
jsc.WorkPool.schedule(&task.task);
}
fn workPoolCallback(task: *jsc.WorkPoolTask) void {
const initial_stat_task: *InitialStatTask = @fieldParentPtr("task", task);
defer bun.destroy(initial_stat_task);
const this = initial_stat_task.watcher;
if (this.closed) {
return;
}
const stat: bun.sys.Maybe(bun.sys.PosixStat) = if (bun.Environment.isLinux and bun.sys.supports_statx_on_linux.load(.monotonic))
bun.sys.statx(this.path, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })
else brk: {
const result = bun.sys.stat(this.path);
break :brk switch (result) {
.result => |r| bun.sys.Maybe(bun.sys.PosixStat){ .result = bun.sys.PosixStat.init(&r) },
.err => |e| bun.sys.Maybe(bun.sys.PosixStat){ .err = e },
};
};
switch (stat) {
.result => |res| {
// we store the stat, but do not call the callback
this.last_stat = res;
this.enqueueTaskConcurrent(jsc.ConcurrentTask.fromCallback(this, initialStatSuccessOnMainThread));
},
.err => {
// on enoent, eperm, we call cb with two zeroed stat objects
// and store previous stat as a zeroed stat object, and then call the callback.
this.last_stat = std.mem.zeroes(bun.sys.PosixStat);
this.enqueueTaskConcurrent(jsc.ConcurrentTask.fromCallback(this, initialStatErrorOnMainThread));
},
}
}
};
pub fn initialStatSuccessOnMainThread(this: *StatWatcher) void {
if (this.closed) {
return;
}
const jsvalue = statToJSStats(this.globalThis, &this.last_stat, this.bigint) catch return; // TODO: properly propagate exception upwards
this.last_jsvalue = .create(jsvalue, this.globalThis);
this.scheduler.data.append(this);
}
pub fn initialStatErrorOnMainThread(this: *StatWatcher) void {
if (this.closed) {
return;
}
const jsvalue = statToJSStats(this.globalThis, &this.last_stat, this.bigint) catch return; // TODO: properly propagate exception upwards
this.last_jsvalue = .create(jsvalue, this.globalThis);
_ = js.listenerGetCached(this.js_this).?.call(
this.globalThis,
.js_undefined,
&[2]jsc.JSValue{
jsvalue,
jsvalue,
},
) catch |err| this.globalThis.reportActiveExceptionAsUnhandled(err);
if (this.closed) {
return;
}
this.scheduler.data.append(this);
}
/// Called from any thread
pub fn restat(this: *StatWatcher) void {
log("recalling stat", .{});
const stat: bun.sys.Maybe(bun.sys.PosixStat) = if (bun.Environment.isLinux and bun.sys.supports_statx_on_linux.load(.monotonic))
bun.sys.statx(this.path, &.{ .type, .mode, .nlink, .uid, .gid, .atime, .mtime, .ctime, .btime, .ino, .size, .blocks })
else brk: {
const result = bun.sys.stat(this.path);
break :brk switch (result) {
.result => |r| bun.sys.Maybe(bun.sys.PosixStat){ .result = bun.sys.PosixStat.init(&r) },
.err => |e| bun.sys.Maybe(bun.sys.PosixStat){ .err = e },
};
};
const res = switch (stat) {
.result => |res| res,
.err => std.mem.zeroes(bun.sys.PosixStat),
};
// Ignore atime changes when comparing stats
// Compare field-by-field to avoid false positives from padding bytes
if (res.dev == this.last_stat.dev and
res.ino == this.last_stat.ino and
res.mode == this.last_stat.mode and
res.nlink == this.last_stat.nlink and
res.uid == this.last_stat.uid and
res.gid == this.last_stat.gid and
res.rdev == this.last_stat.rdev and
res.size == this.last_stat.size and
res.blksize == this.last_stat.blksize and
res.blocks == this.last_stat.blocks and
res.mtim.sec == this.last_stat.mtim.sec and
res.mtim.nsec == this.last_stat.mtim.nsec and
res.ctim.sec == this.last_stat.ctim.sec and
res.ctim.nsec == this.last_stat.ctim.nsec and
res.birthtim.sec == this.last_stat.birthtim.sec and
res.birthtim.nsec == this.last_stat.birthtim.nsec)
return;
this.last_stat = res;
this.enqueueTaskConcurrent(jsc.ConcurrentTask.fromCallback(this, swapAndCallListenerOnMainThread));
}
/// After a restat found the file changed, this calls the listener function.
pub fn swapAndCallListenerOnMainThread(this: *StatWatcher) void {
const prev_jsvalue = this.last_jsvalue.swap();
const current_jsvalue = statToJSStats(this.globalThis, &this.last_stat, this.bigint) catch return; // TODO: properly propagate exception upwards
this.last_jsvalue.set(this.globalThis, current_jsvalue);
_ = js.listenerGetCached(this.js_this).?.call(
this.globalThis,
.js_undefined,
&[2]jsc.JSValue{
current_jsvalue,
prev_jsvalue,
},
) catch |err| this.globalThis.reportActiveExceptionAsUnhandled(err);
}
pub fn init(args: Arguments) !*StatWatcher {
log("init", .{});
const buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(buf);
var slice = args.path.slice();
if (bun.strings.startsWith(slice, "file://")) {
slice = slice[6..];
}
var parts = [_]string{slice};
const file_path = Path.joinAbsStringBuf(
fs.FileSystem.instance.top_level_dir,
buf,
&parts,
.auto,
);
const alloc_file_path = try bun.default_allocator.allocSentinel(u8, file_path.len, 0);
errdefer bun.default_allocator.free(alloc_file_path);
@memcpy(alloc_file_path, file_path);
var this = try bun.default_allocator.create(StatWatcher);
const vm = args.global_this.bunVM();
this.* = .{
.ctx = vm,
.persistent = args.persistent,
.bigint = args.bigint,
.interval = @max(5, args.interval),
.globalThis = args.global_this,
.js_this = .zero,
.closed = false,
.path = alloc_file_path,
// Instant.now will not fail on our target platforms.
.last_check = std.time.Instant.now() catch unreachable,
// InitStatTask is responsible for setting this
.last_stat = std.mem.zeroes(bun.sys.PosixStat),
.last_jsvalue = .empty,
.scheduler = vm.rareData().nodeFSStatWatcherScheduler(vm),
.ref_count = .init(),
};
errdefer this.deinit();
if (this.persistent) {
this.poll_ref.ref(this.ctx);
}
const js_this = StatWatcher.toJS(this, this.globalThis);
this.js_this = js_this;
js.listenerSetCached(js_this, this.globalThis, args.listener);
InitialStatTask.createAndSchedule(this);
return this;
}
};
const string = []const u8;
const Path = @import("../../resolver/resolve_path.zig");
const fs = @import("../../fs.zig");
const std = @import("std");
const bun = @import("bun");
const Output = bun.Output;
const UnboundedQueue = bun.threading.UnboundedQueue;
const EventLoopTimer = bun.api.Timer.EventLoopTimer;
const jsc = bun.jsc;
const EventLoop = jsc.EventLoop;
const VirtualMachine = jsc.VirtualMachine;
const ArgumentsSlice = jsc.CallFrame.ArgumentsSlice;
const PathLike = jsc.Node.PathLike;
const StatsBig = bun.jsc.Node.StatsBig;
const StatsSmall = bun.jsc.Node.StatsSmall;