Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/pm/cli/update.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Updated packages appear in the install summary as `↑ name old → new`, with `
- Bun does not add a version that another one covers. Two dependencies declaring `foo@^1` and `foo@*` share one `1.x` copy, as on `bun install`. Versions already in `bun.lock` and your own `package.json` entries are never moved to save a copy.
- Versions in `patchedDependencies` stay put as long as their range allows. Bun reports them as `kept name@version (patched, v1.2.3 available)`. `--latest` and [`bun audit fix`](/pm/cli/audit#bun-audit-fix) do move them; re-create the patch with [`bun patch`](/pm/cli/patch) afterwards.
- If a registry request for a transitive package fails, that package keeps its locked version and Bun prints a warning. A failed request for a direct dependency is an error.
- An optional dependency on a git repository, a GitHub repository, or a tarball keeps its locked package when Bun cannot fetch that source. Bun prints `warn: name@version was not checked for updates`. This does not apply to a dependency that an [`overrides`](/pm/overrides) rule or a catalog sets. If you pass the `package.json` key of the dependency, or if `--prod` or `--depth 0` selects it, `bun update` exits with code 1 and writes nothing.

## `--interactive`

Expand Down
19 changes: 8 additions & 11 deletions src/install/PackageInstaller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2084,17 +2084,14 @@ impl<'a> PackageInstaller<'a> {
context,
)
}
resolution::Tag::LocalTarball => {
package_manager::enqueue_tarball_for_reading(
self.manager_mut(),
dependency_id,
package_id,
alias.slice(string_buf!()),
resolution,
context,
);
Ok(())
}
resolution::Tag::LocalTarball => package_manager::enqueue_tarball_for_reading(
self.manager_mut(),
dependency_id,
package_id,
alias.slice(string_buf!()),
resolution,
context,
),
resolution::Tag::RemoteTarball => {
package_manager::enqueue_tarball_for_download(
self.manager_mut(),
Expand Down
158 changes: 134 additions & 24 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,7 @@ fn enqueue_download(
task_context: TaskCallbackContext,
authorization: Authorization,
) -> Result<(), ForTarballError> {
if this.network_task_has_failed(task_id) {
if is_asked_by_linker(&task_context) {
report_failed_download_as_required(this, task_id, dependency_id, package_id);
}
if has_already_failed(this, task_id, dependency_id, package_id, &task_context) {
return Err(ForTarballError::AlreadyFailed);
}
if this.options.offline == OfflineMode::Offline {
Expand Down Expand Up @@ -363,12 +360,15 @@ pub fn enqueue_tarball_for_reading(
alias: &[u8],
resolution: &Resolution,
task_context: TaskCallbackContext,
) {
) -> Result<(), ForTarballError> {
// `enqueue_local_tarball` copies `path` into the filename store before `string_bytes` can grow.
let path = this.lockfile.str_detached(resolution.local_tarball());
let task_id = Task::Id::for_tarball(path);
if has_already_failed(this, task_id, dependency_id, package_id, &task_context) {
return Err(ForTarballError::AlreadyFailed);
}
if !push_task_callback(this, task_id, task_context) {
return;
return Ok(());
}

let integrity = this.lockfile.packages.items_meta()[package_id as usize].integrity;
Expand All @@ -383,6 +383,7 @@ pub fn enqueue_tarball_for_reading(
Some(&integrity),
);
this.task_batch.push(ThreadPool::Batch::from(task));
Ok(())
}

/// Returns `Err(Offline)` if the repository isn't cached. Nothing is queued then, and the caller
Expand Down Expand Up @@ -456,6 +457,23 @@ fn is_asked_by_linker(context: &TaskCallbackContext) -> bool {
)
}

/// Returns true if `task_id` has failed, which means that nothing would answer a request for it.
fn has_already_failed(
this: &mut PackageManager,
task_id: Task::Id,
dependency_id: DependencyID,
package_id: PackageID,
context: &TaskCallbackContext,
) -> bool {
if !this.network_task_has_failed(task_id) {
return false;
}
if is_asked_by_linker(context) {
report_failed_download_as_required(this, task_id, dependency_id, package_id);
}
true
}

/// The download failed with only a warning, because only optional dependencies needed it at the
/// time. A linker that requires the package reports an error.
#[cold]
Expand Down Expand Up @@ -986,6 +1004,87 @@ fn locked_repository(
this.locked_tarball(res)
}

/// Only a package.json read from the project may name a tarball in the project.
fn may_name_local_tarball(
lockfile: &Lockfile::Lockfile,
declarer: PackageID,
dependency: &Dependency,
) -> bool {
lockfile.is_local_declarer(declarer)
|| lockfile.has_equal_root_dependency(dependency)
|| lockfile.packages.items_resolution()[declarer as usize].tag
== ResolutionTag::LocalTarball
}

/// Returns false for a dependency that never `refreshes_in_place`, whatever bun.lock has for it.
#[inline]
pub(crate) fn may_refresh_in_place(dependency: &Dependency) -> bool {
use dependency::version::Tag;
dependency.behavior.is_optional()
&& matches!(
dependency.version.tag,
Tag::Git | Tag::Github | Tag::Tarball
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Returns true if `dependency` of `declarer` keeps `locked` while this run reads its source again.
pub(crate) fn refreshes_in_place(
this: &PackageManager,
declarer: PackageID,
dependency: &Dependency,
locked: PackageID,
) -> bool {
use dependency::version::Tag;
if !may_refresh_in_place(dependency) {
return false;
}
let lockfile = &*this.lockfile;
let Some(locked_res) = lockfile.packages.items_resolution().get(locked as usize) else {
return false;
};
let buf = lockfile.buffers.string_bytes.as_slice();
// An older bun.lock binds a dependency that never resolved to any package of its name.
let is_from_source = match dependency.version.tag {
Tag::Github => {
let asks = dependency.version.github();
locked_res.tag == ResolutionTag::Github
&& locked_res.github().owner.eql(asks.owner, buf, buf)
&& locked_res.github().repo.eql(asks.repo, buf, buf)
}
Tag::Git => {
let asks = dependency.version.git().repo.slice(buf);
// bun.lock has `host:path` as a URL.
let recorded = crate::repository_real::locked_scp_like(asks);
locked_res.tag == ResolutionTag::Git
&& locked_res.git().repo.slice(buf) == recorded.as_deref().unwrap_or(asks)
}
_ => match &dependency.version.tarball().uri {
dependency::tarball::Uri::Remote(url) => {
locked_res.tag == ResolutionTag::RemoteTarball
&& locked_res.remote_tarball().eql(*url, buf, buf)
}
dependency::tarball::Uri::Local(path) => {
locked_res.tag == ResolutionTag::LocalTarball
&& locked_res.local_tarball().eql(*path, buf, buf)
&& may_name_local_tarball(lockfile, declarer, dependency)
}
},
};
let has_rule = |name_hash: PackageNameHash| {
lockfile.overrides.has_rule_for_name(name_hash)
|| this
.summary
.overridden_names
.binary_search(&name_hash)
.is_ok()
};
is_from_source
// A rule replaces what the dependency declares, and a changed rule can replace `locked`.
&& !has_rule(dependency.name_hash)
&& !has_rule(lockfile.packages.items_name_hash()[locked as usize])
&& unsafe_alias(lockfile, dependency).is_none()
}

/// Returns the `npm:` alias that the project declares for `name`, if any. A dependency of the root
/// package.json only follows an alias that the root package.json itself declares.
fn followed_npm_alias(
Expand Down Expand Up @@ -1258,7 +1357,7 @@ fn request_remote_package_ahead(
asked.version.tarball().uri,
dependency::tarball::Uri::Local(_)
);
if resolution != invalid_package_id
if !this.lockfile.awaits_resolve(id)
|| this.options.runtime_auto_install
// A deferred peer doesn't request anything, because the tree may provide it.
|| (dependency.behavior.is_peer() && !install_peer)
Expand Down Expand Up @@ -1810,15 +1909,7 @@ fn unsafe_name<'a>(
dependency: &'a Dependency,
asked: &'a Asked<'_>,
) -> Option<&'a [u8]> {
let alias = lockfile.str(&dependency.name);
let alias_is_safe = if alias == lockfile.str(&dependency.version.literal) {
// Until `assign_resolution` runs, the alias of `bun add <specifier>` is the specifier
// itself. It never becomes a folder name, but it is printed.
!dependency::contains_control_character(alias)
} else {
alias.is_empty() || crate::package_installer::alias_is_safe_install_target(alias)
};
if !alias_is_safe {
if let Some(alias) = unsafe_alias(lockfile, dependency) {
return Some(alias);
}
let registry_name: Option<&[u8]> = match asked.version.tag {
Expand All @@ -1832,6 +1923,23 @@ fn unsafe_name<'a>(
registry_name.filter(|name| !crate::package_installer::alias_is_safe_install_target(name))
}

/// The alias of a dependency, which is a future folder, if it is unsafe.
#[inline(always)]
fn unsafe_alias<'a>(
lockfile: &'a Lockfile::Lockfile,
dependency: &'a Dependency,
) -> Option<&'a [u8]> {
let alias = lockfile.str(&dependency.name);
let alias_is_safe = if alias == lockfile.str(&dependency.version.literal) {
// Until `assign_resolution` runs, the alias of `bun add <specifier>` is the specifier
// itself. It never becomes a folder name, but it is printed.
!dependency::contains_control_character(alias)
} else {
alias.is_empty() || crate::package_installer::alias_is_safe_install_target(alias)
};
(!alias_is_safe).then_some(alias)
}

/// Resolves one dependency: binds it to a package that the lockfile has, or appends the package.
pub(crate) fn bind_row(
this: &mut PackageManager,
Expand Down Expand Up @@ -1880,6 +1988,13 @@ pub(crate) fn bind_row(
version,
version_was_replaced,
} = asked;
// A rule for the name that its source has now replaces what the dependency asks for.
let resolution = if version_was_replaced && this.lockfile.is_refreshing(id) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) A refreshing row whose replaced version carries an unsafe install name stays bound to its locked package with the refreshing bit set, so bun update keeps the stale package and prints was not checked for updates for it, where the base dropped the row. unsafe_name at PackageManagerEnqueue.rs:1975 returns Row::Done before the version_was_replaced unbind at PackageManagerEnqueue.rs:1992 runs. Fix: move the unbind_refreshing for version_was_replaced ahead of the unsafe_name check, so every early exit of bind_row that abandons a replaced row also drops the kept package.

Why this was flagged

A row marked refreshing by keep_while_refreshing or enqueue_named_updates reaches bind_row. asked_version (src/install/PackageManager/PackageManagerEnqueue.rs:1841) applies an override rule keyed by the realname hash, which for a lockfile-loaded row whose package_name is filled can differ from both dependency.name_hash and the locked package's name hash that refreshes_in_place checks at PackageManagerEnqueue.rs:1147-1148. If the rule's target has a name that alias_is_safe_install_target rejects, unsafe_name returns it and bind_row exits at PackageManagerEnqueue.rs:1982 with Row::Done, before the unbind at PackageManagerEnqueue.rs:1992-1994. The row keeps the locked package and its refreshing bit; nothing else clears it, so kept_without_refresh (lockfile.rs:2574) lists the package and warn_unchecked_sources prints a misleading was not checked for updates line. On the base branch the row was invalid and clean_lockfile dropped the package.

Verification: When that rule's target is unsafe, unsafe_name (src/install/PackageManager/PackageManagerEnqueue.rs:1915-1923) returns Some, and line 1975-1982 returns Row::Done BEFORE line 1992-1994. The row therefore stays bound to the locked package with refreshing set. On the base the row was unbound to invalid_package_id before the read, so the same Row::Done dropped the package via clean_lockfile.

this.lockfile.unbind_refreshing(id);
invalid_package_id
} else {
resolution
};
let mut loaded_manifest: Option<Npm::PackageManifest> = None;

match version.tag {
Expand Down Expand Up @@ -2170,8 +2285,7 @@ fn bind_row_without_manifest(
let clone_id = Task::Id::for_git_clone(url);

if let Some(repo_fd) = this.git_repositories.get(&clone_id).copied() {
let needs_ctx =
this.lockfile.buffers.resolutions[id as usize] == invalid_package_id;
let needs_ctx = this.lockfile.awaits_resolve(id);

// Waits on a `git log` task; `run_tasks` fills `git_commits` and re-enters here.
let committish = this.lockfile.str_detached(&dep.committish);
Expand Down Expand Up @@ -2336,14 +2450,10 @@ fn bind_row_without_manifest(
dependency::tarball::Uri::Remote(url) => ResolutionTagged::RemoteTarball(*url),
},
});
// Only a package.json read from the project may name a tarball in the project.
if res.tag == ResolutionTag::LocalTarball
&& !version_was_replaced
&& !this.lockfile.is_dependency_of_local_package(id)
&& !this.lockfile.has_equal_root_dependency(dependency)
&& let Some(declarer) = this.lockfile.get_parent_pkg_of_dependency(id)
&& this.lockfile.packages.items_resolution()[declarer as usize].tag
!= ResolutionTag::LocalTarball
&& !may_name_local_tarball(&this.lockfile, declarer, dependency)
{
if dependency.behavior.is_required() {
reject_local_tarball_of_remote_package(this, declarer, dependency);
Expand Down Expand Up @@ -2373,7 +2483,7 @@ fn bind_row_without_manifest(
};
let task_id = Task::Id::for_tarball(url);

if this.lockfile.buffers.resolutions[id as usize] == invalid_package_id {
if this.lockfile.awaits_resolve(id) {
if let Some(pkg_id) = resolve_from_appended_task(this, task_id, id) {
success_fn(this, id, pkg_id);
return Ok(());
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageManager/PackageManagerResolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl PackageManager {
);
debug_assert!((package_id as usize) < self.lockfile.packages.len());
// debug_assert!(self.lockfile.buffers.resolutions.as_slice()[dependency_id as usize] == invalid_package_id);
self.lockfile.take_refreshing(dependency_id);
let buffers = &mut self.lockfile.buffers;
buffers.resolutions.as_mut_slice()[dependency_id as usize] = package_id;
let string_buf = buffers.string_bytes.as_slice();
Expand Down
3 changes: 1 addition & 2 deletions src/install/PackageManager/PopulatePackageCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,7 @@ pub(crate) fn download_pinned_packages(
enqueue_tarball_for_download(manager, dep_id, pkg_id, url, waiter)
}
ResolutionTag::LocalTarball => {
enqueue_tarball_for_reading(manager, dep_id, pkg_id, alias, &resolution, waiter);
Ok(())
enqueue_tarball_for_reading(manager, dep_id, pkg_id, alias, &resolution, waiter)
}
ResolutionTag::Git => enqueue_git_for_checkout(
manager,
Expand Down
38 changes: 30 additions & 8 deletions src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::lockfile::{self, Lockfile, reachable};
use crate::resolution::Tag as ResolutionTag;
use crate::update_transitive::{
DirectDependencies, TransitiveUpdate, enqueue_peer_rows, moved_targets_after_clean,
plannable_peer_rows, print_kept_patched, redirect_moved_edges,
plannable_peer_rows, print_kept_patched, redirect_moved_edges, warn_unchecked_sources,
};
use crate::{
Dependency, DependencyID, Features, PackageID, PackageNameHash, PatchTask, Resolution,
Expand All @@ -38,7 +38,7 @@ use bun_install_types::NodeLinker::NodeLinker;

// Free-function "methods" on `PackageManager` hosted in sibling modules
// to avoid one giant `impl PackageManager` block.
use crate::package_manager_real::enqueue::add_dependency_error;
use crate::package_manager_real::enqueue::{self, add_dependency_error};
use crate::package_manager_real::remove_stale_workspace_links::remove_stale_workspace_links;
use crate::package_manager_real::run_tasks::{RunTasksCallbacks, run_tasks};
use crate::package_manager_real::{
Expand Down Expand Up @@ -513,6 +513,13 @@ pub fn install_with_manager(

builder.clamp();

manager
.summary
.overridden_names
.clone_from(&all_name_hashes);
let rows = lockfile::DependencySlice::new(off, len);
Diff::keep_while_refreshing(manager, 0, root.dependencies, rows);

let invalidates_rows = (manager.summary.overrides_changed
&& !all_name_hashes.is_empty())
|| manager.summary.catalogs_changed;
Expand Down Expand Up @@ -678,6 +685,7 @@ pub fn install_with_manager(
manager.log_held();
transitive.print_plan(manager, &direct_deps_before, &named.moved);
print_kept_patched(manager);
warn_unchecked_sources(manager);

let had_errors_before_cleaning_lockfile = manager.log_mut().has_errors();
manager
Expand Down Expand Up @@ -1811,8 +1819,14 @@ fn enqueue_named_updates(
let mut peer_rows: Vec<DependencyID> = Vec::new();
let dependencies_len = manager.lockfile.buffers.dependencies.len();
for dependency_i in 0..dependencies_len {
let row = dependency_i as DependencyID;
let dependency = manager.lockfile.buffers.dependencies[dependency_i].clone();
let package_id = manager.lockfile.buffers.resolutions[dependency_i];
// A dependency that is refreshed is already queued, like one that has no package.
let package_id = if manager.lockfile.is_refreshing(row) {
invalid_package_id
} else {
manager.lockfile.buffers.resolutions[dependency_i]
};
let Some(request) = index_of_named_update(manager, &dependency, package_id) else {
continue;
};
Expand All @@ -1822,7 +1836,7 @@ fn enqueue_named_updates(
}
matched.set(request);
if collect_latest_rows {
named.latest_rows.push(dependency_i as DependencyID);
named.latest_rows.push(row);
}
// The root's link to a workspace has no version to update. Other dependencies on that name
// can only link to the workspace while it stays resolved.
Expand All @@ -1834,13 +1848,21 @@ fn enqueue_named_updates(
}
if dependency.behavior.is_peer() {
if plannable_peers.is_set(dependency_i) {
peer_rows.push(dependency_i as DependencyID);
peer_rows.push(row);
}
continue;
}
manager.lockfile.buffers.resolutions[dependency_i] = invalid_package_id;
named.moved.push((dependency_i as DependencyID, package_id));
enqueue_row(manager, dependency_i as DependencyID);
let refreshes_in_place = enqueue::may_refresh_in_place(&dependency)
&& (manager.lockfile.get_parent_pkg_of_dependency(row)).is_some_and(|declarer| {
enqueue::refreshes_in_place(manager, declarer, &dependency, package_id)
});
if refreshes_in_place {
manager.lockfile.mark_refreshing(row);
} else {
manager.lockfile.buffers.resolutions[dependency_i] = invalid_package_id;
}
named.moved.push((row, package_id));
enqueue_row(manager, row);
}

reject_unknown_update_requests(
Expand Down
Loading
Loading