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
32 changes: 19 additions & 13 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3684,19 +3684,25 @@ pub mod bv2_impl {
Ok(r) => r,
Err(err) => {
let dev = self.dev_server.expect("unreachable");
dev.handle_parse_task_failure(
err,
if flags.client() {
bake::Graph::Client
} else {
bake::Graph::Server
},
abs_path,
// SAFETY: `transpiler` points at one of self's transpilers, live for `'a`.
unsafe { (*transpiler).log }.cast_const(),
std::ptr::from_mut(self),
)
.expect("oom");
// Each graph that has the file bundles the importers of the file again.
for graph in [
flags.client().then_some(bake::Graph::Client),
(!flags.client() || flags.server() || flags.ssr())
.then_some(bake::Graph::Server),
]
.into_iter()
.flatten()
{
dev.handle_parse_task_failure(
err,
graph,
abs_path,
// SAFETY: `transpiler` points at one of self's transpilers, live for `'a`.
unsafe { (*transpiler).log }.cast_const(),
std::ptr::from_mut(self),
)
.expect("oom");
}
// SAFETY: `transpiler.log` is the `'a`-owned log; sole writer here.
unsafe { (*(*transpiler).log).reset() };
continue;
Expand Down
4 changes: 2 additions & 2 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ impl<'a> Transpiler<'a> {
dir,
bun_paths::SEP,
);
break 'name self.resolver.bust_dir_cache(
break 'name self.resolver.bust_dir_cache_key(
bun_paths::string_paths::without_trailing_slash_windows_path(
buster_name,
),
Expand All @@ -431,7 +431,7 @@ impl<'a> Transpiler<'a> {
else {
break 'name false;
};
self.resolver.bust_dir_cache(
self.resolver.bust_dir_cache_key(
bun_paths::string_paths::without_trailing_slash_windows_path(
buster_name.as_bytes(),
),
Expand Down
5 changes: 5 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export const sslCtxLiveCount = $bindgen("SecureContext.bind.ts", "liveCount");

export const napiThreadsafeFunctionLiveCount = $bindgen("napi_body.bind.ts", "threadsafeFunctionLiveCount");

/** Makes the resolver read the directory `path` again, as a watcher does when the directory changed. */
export const bustResolverDirCache = $bindgen("BunObjectInternals.bind.ts", "bustResolverDirCache") as (
path: string,
) => void;

export const bundlerWorkerLiveCount = $bindgen("JSBundler.bind.ts", "workerLiveCount");

export const semverIntersects = $bindgen("SemverObjectInternals.bind.ts", "intersects") as (
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5498,7 +5498,7 @@ impl VirtualMachine {
};

// Only re-query if we previously had something cached.
if self.transpiler.resolver.bust_dir_cache(
if self.transpiler.resolver.bust_dir_cache_key(
bun_paths::string_paths::without_trailing_slash_windows_path(buster_name),
) {
continue;
Expand Down
130 changes: 126 additions & 4 deletions src/resolver/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,108 @@ pub mod fs {
/// The active filesystem backend (always the real filesystem).
pub type Implementation = RealFS;

/// Which cache keys of a directory a bust evicts.
#[derive(Clone, Copy)]
pub(crate) enum DirKeys {
/// Its real path and that path through each link followed: the directory changed on disk.
Every,
/// The given path alone: a guess after a miss, which names the path that the lookup walked.
Walked,
}

/// Every cache key of a directory: its real path, and that path through each link followed.
#[derive(Default)]
struct DirLinks {
/// Path of a link as walked, to the real path of the directory.
real_of: bun_collections::StringHashMap<&'static [u8]>,
/// Real path of a directory, to the paths of the links to it.
links_of: bun_collections::StringHashMap<bun_collections::SmallList<&'static [u8], 1>>,
}

impl DirLinks {
fn insert(&mut self, link: &'static [u8], real: &'static [u8]) {
if self.real_of.get(link) == Some(&real) {
return;
}
bun_core::handle_oom(self.real_of.put_static_key(link, real));
match self.links_of.get_mut(real) {
Some(links) if links.0.contains(&link) => {}
Some(links) => links.0.push(link),
None => bun_core::handle_oom(
self.links_of
.put_static_key(real, core::iter::once(link).collect()),
),
}
}

/// Calls `each` with `path`, then with every other cache key of that directory.
fn for_each_key_of(&self, path: &[u8], mut each: impl FnMut(&[u8])) {
each(path);
if self.real_of.is_empty() {
return;
}

let mut real_buf = bun_paths::path_buffer_pool::get();
let mut key_buf = bun_paths::path_buffer_pool::get();
// The record of the deepest link in `path` holds the real path of that link in full.
let deepest_link = Self::ancestors(path)
.find_map(|ancestor| Some((ancestor.len(), *self.real_of.get(ancestor)?)));
let real: &[u8] = match deepest_link {
Some((link_len, real_of_link)) => {
let Some(len) =
Self::join_below(&mut real_buf[..], real_of_link, &path[link_len..])
else {
return;
};
each(&real_buf[..len]);
&real_buf[..len]
}
None => path,
};

for ancestor in Self::ancestors(real) {
let Some(links) = self.links_of.get(ancestor) else {
continue;
};
for link in links {
let Some(len) =
Self::join_below(&mut key_buf[..], link, &real[ancestor.len()..])
else {
continue;
};
if &key_buf[..len] != path {
each(&key_buf[..len]);
}
}
}
}

/// `path`, then each directory above it up to the root, as cache keys.
fn ancestors<'p>(path: &'p [u8]) -> impl Iterator<Item = &'p [u8]> {
core::iter::successors(Some(path), |dir: &&'p [u8]| {
let parent = strings::paths::without_trailing_slash_windows_path(
crate::Dirname::dirname(dir),
);
(parent.len() < dir.len()).then_some(parent)
})
}

/// Writes `dir`, one separator and `below`: the rest of a path after a directory, which can be empty.
fn join_below(buf: &mut [u8], dir: &[u8], below: &[u8]) -> Option<usize> {
let below = strings::paths::without_leading_path_separator(below);
let dir_ends_in_sep = dir.last().is_some_and(|&last| bun_paths::is_sep_any(last));
let sep_len = usize::from(!below.is_empty() && !dir_ends_in_sep);
let len = dir.len() + sep_len + below.len();
if len > buf.len() {
return None;
}
buf[..dir.len()].copy_from_slice(dir);
buf[dir.len()..dir.len() + sep_len].fill(bun_paths::SEP);
buf[len - below.len()..len].copy_from_slice(below);
Some(len)
}
}

// ── RealFS ───────────────────────────────────────────────────────────

/// Real-filesystem backend: directory-entry cache plus fd-limit
Expand All @@ -900,6 +1002,8 @@ pub mod fs {
/// this directly (`rfs.entries.get_or_put(..)`); modeled as the wrapper
/// `EntriesMap` (bun_alloc has no BSSMap equivalent).
pub entries: EntriesMap,
/// Guarded by `entries_mutex`.
dir_links: DirLinks,
pub(crate) cwd: &'static [u8],
#[cfg(not(windows))]
pub(crate) file_limit: usize,
Expand All @@ -916,12 +1020,21 @@ pub mod fs {
RealFS {
entries_mutex: Mutex::default(),
entries: EntriesMap::new(),
dir_links: DirLinks::default(),
cwd,
#[cfg(not(windows))]
file_limit,
}
}

/// Records the directory link `link` to `real`. The caller holds `entries_mutex`.
pub(crate) fn record_dir_link(&mut self, link: &'static [u8], real: &'static [u8]) {
debug_assert!(self.entries_mutex.is_held_by_current_thread());
if link != real {
self.dir_links.insert(link, real);
}
}

/// Port of `RealFS.adjustUlimit` — always try to max out how many
/// files we can keep open. Returns 0 when the limit cannot be read:
/// `need_to_close_files` is then always true and no fd stays cached.
Expand Down Expand Up @@ -1192,17 +1305,26 @@ pub mod fs {
Ok(unsafe { &mut *out })
}

/// Evicts `file_path` from the directory-entry cache; returns whether
/// an entry was removed.
pub(crate) fn bust_entries_cache(&mut self, file_path: &[u8]) -> bool {
/// Evicts the listing of the directory `path` under `keys`. `each` gets a key and whether a listing went.
pub(crate) fn bust_entries_cache(
&mut self,
path: &[u8],
keys: DirKeys,
mut each: impl FnMut(&[u8], bool),
) {
// `entries` is the process-global
// BSSMap singleton and `remove` mutates it; callers (transpiler /
// hot-reloader / VM) reach this without `RESOLVER_MUTEX`, so take
// `entries_mutex` to satisfy `EntriesMap::inner`'s aliasing
// invariant. No caller already holds it (no re-entry from
// `read_directory`/`dir_info_cached_maybe_log`).
let _g = self.entries_mutex.lock_guard();
self.entries.remove(file_path)
let entries = &mut self.entries;
let mut evict = |key: &[u8]| each(key, entries.remove(key));
match keys {
DirKeys::Every => self.dir_links.for_each_key_of(path, evict),
DirKeys::Walked => evict(path),
}
}

/// lstat + (if symlink) open + fstat +
Expand Down
59 changes: 47 additions & 12 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2640,18 +2640,34 @@ impl<'a> Resolver<'a> {

/// Bust the directory cache for the given path, which may end in a separator.
pub fn bust_dir_cache(&mut self, path: &[u8]) -> bool {
self.bust_dir_cache_keys(path, Fs::DirKeys::Every)
}

/// `bust_dir_cache` for the one cache key `path`: what a retry after a miss drops.
pub fn bust_dir_cache_key(&mut self, path: &[u8]) -> bool {
self.bust_dir_cache_keys(path, Fs::DirKeys::Walked)
}

fn bust_dir_cache_keys(&mut self, path: &[u8], keys: Fs::DirKeys) -> bool {
let path = strings::without_trailing_slash_windows_path(path);
Self::assert_valid_cache_key(path);
let first_bust = self.fs_mut().fs.bust_entries_cache(path);
let second_bust = self.dir_cache_mut().remove(path);
bun_core::scoped_log!(
ResolverDev,
"Bust {} = {}, {}",
bstr::BStr::new(path),
first_bust,
second_bust
);
first_bust || second_bust
let dir_cache = self.dir_cache();
let mut any_bust = false;
self.fs_mut()
.fs
.bust_entries_cache(path, keys, |key, first_bust| {
// SAFETY: `dir_cache` is the process-lifetime `DirInfo` map, which locks itself.
let second_bust = unsafe { &mut *dir_cache }.remove(key);
bun_core::scoped_log!(
ResolverDev,
"Bust {} = {}, {}",
bstr::BStr::new(key),
first_bust,
second_bust
);
any_bust |= first_bust || second_bust;
});
any_bust
}

/// bust both the named file and a parent directory, because `./hello` can resolve
Expand Down Expand Up @@ -2690,8 +2706,8 @@ impl<'a> Resolver<'a> {
let path = strings::without_trailing_slash_windows_path(path);
let dir = bun_paths::dirname_platform(path, bun_paths::Platform::AUTO);

let a = self.bust_dir_cache(dir);
let b = self.bust_dir_cache(path);
let a = self.bust_dir_cache_key(dir);
let b = self.bust_dir_cache_key(path);
a || b
}

Expand Down Expand Up @@ -6574,6 +6590,25 @@ impl<'a> Resolver<'a> {
logs.add_note(buf);
}
info.abs_real_path = symlink;
// A directory below a link has its real path from the parent. It is no link.
let parent_real = if parent_.abs_real_path.is_empty() {
parent_.abs_path
} else {
parent_.abs_real_path
};
let below_parent = self.fs_ref().abs_buf_checked(
&[parent_real, base],
bufs!(dir_info_uncached_filename),
);
let real = strings::without_trailing_slash_windows_path(symlink);
if below_parent != Some(real) {
// SAFETY: `rfs_ptr` points at the process-global RealFS, and the
// caller holds `entries_mutex`.
unsafe { &mut *rfs_ptr }.record_dir_link(
strings::without_trailing_slash_windows_path(path),
real,
);
}
} else if !parent_.abs_real_path.is_empty()
&& let Some(joined) = self.fs_ref().abs_buf_checked(
&[parent_.abs_real_path, base],
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,15 @@ fn get_inspect(global_object: &JSGlobalObject, _: &JSObject) -> JSValue {
fun
}

/// `bun:internal-for-testing`: see `Resolver::bust_dir_cache`.
pub(crate) fn bust_resolver_dir_cache(path: &[u8]) {
let _ = VirtualMachine::get()
.as_mut()
.transpiler
.resolver
.bust_dir_cache(path);
}

pub(crate) fn register_macro<'a>(
heap: &JsHeap<'a>,
id: f64,
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/api/BunObjectInternals.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ export const registerMacro = b.fn({
capability: "heap",
throws: true,
});

/** `bun:internal-for-testing`: the bust that a watcher sends for a directory that changed. */
export const bustResolverDirCache = b.fn({ args: { path: b.string.utf8 } });
4 changes: 2 additions & 2 deletions src/runtime/api/filesystem_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ impl FileSystemRouter {
// `abs()` writes into a thread-local buffer; copy out
// before recursing (recursion overwrites it).
let full_path = vm.fs().abs(&abs_parts).to_vec();
let _ = vm.as_mut().transpiler.resolver.bust_dir_cache(
let _ = vm.as_mut().transpiler.resolver.bust_dir_cache_key(
strings::paths::without_trailing_slash_windows_path(&full_path),
);
Self::bust_dir_cache_recursive(&full_path);
Expand All @@ -217,7 +217,7 @@ impl FileSystemRouter {
}
}

let _ = vm.as_mut().transpiler.resolver.bust_dir_cache(path);
let _ = vm.as_mut().transpiler.resolver.bust_dir_cache_key(path);
}
}

Expand Down
12 changes: 12 additions & 0 deletions test/bake/bake-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export interface DevServerTest {

/** Starting files */
files?: FileObject;
/**
* Directory links to create after `files` are written and before the
* server starts. Each key is the path of a link and each value is its
* target, relative to the directory of the link.
*/
symlinks?: Record<string, string>;
/** Manually specify which html files to serve */
htmlFiles?: string[];
/**
Expand Down Expand Up @@ -1968,6 +1974,12 @@ function testImpl<T extends DevServerTest>(
x => path.join(root, x),
);
await writeAll(root, options.files);
for (const [link, target] of Object.entries(options.symlinks ?? {})) {
const linkPath = path.join(root, link);
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
// A junction needs an absolute target. A POSIX link stays relative, as an install writes it.
fs.symlinkSync(isWindows ? path.resolve(path.dirname(linkPath), target) : target, linkPath, "junction");
}
const runInstall = options.framework === "react";
if (runInstall) {
// await copyCachedReactDeps(root);
Expand Down
Loading
Loading