Skip to content
Merged
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
23 changes: 15 additions & 8 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4373,14 +4373,21 @@ pub mod bv2_impl {
};

// Failures to watch are intentionally ignored.
let _ = this.bun_watcher_mut().unwrap().add_file::<true>(
fd,
&load.path,
bun_wyhash::hash(load.path.as_ref()) as u32,
bun_watcher::Loader(code.loader as u8),
bun_sys::Fd::INVALID,
None,
);
if !matches!(
this.bun_watcher_mut().unwrap().add_file::<true>(
fd,
&load.path,
bun_wyhash::hash(load.path.as_ref()) as u32,
bun_watcher::Loader(code.loader as u8),
bun_sys::Fd::INVALID,
None,
),
Ok(bun_watcher::FdOwnership::Watcher)
) && fd.is_valid()
{
// Not adopted; close the fd opened above.
let _ = bun_sys::close(fd);
}
}
}
}
Expand Down
61 changes: 8 additions & 53 deletions src/jsc/AsyncModule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@
use bun_install::dependency::Dependency;
use bun_install::{DependencyID, Resolution};
use bun_io::KeepAlive;
use bun_options_types::LoaderExt as _;
use bun_options_types::schema::api;
use bun_resolver::fs as Fs;
use bun_resolver::package_json::PackageJSON;
use bun_sys::Fd;

use crate::virtual_machine::VirtualMachine;
use crate::{
Expand All @@ -26,10 +22,6 @@
pub specifier: &'a [u8],
pub path: Fs::Path<'a>,
pub promise_ptr: Option<*mut *mut JSInternalPromise>,
pub fd: Option<Fd>,
pub package_json: Option<&'a PackageJSON>,
pub loader: bun_ast::Loader,
pub hash: u32,
pub arena: Box<ArenaAllocator>,
/// Backs `parse_result`'s small `AstVec`s (inline bump chunk); must stay
/// alive alongside `arena` until the module finishes loading.
Expand All @@ -46,14 +38,9 @@
pub(crate) string_buf: Box<[u8]>,
referrer_len: u32,
specifier_len: u32,
// `?*PackageJSON` / `*JSGlobalObject` — both are VM-lifetime
// backrefs (BACKREF/JSC_BORROW class in LIFETIMES.tsv). `package_json` is
// stored as a raw ptr so `AsyncModule` is `'static`-embeddable in
// `Queue`/`VirtualMachine` without a phantom lifetime; `global_this` uses
// [`crate::GlobalRef`] which encapsulates the single audited deref.
pub(crate) package_json: Option<core::ptr::NonNull<PackageJSON>>,
pub(crate) loader: api::Loader,
pub(crate) hash: u32, // default = u32::MAX
// `*JSGlobalObject` is a VM-lifetime backref (BACKREF/JSC_BORROW class in
// LIFETIMES.tsv); [`crate::GlobalRef`] encapsulates the single audited
// deref.
Comment thread
robobun marked this conversation as resolved.
pub global_this: crate::GlobalRef,
pub(crate) arena: Box<ArenaAllocator>,
/// See [`InitOpts::ast_alloc_state`].
Expand Down Expand Up @@ -255,7 +242,6 @@
use core::sync::atomic::Ordering;
use std::io::Write as _;

use bun_core::strings;
use bun_install::package_manager::run_tasks;
use bun_install::{self as install, LogLevel, PackageID};

Expand Down Expand Up @@ -654,9 +640,6 @@
string_buf,
referrer_len,
specifier_len,
package_json: opts.package_json.map(core::ptr::NonNull::from),
loader: opts.loader.to_api(),
hash: opts.hash,
// .stmt_blocks = stmt_blocks,
// .expr_blocks = expr_blocks,
global_this: crate::GlobalRef::new(global_object),
Expand Down Expand Up @@ -1228,11 +1211,10 @@
self.parse_result = parse_result;
// `print_with_source_map` consumes `ParseResult` by
// value (it moves `ast` into `print_ast`). Hoist the post-print
// reads (`is_commonjs_module` / `input_fd`) above the move so we
// read (`is_commonjs_module`) above the move so we
// can `mem::take` instead of cloning.
let is_commonjs_module = self.parse_result.ast.has_commonjs_export_names
|| self.parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs;
let input_fd = self.parse_result.input_fd;
let arena = *self.parse_result.ast.parts.allocator();
let parse_result = core::mem::replace(&mut self.parse_result, ParseResult::empty(arena));

Expand Down Expand Up @@ -1303,6 +1285,10 @@
);
}

// No watcher registration here: `maybe_watch_file` already ran before
// the enqueue, and `parse_result.input_fd` may have been closed (and
// the number recycled) by the transpile frame's fd guard.

Check warning on line 1290 in src/jsc/AsyncModule.rs

View check run for this annotation

Claude / Claude Code Review

ParseResult::input_fd is now a dead field

