Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ new!(pub BUN_INSTALL_STREAMING_MIN_SIZE: unsigned, "BUN_INSTALL_STREAMING_MIN_SI
// thread schedules a drain; collapses the per-chunk thread-pool futex wake
// into roughly one per `threshold` bytes.
new!(pub BUN_INSTALL_STREAMING_DRAIN_THRESHOLD: unsigned, "BUN_INSTALL_STREAMING_DRAIN_THRESHOLD", { default: 256 * 1024 });
// The project a parent bun process holds the install lock of; see `lock_project` in bun_install.
new!(pub BUN_INTERNAL_INSTALL_LOCK_DIR: string, "BUN_INTERNAL_INSTALL_LOCK_DIR", {});
new!(pub BUN_NEEDS_PROC_SELF_WORKAROUND: boolean, "BUN_NEEDS_PROC_SELF_WORKAROUND", { default: false });
new!(pub BUN_OPTIONS: string, "BUN_OPTIONS", {});
new!(pub BUN_POSTGRES_SOCKET_MONITOR: string, "BUN_POSTGRES_SOCKET_MONITOR", {});
Expand Down
52 changes: 33 additions & 19 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ pub struct PackageManager {
/// Only set in `bun pm`
pub root_package_json_name_at_time_of_init: Box<[u8]>,

pub root_package_json_file: bun_sys::File,
/// Held from `lock_project` until the process exits.
pub(crate) project_lock: Option<bun_sys::File>,

/// The package id corresponding to the workspace the install is happening in. Could be root, or
/// could be any of the workspaces.
Expand Down Expand Up @@ -541,6 +542,22 @@ impl Subcommand {
pub(crate) fn should_chdir_to_root(self) -> bool {
!matches!(self, Self::Link)
}

/// `init` takes the project lock for these. `Pm` and `Audit` take it themselves when they edit.
pub(crate) fn always_edits_project(self) -> bool {
matches!(
self,
Self::Install
| Self::Update
| Self::Add
| Self::Remove
| Self::Link
| Self::Patch
| Self::PatchCommit
| Self::Dedupe
| Self::Prune
)
}
}

/// The resolved outcome of `--filter` for one install: the importer ids whose dependencies get installed.
Expand Down Expand Up @@ -1833,6 +1850,8 @@ pub fn init(
fs.set_top_level_dir(fs.dirname_store().append(child_cwd)?);
break 'root_package_json_file child_json;
};
// Opened to report a package.json that cannot be written to. The commands read it by path.
drop(root_package_json_file);
Comment thread
robobun marked this conversation as resolved.

let top_level_dir_z = ZBox::from_bytes(fs.top_level_dir());
bun_sys::chdir(&top_level_dir_z)?;
Expand All @@ -1855,17 +1874,12 @@ pub fn init(
// until now). The slice excludes the NUL — `top_level_dir` is `[]u8`.
// PathBuffer is repr(transparent) over [u8; N], so the raw cast is sound.
fs.set_top_level_dir(bun_core::ffi::slice(CWD_BUF.get().cast::<u8>(), tld.len()));
// bun_sys exposes the non-Z `get_fd_path`;
// append the NUL ourselves so the static `&ZStr` invariant holds.
// By name, not through the file opened above: another process may replace that file.
let root_buf = &mut *ROOT_PACKAGE_JSON_PATH_BUF.get();
let plen = if no_project {
// Where the file would be; nothing reads it in this mode.
let p = original_package_json_path.as_bytes();
root_buf[..p.len()].copy_from_slice(p);
p.len()
} else {
bun_sys::get_fd_path(root_package_json_file.handle, root_buf)?.len()
};
let tld_no_slash = strings::without_trailing_slash(tld);
let plen = tld_no_slash.len() + SEP_PACKAGE_JSON.len();
root_buf[..tld_no_slash.len()].copy_from_slice(tld_no_slash);
root_buf[tld_no_slash.len()..plen].copy_from_slice(SEP_PACKAGE_JSON);
root_buf[plen] = 0;
ROOT_PACKAGE_JSON_PATH.write(ZStr::from_raw(root_buf.as_ptr(), plen));
}
Expand Down Expand Up @@ -2061,7 +2075,7 @@ pub fn init(
// zero-bit pattern is UB; allocate the real (empty) lockfile here directly.
// `Lockfile::default()` ≡ `Lockfile::init_empty()`.
wr!(lockfile, Box::new(Lockfile::default()));
wr!(root_package_json_file, root_package_json_file);
wr!(project_lock, None);
// .progress
wr!(event_loop, AnyEventLoop::init());
wr!(
Expand Down Expand Up @@ -2248,6 +2262,12 @@ pub fn init(
manager.options.hoist_pattern = Some(p);
}
}

if subcommand.always_edits_project() && !manager.options.dry_run {
manager.lock_project();
// The workspace walk above read package.json files before the lock was held.
manager.workspace_package_json_cache.map.clear();
}
}

// Singleton fully initialized; main thread, no workers yet. Wrapped once as
Expand Down Expand Up @@ -2501,13 +2521,7 @@ fn init_with_runtime_once(
// `Lockfile` holds `HashMap`/`Vec`/`NonNull` (zero-bit pattern is
// UB), so allocate the real empty lockfile here directly instead of a zeroed placeholder.
wr!(lockfile, Box::new(Lockfile::default()));
// `.root_package_json_file` is never read in the runtime
// path. Use the explicit invalid-fd sentinel rather than `mem::zeroed()` —
// on posix `Fd(0)` is stdin, not the invalid marker.
wr!(
root_package_json_file,
bun_sys::File::from_fd(Fd::invalid())
);
wr!(project_lock, None);
// erased *mut () set by tier-6; `js_current()` resolves the per-thread JS
// event loop via `bun_io::__bun_get_vm_ctx` (link-time, definer in bun_runtime).
wr!(event_loop, AnyEventLoop::js_current());
Expand Down
125 changes: 113 additions & 12 deletions src/install/PackageManager/PackageManagerDirectories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,99 @@ impl PackageManager {
pub(crate) fn get_temporary_directory(&mut self) -> &'static TemporaryDirectory {
get_temporary_directory(self)
}

#[inline]
pub fn lock_project(&mut self) {
lock_project(self)
}
}

// ───────────────────────────── project lock ───────────────────────────────────

/// Serializes the processes that edit one project; held until exit, and best effort.
pub fn lock_project(this: &mut PackageManager) {
if this.project_lock.is_some() {
return;
}
// Canonical: a junction or another case of the same directory must lock the same file.
let top_level_dir = FileSystem::instance().top_level_dir();
let top_level_dir_z = ZBox::from_bytes(top_level_dir);
let mut project_dir_buf = path::path_buffer_pool::get();
let project_dir = bun_core::strings::without_trailing_slash(
sys::realpath(top_level_dir_z.as_zstr(), &mut project_dir_buf).unwrap_or(top_level_dir),
);
if env_var::BUN_INTERNAL_INSTALL_LOCK_DIR
.get()
.is_some_and(|held| bun_core::strings::without_trailing_slash(held) == project_dir)
{
return;
}

let Some(lock_file) = open_project_lock_file(this, project_dir) else {
bun_core::scoped_log!(project_lock, "no lock for {}", bstr::BStr::new(project_dir));
return;
};
let locked = match sys::flock(lock_file.handle, sys::FileLockMode::Exclusive, true) {
Ok(true) => true,
Ok(false) => {
if !this.options.log_level.is_silent() {
bun_core::pretty_errorln!(
"<d>Waiting for another bun process to finish in {}<r>",
bstr::BStr::new(project_dir)
);
Output::flush();
}
matches!(
sys::flock(lock_file.handle, sys::FileLockMode::Exclusive, false),
Ok(true)
)
}
Err(_) => false,
};
if !locked {
bun_core::scoped_log!(
project_lock,
"could not lock {}",
bstr::BStr::new(project_dir)
);
return;
}

bun_core::handle_oom(
this.env_mut()
.map
.put(env_var::BUN_INTERNAL_INSTALL_LOCK_DIR.key(), project_dir),
);
this.project_lock = Some(lock_file);
}

bun_core::declare_scope!(project_lock, hidden);

/// Read-only: `flock` does not need more, and another user may own the file in a shared cache.
fn open_project_lock_file(this: &mut PackageManager, project_dir: &[u8]) -> Option<File> {
let mut locks_dir_path = fetch_cache_directory_path(this.env_mut(), Some(&this.options)).path;
if locks_dir_path.last() != Some(&SEP) {
locks_dir_path.push(SEP);
}
locks_dir_path.extend_from_slice(b".locks");
let locks_dir = Dir::cwd()
.make_open_path(&locks_dir_path, Default::default())
.ok()?;

let mut lock_file_name = [0u8; b"0123456789abcdef.lock".len()];
write!(
&mut lock_file_name[..],
"{:016x}.lock",
bun_wyhash::hash(project_dir)
)
.expect("the buffer is sized for this format");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
File::openat(
locks_dir.fd(),
&lock_file_name,
sys::O::RDONLY | sys::O::CREAT | sys::O::CLOEXEC,
0o644,
)
.ok()
}

// ───────────────────────────── cache directory ────────────────────────────────
Expand Down Expand Up @@ -1052,26 +1145,34 @@ pub fn compute_cache_dir_and_subpath<'a>(
// ─────────────────────────── package.json / lockfile ──────────────────────────

pub(crate) fn attempt_to_create_package_json_and_open() -> Result<File, Error> {
let package_json_file = match Dir::cwd().create_file_z(
z_static(b"package.json\0"),
sys::CreateFlags {
read: true,
..Default::default()
},
let open_flags = sys::O::RDWR | sys::O::CLOEXEC;
let opened = match File::openat(
Fd::cwd(),
b"package.json",
open_flags | sys::O::CREAT | sys::O::EXCL,
0o666,
) {
Ok(f) => f,
Ok(package_json_file) => {
package_json_file.pwrite_all(b"{\"dependencies\": {}}", 0)?;
return Ok(package_json_file);
}
// Another process created it first and writes the contents; an empty file parses as `{}`.
Err(err) if err.get_errno() == sys::E::EEXIST => {
File::openat(Fd::cwd(), b"package.json", open_flags, 0)
}
Err(err) => Err(err),
};

match opened {
Ok(package_json_file) => Ok(package_json_file),
Err(err) => {
bun_core::pretty_errorln!(
"<r><red>error:<r> {} create package.json",
bun_fmt::s(err.name())
);
Global::crash();
}
};

package_json_file.pwrite_all(b"{\"dependencies\": {}}", 0)?;

Ok(package_json_file)
}
}

pub fn attempt_to_create_package_json() -> Result<(), Error> {
Expand Down
4 changes: 2 additions & 2 deletions src/install/PackageManager/add_remove_with_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use bun_install::dependency;
use bun_install::{Lockfile, PackageID, PackageNameHash};
use bun_paths::path_buffer_pool;
use bun_paths::resolve_path::{self, Platform, join_abs_string_buf, platform};
use bun_sys::{Fd, File};
use bun_sys::File;

use super::add_catalog;
use super::install_with_manager::install_with_manager;
Expand Down Expand Up @@ -295,7 +295,7 @@ pub(crate) fn write_target(manager: &mut PackageManager, target: &WorkspaceTarge
let entry = fetch_entry(manager, target);
let mut zbuf = path_buffer_pool::get();
let path = resolve_path::z(&target.package_json_path, &mut zbuf);
match File::write_file(Fd::cwd(), path, &entry.source.contents) {
match File::write_file_atomically(path, &entry.source.contents, 0o644) {
Ok(()) => true,
Err(err) => {
Output::err_generic(
Expand Down
11 changes: 2 additions & 9 deletions src/install/PackageManager/updatePackageJSONAndInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use bun_core::{Global, Output};
use bun_core::{ZStr, strings};
use bun_js_printer as js_printer;
use bun_paths::{self, PathBuffer};
use bun_sys::{self, Fd, File};
use bun_sys::{self, File};

use super::add_catalog;
use super::add_remove_with_filter::WorkspaceTarget;
Expand Down Expand Up @@ -698,14 +698,7 @@ fn update_package_json_and_install_with_manager_with_updates(

// Now that we've run the install step
// We can save our in-memory package.json to disk
let workspace_package_json_file =
File::openat(Fd::cwd(), path, bun_sys::O::RDWR, 0).map_err(Error::from)?;

workspace_package_json_file
.pwrite_all(source, 0)
.map_err(Error::from)?;
let _ = bun_sys::ftruncate(workspace_package_json_file.handle, source.len() as i64);
let _ = workspace_package_json_file.close(); // close error is non-actionable
File::write_file_atomically(path, source, 0o644).map_err(Error::from)?;

if subcommand == Subcommand::Remove {
if !any_changes {
Expand Down
29 changes: 26 additions & 3 deletions src/install/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ impl<'a> Linker<'a> {

debug_assert!(strings::has_prefix(rel_target.as_bytes(), b".."));

match sys::symlink_running_executable(rel_target, abs_dest) {
match Self::symlink_or_keep(rel_target, abs_dest) {
sys::Result::Err(err) => {
if err.get_errno() != sys::Errno::EEXIST && err.get_errno() != sys::Errno::ENOENT {
self.err = Some(err.into());
Expand All @@ -1291,7 +1291,7 @@ impl<'a> Linker<'a> {
let _ = sys::Dir::cwd().make_path(self.node_modules_path.slice());
self.node_modules_path.set_length(node_modules_path_save);

match sys::symlink_running_executable(rel_target, abs_dest) {
match Self::symlink_or_keep(rel_target, abs_dest) {
sys::Result::Err(real_error) => {
// It was just created, no need to delete destination and symlink again
self.err = Some(real_error.into());
Expand All @@ -1316,12 +1316,35 @@ impl<'a> Linker<'a> {

// delete and try again
let _ = sys::delete_tree_absolute(abs_dest.as_bytes());
if let Err(err) = sys::symlink_running_executable(rel_target, abs_dest) {
if let Err(err) = Self::symlink_or_keep(rel_target, abs_dest) {
self.err = Some(err.into());
}
Self::chmod_on_ok(self.err, abs_target);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// A link that already points at `rel_target` (a repeat or a concurrent install) is kept.
#[cfg(not(windows))]
fn symlink_or_keep(rel_target: &ZStr, abs_dest: &ZStr) -> sys::Result<()> {
match sys::symlink_running_executable(rel_target, abs_dest) {
Err(err)
if err.get_errno() == sys::Errno::EEXIST
&& Self::already_links_to(abs_dest, rel_target) =>
{
Ok(())
}
result => result,
}
}

#[cfg(not(windows))]
fn already_links_to(abs_dest: &ZStr, rel_target: &ZStr) -> bool {
let mut existing_target_buf = path::path_buffer_pool::get();
match sys::readlink(abs_dest, &mut existing_target_buf) {
Ok(len) => strings::eql(&existing_target_buf[..len], rel_target.as_bytes()),
Err(_) => false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

#[cfg(not(windows))]
fn chmod_on_ok(err: Option<Error>, abs_target: &ZStr) {
// hoisted from `defer` block in create_symlink
Expand Down
2 changes: 1 addition & 1 deletion src/install/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub fn detect_and_load_other_lockfile<'a>(
let Ok(data) = File::read_from(dir, b"pnpm-lock.yaml") else {
break 'pnpm;
};
let migrate_result = match pnpm::migrate_pnpm_lockfile(this, manager, log, &data, dir) {
let migrate_result = match pnpm::migrate_pnpm_lockfile(this, manager, log, &data) {
Ok(r) => r,
Err(MigratePnpmLockfileError::PnpmLockfileTooOld) => {
report_unsupported_lockfile_version(
Expand Down
Loading
Loading