Skip to content
Draft
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
5 changes: 5 additions & 0 deletions src/jsc/hot_reloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,11 @@ where
}

pub(crate) fn append(&mut self, id: u32) {
// A replaced file is reported by its directory and by itself.
if self.hashes[..self.count as usize].contains(&id) {
return;
}

if self.count == 8 {
self.enqueue();
self.count = 0;
Expand Down
56 changes: 50 additions & 6 deletions src/watcher/INotifyWatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use core::ffi::c_int;
use core::mem::{align_of, size_of};
use core::sync::atomic::{AtomicU32, Ordering};

use bun_core::{ZStr, env_var, output as Output};
use bun_core::{ZStr, env_var, output as Output, strings};
use bun_paths::MAX_PATH_BYTES;
use bun_sys::{self, Fd};
use bun_threading::Futex;
Expand Down Expand Up @@ -74,6 +74,12 @@ impl Default for INotifyWatcher {

pub(crate) type EventListIndex = c_int;

/// IN_ATTRIB reports the link-count change of an inode that a rename replaces: see `file_was_replaced`.
const WATCH_FILE_MASK: u32 = {
use bun_sys::linux::IN;
IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF | IN::MOVED_TO | IN::MODIFY | IN::ATTRIB
};

#[repr(C)]
pub struct Event {
pub watch_descriptor: EventListIndex,
Expand Down Expand Up @@ -127,14 +133,11 @@ impl Event {

impl INotifyWatcher {
pub(crate) fn watch_path(&mut self, pathname: &ZStr) -> bun_sys::Result<EventListIndex> {
use bun_sys::linux::IN;
debug_assert!(self.loaded);
let old_count = self.watch_count.fetch_add(1, Ordering::Release);
let watch_file_mask =
IN::EXCL_UNLINK | IN::MOVE_SELF | IN::DELETE_SELF | IN::MOVED_TO | IN::MODIFY;
// SAFETY: fd is a valid inotify fd (loaded == true), pathname is NUL-terminated.
let rc = unsafe {
bun_sys::linux::inotify_add_watch(self.fd.native(), pathname.as_ptr(), watch_file_mask)
bun_sys::linux::inotify_add_watch(self.fd.native(), pathname.as_ptr(), WATCH_FILE_MASK)
};
bun_core::scoped_log!(watcher, "inotify_add_watch({}) = {}", self.fd, rc);
let result = if rc < 0 {
Expand Down Expand Up @@ -451,7 +454,16 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> {
continue;
}
};
this.watch_events[event_id] = watch_event_from_inotify_event(event, idx);
let mut watch_event = watch_event_from_inotify_event(event, idx);
if (event.mask & bun_sys::linux::IN::ATTRIB) != 0 {
if file_was_replaced(this, idx, event.watch_descriptor) {
watch_event.op |= Op::DELETE;
} else if watch_event.op.is_empty() {
events_processed += 1;
continue;
}
}
this.watch_events[event_id] = watch_event;

// Safely handle event names with bounds checking
if event.name_len > 0 && (temp_name_off as usize) < temp_name_list.len() {
Expand Down Expand Up @@ -524,6 +536,38 @@ fn process_inotify_event_batch(
Ok(())
}

/// inotify returns another watch descriptor for the path of a watched file once that path names another inode.
fn file_was_replaced(this: &Watcher, index: WatchItemIndex, wd: EventListIndex) -> bool {
use crate::watcher_impl::{WatchItemColumns, WatchItemKind};
let mut buf = bun_paths::path_buffer_pool::get();
let path = {
let _guard = this.mutex.lock_guard();
let i = usize::from(index);
// `index` is from this cycle's snapshot; an eviction since then can have moved another item there.
if this.watchlist.items_eventlist_index().get(i) != Some(&wd)
|| this.watchlist.items_kind()[i] != WatchItemKind::File
{
return false;
}
let file_path: &[u8] = &this.watchlist.items_file_path()[i];
if file_path.len() >= buf.len() || strings::contains(file_path, b"node_modules") {
return false;
}
buf[..file_path.len()].copy_from_slice(file_path);
buf[file_path.len()] = 0;
ZStr::from_buf(&buf[..], file_path.len())
};
// SAFETY: the inotify fd stays open until this thread stops it; `path` is NUL-terminated.
let now = unsafe {
bun_sys::linux::inotify_add_watch(
this.platform.fd.native(),
path.as_ptr(),
WATCH_FILE_MASK | bun_sys::linux::IN::MASK_ADD,
)
};
now >= 0 && now != wd
}

fn watch_event_from_inotify_event(event: &Event, index: WatchItemIndex) -> WatchEvent {
use bun_sys::linux::IN;
let mut op = Op::empty();
Expand Down
32 changes: 32 additions & 0 deletions test/bake/dev/html.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// HTML tests are tests relating to HTML files themselves.
import { renameSync, writeFileSync } from "node:fs";
import { devTest, emptyHtmlFile } from "../bake-harness";

devTest("html file is watched", {
Expand Down Expand Up @@ -444,3 +445,34 @@ devTest("editing a file imported from outside the project root hot-reloads", {
await c.expectMessage("three");
},
});
devTest("replacing a file imported from outside the project root by rename hot-reloads", {
// This pins the inotify watcher. The Windows watcher does not watch files outside the project directory.
skip: ["win32", "darwin"],
files: {
"web/index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"web/index.ts": `
import { value } from "../outside/dep";
console.log(value);
import.meta.hot.accept();
`,
"outside/dep.ts": `
export const value = "one";
`,
},
cwd: "web",
async test(dev) {
await using c = await dev.client("/");
await c.expectMessage("one");
const dep = dev.join("outside/dep.ts");
for (const value of ["two", "three"]) {
{
await using _batch = await dev.batchChanges();
writeFileSync(dep + ".next", `export const value = "${value}";`);
renameSync(dep + ".next", dep);
}
await c.expectMessage(value);
}
},
});
Loading
Loading