Removing the deferred watcher re-add here (and the `AsyncModule`/`InitOpts` fields that fed it) leaves `ParseResult::input_fd` with no readers anywhere in the codebase — it's now written at every construction site and threaded through `parse_data_loader`/`parse_text_loader`/`parse_md_loader`/`parse_wasm_loader`/`ParseResult::file` as a pass-through parameter, but never read. Per REVIEW.md ("Delete dead code in the same PR that makes it dead — required scope: fields nothing reads, parameters disc
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

// SAFETY: per-thread VM.
if unsafe { (*jsc_vm).is_watcher_enabled() } {
// SAFETY: per-thread VM.
Expand All @@ -1315,37 +1301,6 @@
)
};

if let Some(fd_) = input_fd {
if bun_paths::is_absolute(path.text)
&& !strings::contains(path.text, b"node_modules")
{
// SAFETY: `bun_watcher` is the `*mut ImportWatcher` set
// when `is_watcher_enabled()`; cast recovers the
// concrete type (matches VirtualMachine.rs:2301).
let watcher = unsafe {
&mut *(*jsc_vm)
.bun_watcher
.cast::<crate::hot_reloader::ImportWatcher>()
};
// `bun_watcher::PackageJSON` is an opaque
// forward-decl of `bun_resolver::PackageJSON`;
// the watcher only stores the pointer, so cast through.
// SAFETY: `package_json` (when set) is a VM-lifetime
// backref — outlives the watcher entry.
let package_json = self
.package_json
.map(|p| unsafe { &*p.as_ptr().cast::<bun_watcher::PackageJSON>() });
let _ = watcher.add_file::<true>(
fd_,
path.text,
self.hash,
bun_ast::Loader::from_api(self.loader),
Fd::INVALID,
package_json,
);
}
}

resolved_source.is_commonjs_module = is_commonjs_module;

return Ok(resolved_source);
Expand Down
41 changes: 15 additions & 26 deletions src/jsc/RuntimeTranspilerStore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,6 @@ impl TranspilerJob {
// as the `Transpiler<'_>` cast above.
transpiler.linker.resolver = ptr::addr_of_mut!(transpiler.resolver).cast();

let mut fd: Option<Fd> = None;
let mut package_json: Option<&'static bun_watcher::PackageJSON> = None;
let hash = Watcher::get_hash(path.text);

Expand All @@ -722,21 +721,9 @@ impl TranspilerJob {
let import_watcher: Option<bun_ptr::ParentRef<ImportWatcher, bun_ptr::Mut>> =
unsafe { bun_ptr::ParentRef::from_nullable_mut((*vm).bun_watcher.cast()) };
if let Some(iw) = import_watcher {
// The watchlist *is* mutated cross-thread (the watcher thread's
// `flush_evictions` closes fds and `swap_remove`s), so snapshot
// under the watcher mutex — see
// `ImportWatcher::snapshot_fd_and_package_json` doc for the EBADF
// race this closes.
(fd, package_json) = iw.snapshot_fd_and_package_json(hash);
// On Linux, `addFileByPathSlow` inserts watchlist entries with
// `fd = invalid_fd` (only kqueue needs the descriptor). Treat
// invalid as "no cached fd" so `readFileWithAllocator` opens the
// file instead of calling `seekTo` on a bogus handle. The snapshot
// helper already filtered `!is_valid()`; additionally reject
// stdio-tagged fds here.
if fd.is_some_and(|f| f.stdio_tag().is_some()) {
fd = None;
}
// Never read through the watchlist's stored fd; see
// `ImportWatcher::snapshot_package_json`.
Comment thread
robobun marked this conversation as resolved.
package_json = iw.snapshot_package_json(hash);
}

// this should be a cheap lookup because 24 bytes == 8 * 3 so it's read 3 machine words
Expand Down Expand Up @@ -769,15 +756,13 @@ impl TranspilerJob {
// only, so skipping `Drop` is sound.
let mut fallback_source = core::mem::MaybeUninit::<bun_ast::Source>::uninit();

// Usually, we want to close the input file automatically.
//
// If we're re-using the file descriptor from the fs watcher
// Do not close it because that will break the kqueue-based watcher
// Close the input file automatically unless the watcher adopts the
// descriptor after the parse (`add_file` below).
Comment thread
robobun marked this conversation as resolved.
//
// Note: stored in a `Cell` so the scopeguard closure can capture
// `&Cell<bool>` and the post-parse writes are visible to it without
// raw-pointer laundering (which the unused-assignment lint can't see).
let should_close_input_file_fd = Cell::new(fd.is_none());
let should_close_input_file_fd = Cell::new(true);

let mut input_file_fd: Fd = Fd::INVALID;

Expand All @@ -798,7 +783,7 @@ impl TranspilerJob {
path,
loader,
dirname_fd: Fd::INVALID,
file_descriptor: fd,
file_descriptor: None,
// SAFETY: `input_file_fd` is a stack local declared above and
// outlives `parse_options`; `addr_of_mut!` avoids forming an
// intermediate `&mut` so the close-guard's later borrow stays sound.
Expand Down Expand Up @@ -888,19 +873,21 @@ impl TranspilerJob {
&& bun_paths::is_absolute(path.text)
&& !strings::contains(path.text, b"node_modules")
{
should_close_input_file_fd.set(false);
if let Some(iw) = import_watcher {
// SAFETY: BACKREF — process-lifetime watcher; no other
// `&ImportWatcher` is live here, and `add_file` is
// thread-safe via watcher mutex.
let _ = unsafe { iw.assume_mut() }.add_file::<true>(
let added = unsafe { iw.assume_mut() }.add_file::<true>(
input_file_fd,
path.text,
hash,
loader,
Fd::INVALID,
package_json,
);
if matches!(added, Ok(bun_watcher::FdOwnership::Watcher)) {
should_close_input_file_fd.set(false);
}
}
}
}
Expand All @@ -914,19 +901,21 @@ impl TranspilerJob {
&& bun_paths::is_absolute(path.text)
&& !strings::contains(path.text, b"node_modules")
{
should_close_input_file_fd.set(false);
if let Some(iw) = import_watcher {
// SAFETY: BACKREF — process-lifetime watcher; no other
// `&ImportWatcher` is live here, and `add_file` is
// thread-safe via watcher mutex.
let _ = unsafe { iw.assume_mut() }.add_file::<true>(
let added = unsafe { iw.assume_mut() }.add_file::<true>(
input_file_fd,
path.text,
hash,
loader,
Fd::INVALID,
package_json,
);
if matches!(added, Ok(bun_watcher::FdOwnership::Watcher)) {
should_close_input_file_fd.set(false);
}
}
}
}
Expand Down
52 changes: 15 additions & 37 deletions src/jsc/hot_reloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,48 +39,26 @@ pub enum ImportWatcher {
const _: () = assert!(bun_watcher::Loader::File.0 == bun_ast::Loader::File as u8);

impl ImportWatcher {
/// Look up the cached fd (and `package_json` column) for `hash` under the
/// watcher's mutex, snapshotting both before returning.
/// Look up the `package_json` column for `hash` under the watcher's
/// mutex.
Comment thread
robobun marked this conversation as resolved.
///
/// The watcher thread's `flush_evictions` (called from `on_file_update`)
/// closes the cached fd in pass 1 and `swap_remove`s the entry in pass 2.
/// `on_file_update` orders `flush_evictions` *before* `enqueue` so the JS
/// thread cannot observe the closed-fd window for the *same* event, but
/// nothing serializes a *subsequent* event's `flush_evictions` against the
/// JS thread's previous-event reload that re-added the entry: the JS
/// thread can read the cached fd here while the watcher thread is between
/// pass 1 (close) and pass 2 (remove), surfacing as `EBADF reading
/// "<path>"` in `transpiler.rs:read_file_with_allocator` (hot.test.ts
/// "should work with sourcemap generation" on debian-aarch64). The race
/// is closed by locking the same mutex `append_file_maybe_lock<true>` and
/// `flush_evictions` take.
pub fn snapshot_fd_and_package_json(
/// Deliberately does NOT hand out the stored fd: the watchlist owns it
/// and `flush_evictions` closes it concurrently, so a reader would hit
/// `EBADF`/`EISDIR` after the mutex is released (watch-many-dirs.test.ts)
/// or read the stale pre-rename inode after an atomic save. Reloads open
/// the file by path; `Watcher::add_file` adopts the fresh descriptor.
Comment thread
robobun marked this conversation as resolved.
pub fn snapshot_package_json(
&self,
hash: bun_watcher::HashType,
) -> (
Option<bun_sys::Fd>,
Option<&'static bun_watcher::PackageJSON>,
) {
) -> Option<&'static bun_watcher::PackageJSON> {
let w = match self {
ImportWatcher::Hot(w) | ImportWatcher::Watch(w) => w,
ImportWatcher::None => return (None, None),
ImportWatcher::None => return None,
};
let _guard = w.mutex.lock_guard();
let Some(index) = w.index_of(hash) else {
return (None, None);
};
let watcher_fd = w.watchlist.items_fd()[index as usize];
let package_json = w
.watchlist
.items::<"package_json", Option<&'static bun_watcher::PackageJSON>>()[index as usize];
(
if watcher_fd.is_valid() {
Some(watcher_fd)
} else {
None
},
package_json,
)
let index = w.index_of(hash)?;
w.watchlist
.items::<"package_json", Option<&'static bun_watcher::PackageJSON>>()[index as usize]
}

#[inline]
Expand All @@ -106,7 +84,7 @@ impl ImportWatcher {
// Note: bun_watcher::PackageJSON is an opaque forward-decl;
// callers cast from `&bun_resolver::PackageJSON`.
package_json: Option<&'static bun_watcher::PackageJSON>,
) -> bun_sys::Result<()> {
) -> bun_sys::Result<bun_watcher::FdOwnership> {
match self {
ImportWatcher::Hot(watcher) | ImportWatcher::Watch(watcher) => watcher
.add_file::<COPY_FILE_PATH>(
Expand All @@ -117,7 +95,7 @@ impl ImportWatcher {
dir_fd,
package_json,
),
ImportWatcher::None => Ok(()),
ImportWatcher::None => Ok(bun_watcher::FdOwnership::Caller),
}
}
}
Expand Down
Loading
Loading