diff --git a/.changeset/spotty-citrus-fix.md b/.changeset/spotty-citrus-fix.md new file mode 100644 index 0000000000..e0e7546af0 --- /dev/null +++ b/.changeset/spotty-citrus-fix.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolving.npm-resolver": patch +"pnpm": patch +--- + +A `304 Not Modified` answer from the registry now renews the cached metadata file's mtime, so the `minimumReleaseAge` freshness shortcut keeps serving resolutions from the cache. Previously, once a cached packument grew older than `minimumReleaseAge`, every subsequent install re-validated it against the registry forever, because a 304 never rewrites the file. diff --git a/pacquet/crates/cli/src/bin/main.rs b/pacquet/crates/cli/src/bin/main.rs index 33ee2777d7..f085cd8a76 100644 --- a/pacquet/crates/cli/src/bin/main.rs +++ b/pacquet/crates/cli/src/bin/main.rs @@ -1,4 +1,3 @@ -#[tokio::main(flavor = "multi_thread")] -pub async fn main() -> miette::Result<()> { - pacquet_cli::main().await +pub fn main() -> miette::Result<()> { + pacquet_cli::main() } diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index e0a45e4eb5..c03f9dbc2f 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -22,7 +22,7 @@ use outdated::{OutdatedArgs, OutdatedOutcome}; use pacquet_config::{Config, Host}; use pacquet_executor::execute_shell; use pacquet_package_manifest::PackageManifest; -use pacquet_reporter::{NdjsonReporter, SilentReporter}; +use pacquet_reporter::{NdjsonReporter, Reporter, SilentReporter}; use remove::RemoveArgs; use run::RunArgs; use std::path::PathBuf; @@ -137,6 +137,40 @@ pub enum CliCommand { } impl CliArgs { + /// Try to finish `pacquet install` synchronously through the + /// repeat-install fast path, before the caller builds the async + /// runtime. `true` means the install completed (the "Already up to + /// date" events were emitted); `false` means undecided — proceed + /// with [`Self::run`], which loads its own config and re-runs the + /// same check. + /// + /// Mirrors the install arm of [`Self::run`]'s dispatch: the same + /// canonicalized `--dir`, the same config layering (`.npmrc` auth + /// file seed + `--config.` overrides). Workspace-filtered and + /// recursive installs always take the full path. + pub fn finished_via_install_fast_path(&self, config_overrides: &ConfigOverrides) -> bool { + let CliCommand::Install(install_args) = &self.command else { + return false; + }; + if self.recursive || !self.filter.is_empty() || !self.filter_prod.is_empty() { + return false; + } + let Ok(dir) = dunce::canonicalize(&self.dir) else { + return false; + }; + let loaded = Config { npmrc_auth_file: self.npmrc_auth_file.clone(), ..Config::default() } + .current::(&dir); + let Ok(mut config) = loaded else { + return false; + }; + config_overrides.apply(&mut config); + let emit = match self.reporter { + ReporterType::Ndjson => NdjsonReporter::emit as fn(&pacquet_reporter::LogEvent), + ReporterType::Silent => SilentReporter::emit as fn(&pacquet_reporter::LogEvent), + }; + install_args.finished_via_up_to_date_fast_path(&dir, &config, emit) + } + /// Execute the command. `config_overrides` carries `--config.=` /// tokens already stripped from argv by [`ConfigOverrides::extract`]; /// they're layered on top of `.npmrc` / `pnpm-workspace.yaml` whenever diff --git a/pacquet/crates/cli/src/cli_args/add.rs b/pacquet/crates/cli/src/cli_args/add.rs index 2279b67f3f..95347cd570 100644 --- a/pacquet/crates/cli/src/cli_args/add.rs +++ b/pacquet/crates/cli/src/cli_args/add.rs @@ -194,6 +194,8 @@ where // TODO: if a package already exists in another dependency group, don't remove the existing entry. let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } = &mut state; + let lockfile = + lockfile.get().map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?; let lockfile_path = manifest.path().parent().map(|parent| parent.join(pacquet_lockfile::Lockfile::FILE_NAME)); @@ -203,7 +205,7 @@ where http_client_arc: std::sync::Arc::clone(http_client), config, manifest, - lockfile: lockfile.as_ref(), + lockfile, lockfile_path: lockfile_path.as_deref(), list_dependency_groups, package_name, diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index d35207a11e..49377f70a3 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -3,10 +3,10 @@ use clap::{Args, ValueEnum}; use derive_more::{Display, Error}; use miette::{Context, Diagnostic}; use pacquet_config::NodeLinker; -use pacquet_lockfile::{Lockfile, LockfileResolution}; +use pacquet_lockfile::{Lockfile, LockfileResolution, MaybeLazyLockfile}; use pacquet_package_manager::{ Install, InstallFrozenLockfileError, LockfileVerificationOverride, TarballPrefetcher, - UpdateSeedPolicy, + UpToDateFastPathCheck, UpdateSeedPolicy, install_already_up_to_date, }; use pacquet_package_manifest::DependencyGroup; use pacquet_pnpr_client::{PnprClient, PnprClientError, ResolveOptions, VerifyLockfileOptions}; @@ -255,6 +255,73 @@ pub struct InstallArgs { } impl InstallArgs { + /// Run the repeat-install fast path before any of the async install + /// machinery exists: when every gate below holds and + /// [`install_already_up_to_date`] confirms nothing changed since the + /// previous install, emit the same "Already up to date" + summary + /// events [`Install::run`] would and report the install as finished. + /// + /// The gates mirror the dispatch in [`crate::cli_args::CliArgs::run`] + /// (which checks `recursive` / `filter` before reaching here) plus + /// every input that would make [`Install::run`] skip its own + /// short-circuit or do extra pre-install work: an explicit + /// `--frozen-lockfile` / `--lockfile-only`, a configured pnpr + /// server (that path never runs the optimistic check), config + /// dependencies, and pnpmfile `updateConfig` hooks (both can + /// mutate the config the check compares against). + /// + /// `false` means "not decided" — the caller proceeds with the full + /// install path, which re-runs the same check cheaply and + /// reproduces any error with its established shape. + pub fn finished_via_up_to_date_fast_path( + &self, + dir: &std::path::Path, + config: &pacquet_config::Config, + emit: fn(&pacquet_reporter::LogEvent), + ) -> bool { + if self.frozen_lockfile || self.lockfile_only || self.pnpr_server.is_some() { + return false; + } + if config.pnpr_server.is_some() { + return false; + } + if config.config_dependencies.as_ref().is_some_and(|deps| !deps.is_empty()) { + return false; + } + let config_root = config.workspace_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + if pacquet_hooks::finder::find_pnpmfile(&config_root).is_some() { + return false; + } + let manifest_path = dir.join("package.json"); + if !manifest_path.is_file() { + return false; + } + let Ok(manifest) = pacquet_package_manifest::PackageManifest::from_path(manifest_path) + else { + return false; + }; + let node_linker = self.node_linker.map_or(config.node_linker, NodeLinkerArg::into_config); + let Some(workspace_root) = install_already_up_to_date(&UpToDateFastPathCheck { + config, + manifest: &manifest, + dependency_groups: self.dependency_options.dependency_groups().collect(), + node_linker, + }) else { + return false; + }; + let prefix = workspace_root.to_string_lossy().into_owned(); + emit(&pacquet_reporter::LogEvent::Pnpm(pacquet_reporter::PnpmLog { + level: pacquet_reporter::LogLevel::Info, + message: "Already up to date".to_string(), + prefix: prefix.clone(), + })); + emit(&pacquet_reporter::LogEvent::Summary(pacquet_reporter::SummaryLog { + level: pacquet_reporter::LogLevel::Debug, + prefix, + })); + true + } + pub async fn run(self, state: State) -> miette::Result<()> { let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } = &state; @@ -362,7 +429,7 @@ impl InstallArgs { http_client_arc: std::sync::Arc::clone(http_client), config, manifest, - lockfile: lockfile.as_ref(), + lockfile: MaybeLazyLockfile::Lazy(lockfile), lockfile_path: lockfile_path.as_deref(), dependency_groups: dependency_options.dependency_groups(), frozen_lockfile, @@ -541,7 +608,11 @@ async fn install_via_pnpr( .collect(), authorization: state.config.auth_headers.for_url(pnpr_server), overrides, - lockfile: state.lockfile.clone(), + lockfile: state + .lockfile + .get() + .map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))? + .cloned(), frozen_lockfile: link.frozen_lockfile, prefer_frozen_lockfile: Some(link.prefer_frozen_lockfile), ignore_manifest_check: link.ignore_manifest_check, @@ -562,7 +633,10 @@ async fn install_via_pnpr( if link.frozen_lockfile && !link.lockfile_only - && let Some(lockfile) = state.lockfile.as_ref() + && let Some(lockfile) = state + .lockfile + .get() + .map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))? { let prefetcher = TarballPrefetcher::new( state.config, @@ -601,7 +675,7 @@ async fn install_via_pnpr( http_client_arc: std::sync::Arc::clone(&state.http_client), config: state.config, manifest: &state.manifest, - lockfile: Some(lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(lockfile)), lockfile_path: link.lockfile_path, dependency_groups: link.dependency_groups, frozen_lockfile: true, @@ -721,7 +795,7 @@ async fn install_via_pnpr( http_client_arc: std::sync::Arc::clone(&state.http_client), config: state.config, manifest: &state.manifest, - lockfile: Some(&outcome.lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&outcome.lockfile)), lockfile_path: link.lockfile_path, dependency_groups: link.dependency_groups, frozen_lockfile: true, diff --git a/pacquet/crates/cli/src/cli_args/outdated.rs b/pacquet/crates/cli/src/cli_args/outdated.rs index ec2bbeac1f..dc027d1351 100644 --- a/pacquet/crates/cli/src/cli_args/outdated.rs +++ b/pacquet/crates/cli/src/cli_args/outdated.rs @@ -326,7 +326,10 @@ impl OutdatedArgs { let config = state.config; let manifest = &state.manifest; - let lockfile = &state.lockfile; + let lockfile = state + .lockfile + .get() + .map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?; let http_client = &state.http_client; // A manifest with no dependencies at all is reported as up to date @@ -361,7 +364,7 @@ impl OutdatedArgs { include_deprecated: true, }; let mut outdated = - collect_outdated(manifest, lockfile.as_ref(), config, http_client, &query).await?; + collect_outdated(manifest, lockfile, config, http_client, &query).await?; sort_outdated(&mut outdated, self.sort_by); diff --git a/pacquet/crates/cli/src/cli_args/remove.rs b/pacquet/crates/cli/src/cli_args/remove.rs index a126a06009..87a17b2522 100644 --- a/pacquet/crates/cli/src/cli_args/remove.rs +++ b/pacquet/crates/cli/src/cli_args/remove.rs @@ -60,6 +60,8 @@ impl RemoveArgs { ) -> miette::Result<()> { let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } = &mut state; + let lockfile = + lockfile.get().map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?; let lockfile_path = manifest .path() @@ -71,7 +73,7 @@ impl RemoveArgs { http_client_arc: std::sync::Arc::clone(http_client), config, manifest, - lockfile: lockfile.as_ref(), + lockfile, lockfile_path: lockfile_path.as_deref(), package_names: &self.package_names, save_type: self.dependency_options.save_type(), diff --git a/pacquet/crates/cli/src/cli_args/update.rs b/pacquet/crates/cli/src/cli_args/update.rs index 42949deb8c..1d6bc29bad 100644 --- a/pacquet/crates/cli/src/cli_args/update.rs +++ b/pacquet/crates/cli/src/cli_args/update.rs @@ -131,6 +131,8 @@ impl UpdateArgs { let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } = &mut state; + let lockfile = + lockfile.get().map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?; let supported_architectures = self.supported_architectures.apply_to(config.supported_architectures.clone()); @@ -143,7 +145,7 @@ impl UpdateArgs { let packages = if self.interactive { match crate::cli_args::update_interactive::select_packages( manifest, - lockfile.as_ref(), + lockfile, config, http_client, self.latest, @@ -168,7 +170,7 @@ impl UpdateArgs { http_client_arc: std::sync::Arc::clone(http_client), config, manifest, - lockfile: lockfile.as_ref(), + lockfile, lockfile_path: lockfile_path.as_deref(), packages: &packages, latest: self.latest, diff --git a/pacquet/crates/cli/src/lib.rs b/pacquet/crates/cli/src/lib.rs index 3dbafffab4..660ad3a3de 100644 --- a/pacquet/crates/cli/src/lib.rs +++ b/pacquet/crates/cli/src/lib.rs @@ -10,7 +10,7 @@ use miette::set_panic_hook; use pacquet_diagnostics::enable_tracing_by_env; use state::State; -pub async fn main() -> miette::Result<()> { +pub fn main() -> miette::Result<()> { enable_tracing_by_env(); set_panic_hook(); // Extract pnpm's `--config.=` tokens before clap sees @@ -19,16 +19,21 @@ pub async fn main() -> miette::Result<()> { // would otherwise error out as "unexpected argument". Each extracted // token is layered onto `Config` after `.npmrc` / yaml run. let (config_overrides, argv) = ConfigOverrides::extract(std::env::args_os()); - // Run argument parsing *before* sizing the rayon pool so - // `pacquet --help` / `--version` (and any clap parse error) exit - // without spinning up worker threads. `clap::Parser::parse` calls - // `std::process::exit` on those paths, so we never reach - // `configure_rayon_pool` for them (Copilot review on ). let args = CliArgs::parse_from(argv); + // An up-to-date `pacquet install` finishes here, without paying for + // the runtime, the HTTP client, or any worker threads. + if args.finished_via_install_fast_path(&config_overrides) { + return Ok(()); + } configure_rayon_pool(); - // Boxed for `clippy::large_futures`: the command future exceeds the - // lint's stack-size threshold (the limit trips on Windows first). - Box::pin(args.run(&config_overrides)).await + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build the tokio runtime") + // Boxed for `clippy::large_futures`: the command future exceeds + // the lint's stack-size threshold (the limit trips on Windows + // first). + .block_on(Box::pin(args.run(&config_overrides))) } /// Size rayon's global pool at `2 × available_parallelism`. The link @@ -41,6 +46,16 @@ pub async fn main() -> miette::Result<()> { /// switching and per-thread fixed costs (`user` time scales linearly /// past 50 without any wall-time payoff). /// +/// Runs after the repeat-install fast path has declined, so commands +/// that never reach a parallel phase (`--help`, the "Already up to +/// date" short-circuit) skip the worker-thread spawn cost entirely. +/// Deliberately NOT communicated via the `RAYON_NUM_THREADS` +/// environment variable: a process-env write would leak into every +/// child the install spawns (lifecycle scripts, `node --version`, +/// git), and pnpm exposes no such variable to scripts. An explicit +/// `RAYON_NUM_THREADS` from the caller is honoured by skipping the +/// override. +/// /// Use [`std::thread::available_parallelism`] rather than the /// workspace's existing `num_cpus::get()` so cgroup / CPU-quota /// limits in containers and CI runners are respected — `num_cpus` @@ -59,12 +74,8 @@ pub async fn main() -> miette::Result<()> { /// quota literally — Copilot's follow-up flagged the tension; we're /// keeping the floor and documenting it explicitly. /// -/// Honours an explicit `RAYON_NUM_THREADS` env var by skipping our -/// override (rayon's `build_global` errors if a pool is already set, -/// but env vars don't pre-init it — so we just apply a smaller -/// override only when nothing else has been configured). Best-effort: -/// if another part of the binary already initialised the pool, leave -/// it alone. +/// Best-effort: if another part of the binary already initialised the +/// pool, leave it alone. /// /// [#292]: https://github.com/pnpm/pacquet/pull/292 fn configure_rayon_pool() { diff --git a/pacquet/crates/cli/src/state.rs b/pacquet/crates/cli/src/state.rs index fed28b681e..082518d928 100644 --- a/pacquet/crates/cli/src/state.rs +++ b/pacquet/crates/cli/src/state.rs @@ -1,7 +1,7 @@ use derive_more::{Display, Error}; use miette::Diagnostic; use pacquet_config::Config; -use pacquet_lockfile::{LoadLockfileError, Lockfile}; +use pacquet_lockfile::LazyLockfile; use pacquet_network::{ForInstallsError, NetworkSettings, ThrottledClient}; use pacquet_package_manager::ResolvedPackages; use pacquet_package_manifest::{PackageManifest, PackageManifestError}; @@ -28,8 +28,10 @@ pub struct State { pub config: &'static Config, /// Data from the `package.json` file. pub manifest: PackageManifest, - /// Data from the `pnpm-lock.yaml` file. - pub lockfile: Option, + /// The `pnpm-lock.yaml` file, read + parsed on first access so the + /// repeat-install fast path (which never needs its contents) skips + /// the YAML parse. + pub lockfile: LazyLockfile, /// In-memory cache for packages that have started resolving dependencies. pub resolved_packages: ResolvedPackages, } @@ -41,9 +43,6 @@ pub enum InitStateError { #[diagnostic(transparent)] Manifest(#[error(source)] PackageManifestError), - #[diagnostic(transparent)] - Lockfile(#[error(source)] LoadLockfileError), - #[diagnostic(transparent)] Network(#[error(source)] ForInstallsError), } @@ -63,13 +62,21 @@ impl State { require_lockfile: bool, ) -> Result { let should_load = config.lockfile || require_lockfile; + let lockfile = if should_load { + manifest_path + .parent() + .expect("manifest path always has a parent dir") + .to_path_buf() + .pipe(LazyLockfile::deferred) + } else { + LazyLockfile::disabled() + }; Ok(State { config, manifest: manifest_path .pipe(PackageManifest::create_if_needed) .map_err(InitStateError::Manifest)?, - lockfile: call_load_lockfile(should_load, Lockfile::load_from_current_dir) - .map_err(InitStateError::Lockfile)?, + lockfile, http_client: std::sync::Arc::new( ThrottledClient::for_installs( &config.proxy, @@ -88,21 +95,3 @@ impl State { }) } } - -/// Load the lockfile from the current directory when `should_load` is -/// `true`. Callers compose `should_load` from `config.lockfile || -/// --frozen-lockfile` so the CLI flag is always honoured. -/// -/// This function was extracted to be tested independently. -fn call_load_lockfile( - should_load: bool, - load_lockfile: LoadLockfile, -) -> Result, Error> -where - LoadLockfile: FnOnce() -> Result, Error>, -{ - should_load.then(load_lockfile).transpose().map(Option::flatten) -} - -#[cfg(test)] -mod tests; diff --git a/pacquet/crates/cli/src/state/tests.rs b/pacquet/crates/cli/src/state/tests.rs deleted file mode 100644 index 99080815b0..0000000000 --- a/pacquet/crates/cli/src/state/tests.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::call_load_lockfile; -use pretty_assertions::assert_eq; - -#[test] -fn test_call_load_lockfile() { - macro_rules! case { - ($should_load:expr, $load_lockfile:expr => $output:expr) => {{ - let should_load = $should_load; - let load_lockfile = $load_lockfile; - let output: Result, &str> = $output; - eprintln!( - "CASE: {should_load:?}, {load_lockfile} => {output:?}", - load_lockfile = stringify!($load_lockfile), - ); - assert_eq!(call_load_lockfile(should_load, load_lockfile), output); - }}; - } - - case!(false, || unreachable!() => Ok(None)); - case!(true, || Err("error") => Err("error")); - case!(true, || Ok(None) => Ok(None)); - case!(true, || Ok(Some("value")) => Ok(Some("value"))); -} diff --git a/pacquet/crates/lockfile/src/lazy_lockfile.rs b/pacquet/crates/lockfile/src/lazy_lockfile.rs new file mode 100644 index 0000000000..898182e729 --- /dev/null +++ b/pacquet/crates/lockfile/src/lazy_lockfile.rs @@ -0,0 +1,111 @@ +use crate::{LoadLockfileError, Lockfile}; +use std::{path::PathBuf, sync::OnceLock}; + +/// Wanted lockfile (`pnpm-lock.yaml`) whose read + parse are deferred +/// until a consumer actually needs the contents. +/// +/// The optimistic repeat-install fast path decides "Already up to +/// date" from manifest mtimes alone — upstream's `checkDepsStatus` +/// never reads the wanted lockfile on that path — so parsing a +/// multi-megabyte YAML document up front is pure overhead for the +/// repeat-install case. Commands that always need the lockfile call +/// [`LazyLockfile::get`] immediately and behave as if it were loaded +/// eagerly. +pub struct LazyLockfile { + /// Directory containing `pnpm-lock.yaml`. `None` mirrors + /// `lockfile: false` config: [`Self::get`] yields `None` without + /// touching the filesystem, matching the eager loader's "don't + /// even read the file" behavior. + dir: Option, + cell: OnceLock>, +} + +impl LazyLockfile { + /// A lockfile that will be loaded from `/pnpm-lock.yaml` (the + /// same source as [`Lockfile::load_wanted_from_dir`]) on first + /// [`Self::get`]. + #[must_use] + pub fn deferred(dir: PathBuf) -> Self { + LazyLockfile { dir: Some(dir), cell: OnceLock::new() } + } + + /// A lockfile that is never loaded — [`Self::get`] yields `None` + /// without touching the filesystem. Mirrors `lockfile: false` + /// config. + #[must_use] + pub fn disabled() -> Self { + LazyLockfile { dir: None, cell: OnceLock::new() } + } + + /// A lockfile that is already in memory; [`Self::get`] returns it + /// without touching the filesystem. + #[must_use] + pub fn preloaded(lockfile: Option) -> Self { + let cell = OnceLock::new(); + cell.set(lockfile).expect("a fresh OnceLock accepts the first set"); + LazyLockfile { dir: None, cell } + } + + /// The parsed wanted lockfile, loading it on first call. `None` + /// when the file is absent, empty, or loading is disabled. A load + /// error is returned without being cached, so a subsequent call + /// retries — callers abort on the first error in practice. + pub fn get(&self) -> Result, LoadLockfileError> { + if let Some(lockfile) = self.cell.get() { + return Ok(lockfile.as_ref()); + } + let loaded = match self.dir.as_deref() { + Some(dir) => Lockfile::load_wanted_from_dir(dir)?, + None => None, + }; + Ok(self.cell.get_or_init(|| loaded).as_ref()) + } + + /// Whether a wanted lockfile is known to be available: the parsed + /// document when already loaded, otherwise + /// [`Lockfile::wanted_exists_in_dir`]'s semantic-presence probe — + /// the same absence rules as the loader (an empty or env-only + /// file counts as absent), without paying for the YAML parse on + /// the repeat-install fast path. + #[must_use] + pub fn is_loaded_or_on_disk(&self) -> bool { + if let Some(lockfile) = self.cell.get() { + return lockfile.is_some(); + } + self.dir.as_deref().is_some_and(Lockfile::wanted_exists_in_dir) + } +} + +/// A wanted lockfile that is either already parsed (callers that +/// re-resolve after a manifest mutation hold one) or lazily loadable. +/// `Copy` so it threads through the install pipeline like the +/// `Option<&Lockfile>` it replaces. +#[derive(Clone, Copy)] +pub enum MaybeLazyLockfile<'a> { + Loaded(Option<&'a Lockfile>), + Lazy(&'a LazyLockfile), +} + +impl<'a> MaybeLazyLockfile<'a> { + /// The parsed wanted lockfile, loading it now when lazy. See + /// [`LazyLockfile::get`] for the error contract. + pub fn get(self) -> Result, LoadLockfileError> { + match self { + MaybeLazyLockfile::Loaded(lockfile) => Ok(lockfile), + MaybeLazyLockfile::Lazy(lazy) => lazy.get(), + } + } + + /// Whether a wanted lockfile is available, without forcing a parse + /// in the lazy case. See [`LazyLockfile::is_loaded_or_on_disk`]. + #[must_use] + pub fn is_loaded_or_on_disk(self) -> bool { + match self { + MaybeLazyLockfile::Loaded(lockfile) => lockfile.is_some(), + MaybeLazyLockfile::Lazy(lazy) => lazy.is_loaded_or_on_disk(), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/lockfile/src/lazy_lockfile/tests.rs b/pacquet/crates/lockfile/src/lazy_lockfile/tests.rs new file mode 100644 index 0000000000..5555062867 --- /dev/null +++ b/pacquet/crates/lockfile/src/lazy_lockfile/tests.rs @@ -0,0 +1,87 @@ +use super::{LazyLockfile, MaybeLazyLockfile}; +use crate::Lockfile; +use std::fs; + +fn minimal_lockfile() -> Lockfile { + serde_saphyr::from_str("lockfileVersion: '9.0'\n").expect("parse a minimal lockfile") +} + +#[test] +fn preloaded_returns_the_stored_lockfile_without_io() { + let lazy = LazyLockfile::preloaded(Some(minimal_lockfile())); + let loaded = lazy.get().expect("preloaded lockfile loads infallibly"); + assert!(loaded.is_some()); + assert!(lazy.is_loaded_or_on_disk()); +} + +#[test] +fn preloaded_none_reports_absent() { + let lazy = LazyLockfile::preloaded(None); + assert!(lazy.get().expect("preloaded lockfile loads infallibly").is_none()); + assert!(!lazy.is_loaded_or_on_disk()); +} + +#[test] +fn disabled_never_touches_the_filesystem() { + let lazy = LazyLockfile::disabled(); + assert!(lazy.get().expect("disabled load is infallible").is_none()); + assert!(!lazy.is_loaded_or_on_disk()); +} + +#[test] +fn deferred_loads_from_the_given_dir_not_the_process_cwd() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join(Lockfile::FILE_NAME), "lockfileVersion: '9.0'\n") + .expect("write pnpm-lock.yaml"); + + let lazy = LazyLockfile::deferred(dir.path().to_path_buf()); + assert!(lazy.is_loaded_or_on_disk(), "probe must find the dir-addressed lockfile"); + assert!(lazy.get().expect("deferred load succeeds").is_some()); + + let empty = tempfile::tempdir().expect("tempdir"); + let lazy = LazyLockfile::deferred(empty.path().to_path_buf()); + assert!(!lazy.is_loaded_or_on_disk()); + assert!(lazy.get().expect("absent lockfile loads as None").is_none()); +} + +#[test] +fn empty_and_env_only_files_count_as_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(Lockfile::FILE_NAME); + + fs::write(&path, "").expect("write empty lockfile"); + let lazy = LazyLockfile::deferred(dir.path().to_path_buf()); + assert!(!lazy.is_loaded_or_on_disk(), "an empty file must count as absent"); + + fs::write(&path, "---\nenvDependencies:\n node: '22.0.0'\n").expect("write env-only lockfile"); + let lazy = LazyLockfile::deferred(dir.path().to_path_buf()); + assert!(!lazy.is_loaded_or_on_disk(), "an env-only document must count as absent"); + assert!(lazy.get().expect("env-only lockfile loads as None").is_none()); +} + +#[cfg(unix)] +#[test] +fn unreadable_lockfile_counts_as_present() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(Lockfile::FILE_NAME); + fs::write(&path, "lockfileVersion: '9.0'\n").expect("write pnpm-lock.yaml"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).expect("drop permissions"); + + let lazy = LazyLockfile::deferred(dir.path().to_path_buf()); + let present = lazy.is_loaded_or_on_disk(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("restore permissions"); + assert!(present, "an unreadable lockfile must not be mistaken for a missing one"); +} + +#[test] +fn loaded_variant_passes_through() { + let lockfile = minimal_lockfile(); + let maybe = MaybeLazyLockfile::Loaded(Some(&lockfile)); + assert!(maybe.get().expect("loaded variant is infallible").is_some()); + assert!(maybe.is_loaded_or_on_disk()); + let maybe = MaybeLazyLockfile::Loaded(None); + assert!(maybe.get().expect("loaded variant is infallible").is_none()); + assert!(!maybe.is_loaded_or_on_disk()); +} diff --git a/pacquet/crates/lockfile/src/lib.rs b/pacquet/crates/lockfile/src/lib.rs index 9cb23c859a..463b22c9f1 100644 --- a/pacquet/crates/lockfile/src/lib.rs +++ b/pacquet/crates/lockfile/src/lib.rs @@ -2,6 +2,7 @@ mod catalog_snapshots; mod comver; mod env_lockfile; mod freshness; +mod lazy_lockfile; mod load_lockfile; mod lockfile_version; mod package_metadata; @@ -25,6 +26,7 @@ pub use catalog_snapshots::*; pub use comver::*; pub use env_lockfile::*; pub use freshness::*; +pub use lazy_lockfile::*; pub use load_lockfile::*; pub use lockfile_version::*; pub use package_metadata::*; diff --git a/pacquet/crates/lockfile/src/load_lockfile.rs b/pacquet/crates/lockfile/src/load_lockfile.rs index bfc1f49a18..51d2f22707 100644 --- a/pacquet/crates/lockfile/src/load_lockfile.rs +++ b/pacquet/crates/lockfile/src/load_lockfile.rs @@ -62,6 +62,27 @@ impl Lockfile { Self::load_from_path(&dir.join(Lockfile::FILE_NAME)) } + /// Whether `/pnpm-lock.yaml` would load as `Some`: the file + /// exists and its main document is non-empty. The same absence + /// rules as [`Self::load_wanted_from_dir`] (a missing file, an + /// empty file, and an env-only combined document all count as + /// absent) without paying for the YAML parse — only the read and + /// the document split. + /// + /// Any read failure other than `NotFound` (permissions, invalid + /// UTF-8, I/O) reports the file as present: an existing-but- + /// unreadable lockfile must not be mistaken for a missing one — + /// the regenerate-on-missing path would overwrite it — and the + /// real load surfaces the underlying error when the contents are + /// actually needed. + #[must_use] + pub fn wanted_exists_in_dir(dir: &Path) -> bool { + match fs::read_to_string(dir.join(Lockfile::FILE_NAME)) { + Ok(content) => !extract_main_document(&content).trim().is_empty(), + Err(error) => error.kind() != ErrorKind::NotFound, + } + } + fn load_from_path(file_path: &Path) -> Result, LoadLockfileError> { let content = match fs::read_to_string(file_path) { Ok(content) => content, diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index 74cda0a4ee..401a4171b5 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -10,7 +10,7 @@ use pacquet_catalogs_config::{ use pacquet_catalogs_protocol_parser::parse_catalog_protocol; use pacquet_catalogs_types::Catalogs; use pacquet_config::Config; -use pacquet_lockfile::Lockfile; +use pacquet_lockfile::{Lockfile, MaybeLazyLockfile}; use pacquet_network::ThrottledClient; use pacquet_package_manifest::{DependencyGroup, PackageManifest, PackageManifestError}; use pacquet_registry::{PackageTag, PackageVersion}; @@ -216,7 +216,7 @@ where http_client_arc, config, manifest, - lockfile, + lockfile: MaybeLazyLockfile::Loaded(lockfile), lockfile_path, dependency_groups: list_dependency_groups(), frozen_lockfile: false, diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 70ede14ca2..1b36b9c484 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -17,7 +17,8 @@ use pacquet_executor::{ ScriptsPrependNodePath as ExecScriptsPrependNodePath, run_project_lifecycle_scripts, }; use pacquet_lockfile::{ - LoadLockfileError, Lockfile, SaveLockfileError, StalenessReason, satisfies_package_manifest, + LazyLockfile, LoadLockfileError, Lockfile, MaybeLazyLockfile, SaveLockfileError, + StalenessReason, satisfies_package_manifest, }; use pacquet_lockfile_verification::{ VerifyError, VerifyLockfileResolutionsOptions, record_lockfile_verified, @@ -41,7 +42,7 @@ use pacquet_workspace_state::{ }; use std::{ collections::BTreeMap, - path::Path, + path::{Path, PathBuf}, sync::{Arc, atomic::AtomicU8}, time::SystemTime, }; @@ -106,7 +107,7 @@ where pub http_client_arc: Arc, pub config: &'static Config, pub manifest: &'a PackageManifest, - pub lockfile: Option<&'a Lockfile>, + pub lockfile: MaybeLazyLockfile<'a>, /// Absolute path of the loaded `pnpm-lock.yaml`. Threaded into /// the lockfile-verification gate so the per-path stat shortcut /// in `/lockfile-verified.jsonl` can fire on repeat @@ -285,6 +286,12 @@ pub enum InstallError { #[diagnostic(transparent)] LoadCurrentLockfile(#[error(source)] LoadLockfileError), + /// Surfaces a `pnpm-lock.yaml` read or parse failure from the + /// deferred load that runs once the repeat-install fast path has + /// passed on the install (see [`MaybeLazyLockfile`]). + #[diagnostic(transparent)] + LoadWantedLockfile(#[error(source)] LoadLockfileError), + /// Surfaces a failure to persist the current lockfile so the next /// install can diff against it. A best-effort warn would let /// silent disk-full or permission issues compound across installs; @@ -546,13 +553,20 @@ where // forcing the frozen path. let project_manifests = build_project_manifests_list(&workspace_root, manifest, workspace_projects.as_deref()); - // `pacquet update` must always re-resolve, so it bypasses the - // optimistic short-circuit: a compatible bump leaves the - // manifest byte-identical, which the repeat-install check would - // otherwise read as "nothing changed → already up to date" and - // skip the registry re-resolution entirely. Gating on - // `KeepAll` keeps `install` / `add` on the fast path. - if matches!(update_seed_policy, UpdateSeedPolicy::KeepAll) + // Only a full `pacquet install` may short-circuit. `add` and + // `remove` mutate the manifest in memory and persist it after + // this run returns, so the on-disk mtimes the check reads still + // describe the pre-mutation project — without this gate a fresh + // workspace state would read as "nothing changed → already up + // to date" and the mutation would never be resolved or + // materialized. Mirrors upstream `installDeps` calling + // `checkDepsStatus` only for the plain-install mutation, never + // for `installSome` / `uninstallSome`. `pacquet update` is + // excluded through its seed policy: a compatible bump leaves + // the manifest byte-identical, which the check would likewise + // read as up to date and skip the registry re-resolution. + let optimistic_decision = is_full_install + && matches!(update_seed_policy, UpdateSeedPolicy::KeepAll) && !frozen_lockfile && check_optimistic_repeat_install(&OptimisticRepeatInstallCheck { workspace_root: &workspace_root, @@ -563,8 +577,8 @@ where is_workspace_install: workspace_manifest.is_some(), lockfile, catalogs: &catalogs, - }) == OptimisticRepeatInstallDecision::UpToDate - { + }) == OptimisticRepeatInstallDecision::UpToDate; + if optimistic_decision { Reporter::emit(&LogEvent::Pnpm(PnpmLog { level: LogLevel::Info, message: "Already up to date".to_string(), @@ -574,6 +588,10 @@ where return Ok(()); } + // Past the repeat-install fast path every install flavor needs + // the wanted lockfile's contents; force the deferred load here. + let lockfile = lockfile.get().map_err(InstallError::LoadWantedLockfile)?; + // Register the project against the shared store for prune // tracking, once per install at the workspace root. Mirrors // upstream's call into `@pnpm/store.controller`'s @@ -1757,6 +1775,70 @@ fn run_projects_lifecycle_scripts( /// /// `workspace_projects.is_none()` covers single-project installs (no /// `pnpm-workspace.yaml`) — the only manifest is the root one. +/// Inputs for [`install_already_up_to_date`]. +pub struct UpToDateFastPathCheck<'a> { + pub config: &'a Config, + pub manifest: &'a PackageManifest, + pub dependency_groups: Vec, + pub node_linker: NodeLinker, +} + +/// Pre-runtime twin of the repeat-install short-circuit inside +/// [`Install::run`]: same workspace discovery, same +/// [`check_optimistic_repeat_install`] inputs, callable from a +/// synchronous context so the CLI can finish an up-to-date install +/// before paying for the async runtime, the HTTP client, and the +/// state setup. Returns the workspace root — the reporter `prefix` +/// for the "Already up to date" emission — when the install can +/// short-circuit. +/// +/// Failures deliberately collapse to `None`: the caller falls through +/// to the full install path, which reproduces the failure with its +/// established error shape. +#[must_use] +pub fn install_already_up_to_date(check: &UpToDateFastPathCheck<'_>) -> Option { + let UpToDateFastPathCheck { config, manifest, dependency_groups, node_linker } = check; + let included = IncludedDependencies { + dependencies: dependency_groups.contains(&DependencyGroup::Prod), + dev_dependencies: dependency_groups.contains(&DependencyGroup::Dev), + optional_dependencies: dependency_groups.contains(&DependencyGroup::Optional), + }; + let manifest_dir = manifest.path().parent()?; + let workspace_dir_opt = pacquet_workspace::find_workspace_dir(manifest_dir).ok()?; + let workspace_root = workspace_dir_opt.clone().unwrap_or_else(|| manifest_dir.to_path_buf()); + let workspace_manifest = match workspace_dir_opt.as_deref() { + Some(dir) => pacquet_workspace::read_workspace_manifest(dir).ok()?, + None => None, + }; + let catalogs = match config.catalogs.clone() { + Some(catalogs) => catalogs, + None => get_catalogs_from_workspace_manifest(workspace_manifest.as_ref()).ok()?, + }; + let workspace_projects = + load_workspace_projects(&workspace_root, workspace_manifest.as_ref()).ok()?; + let project_manifests = + build_project_manifests_list(&workspace_root, manifest, workspace_projects.as_deref()); + // Same lockfile source as `State::init`'s (the manifest's + // directory), so the pre-runtime check and the in-pipeline check + // reach their verdicts from the same file. + let lockfile = if config.lockfile { + LazyLockfile::deferred(manifest_dir.to_path_buf()) + } else { + LazyLockfile::disabled() + }; + (check_optimistic_repeat_install(&OptimisticRepeatInstallCheck { + workspace_root: &workspace_root, + config, + node_linker: *node_linker, + included, + project_manifests: &project_manifests, + is_workspace_install: workspace_manifest.is_some(), + lockfile: MaybeLazyLockfile::Lazy(&lockfile), + catalogs: &catalogs, + }) == OptimisticRepeatInstallDecision::UpToDate) + .then_some(workspace_root) +} + fn build_project_manifests_list<'a>( workspace_root: &std::path::Path, root_manifest: &'a PackageManifest, diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index 64903fb939..d2c5cc8ecd 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -3,9 +3,9 @@ reason = "struct-literal test fixtures; field types are evident from the literal and naming each would force ~20 imports" )] -use super::{Install, InstallError}; +use super::{Install, InstallError, UpToDateFastPathCheck, install_already_up_to_date}; use pacquet_config::Config; -use pacquet_lockfile::Lockfile; +use pacquet_lockfile::{Lockfile, MaybeLazyLockfile}; use pacquet_modules_yaml::{ DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, LayoutVersion, Modules, NodeLinker, read_modules_manifest, write_modules_manifest, @@ -83,7 +83,7 @@ async fn should_install_dependencies() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -179,7 +179,7 @@ async fn lockfile_only_routes_scoped_packages_to_configured_scoped_registry() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -231,7 +231,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -280,7 +280,7 @@ async fn should_error_when_frozen_lockfile_and_update_checksums_are_both_set() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -358,7 +358,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -429,7 +429,7 @@ async fn npm_alias_dependency_installs_under_alias_key() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -519,7 +519,7 @@ async fn unversioned_npm_alias_defaults_to_latest() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -592,7 +592,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -688,7 +688,7 @@ async fn install_emits_pnpm_event_sequence() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -838,7 +838,7 @@ async fn install_writes_modules_yaml() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, // Drive a non-default `included`: prod + optional, no dev, // so the assertion below pins the mapping of dispatched @@ -948,7 +948,7 @@ async fn install_writes_workspace_state() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, // Same `included` shape as `install_writes_modules_yaml` so the // dev/optional/production assertions below line up with the @@ -1185,7 +1185,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: false, @@ -1263,7 +1263,7 @@ async fn auto_install_peers_does_not_cascade_optional_peers() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: false, @@ -1364,7 +1364,7 @@ async fn auto_install_peers_skips_meta_only_optional_peers() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: false, @@ -1501,7 +1501,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -1604,7 +1604,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -1715,7 +1715,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -1770,7 +1770,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -1867,7 +1867,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -1974,7 +1974,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2043,7 +2043,7 @@ async fn ignore_manifest_check_bypasses_manifest_freshness_gate() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2113,7 +2113,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2209,7 +2209,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check() http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2321,7 +2321,7 @@ async fn frozen_lockfile_resolves_catalog_protocol_in_overrides_before_freshness http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2387,7 +2387,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2480,7 +2480,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2592,7 +2592,7 @@ async fn gvs_persists_global_virtual_store_dir_in_modules_yaml_and_context_log() http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2711,7 +2711,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -2796,7 +2796,7 @@ async fn frozen_lockfile_under_gvs_registers_workspace_root_only() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3001,7 +3001,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3124,7 +3124,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, @@ -3235,7 +3235,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3346,7 +3346,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3442,7 +3442,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, @@ -3540,7 +3540,7 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, // `--no-optional` shape: Optional NOT in the dispatch list. dependency_groups: [DependencyGroup::Prod], @@ -3639,7 +3639,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3732,7 +3732,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -3833,7 +3833,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, @@ -3932,7 +3932,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], frozen_lockfile: true, @@ -4035,7 +4035,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -4140,7 +4140,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -4231,7 +4231,7 @@ async fn fresh_install_writes_pnpm_lock_yaml_with_expected_shape() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4320,7 +4320,7 @@ async fn fresh_install_splits_dev_and_prod_dependency_sections() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4395,7 +4395,7 @@ async fn fresh_install_records_user_written_specifier() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4466,7 +4466,7 @@ async fn fresh_install_lockfile_round_trips_through_load_save_load() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4536,7 +4536,7 @@ async fn fresh_install_with_lockfile_disabled_does_not_write_a_lockfile() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4609,7 +4609,7 @@ async fn fresh_install_also_writes_current_lockfile_under_virtual_store() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4698,7 +4698,7 @@ async fn fresh_install_with_lockfile_disabled_skips_current_lockfile_too() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4765,7 +4765,7 @@ async fn fresh_install_marks_optional_snapshots_in_pnpm_lock_yaml() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, @@ -4857,7 +4857,7 @@ async fn fresh_install_hoisted_node_linker_records_modules_yaml() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -4929,7 +4929,7 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -5003,7 +5003,7 @@ async fn prefer_frozen_lockfile_takes_frozen_path_when_lockfile_is_fresh() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], // No `--frozen-lockfile`; the dispatch must auto-go-frozen @@ -5080,7 +5080,7 @@ async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -5153,7 +5153,7 @@ async fn stale_lockfile_under_no_flag_falls_through_to_fresh_resolve() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -5410,7 +5410,7 @@ async fn frozen_install_short_circuits_when_modules_and_lockfile_are_consistent( http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -5594,7 +5594,7 @@ async fn optimistic_repeat_install_skips_entire_pipeline_when_state_is_fresh() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -5644,6 +5644,89 @@ async fn optimistic_repeat_install_skips_entire_pipeline_when_state_is_fresh() { ); } +/// The synchronous pre-runtime twin of the short-circuit +/// ([`install_already_up_to_date`]) must reach the same verdict from +/// the same on-disk state — and flip to `None` (fall through to the +/// full install) as soon as a manifest outdates the recorded +/// validation timestamp. +#[test] +fn sync_fast_path_matches_optimistic_short_circuit() { + let dir = tempdir().unwrap(); + let project_root = dir.path().join("project"); + let modules_dir = project_root.join("node_modules"); + + std::fs::create_dir_all(&modules_dir).expect("create modules dir so the deps gate passes"); + let manifest_path = project_root.join("package.json"); + let mut manifest = PackageManifest::create_if_needed(manifest_path.clone()).unwrap(); + manifest.add_dependency("sibling", "link:../sibling", DependencyGroup::Prod).unwrap(); + manifest.save().unwrap(); + std::fs::write(project_root.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") + .expect("seed pnpm-lock.yaml"); + + let mut config = Config::new(); + config.lockfile = false; + config.store_dir = dir.path().join("pacquet-store").into(); + config.modules_dir = modules_dir.clone(); + config.virtual_store_dir = modules_dir.join(".pacquet"); + let config = config.leak(); + + let included = pacquet_modules_yaml::IncludedDependencies { + dependencies: true, + dev_dependencies: false, + optional_dependencies: false, + }; + let mut projects = std::collections::BTreeMap::new(); + projects.insert( + project_root.to_string_lossy().into_owned(), + workspace_state::ProjectEntry { + name: Some("project".to_string()), + version: Some("1.0.0".to_string()), + }, + ); + let settings = crate::optimistic_repeat_install::current_settings( + config, + pacquet_config::NodeLinker::Isolated, + included, + ); + workspace_state::update_workspace_state( + &project_root, + &pacquet_workspace_state::WorkspaceState { + last_validated_timestamp: pacquet_workspace_state::now_millis() + 60_000, + projects, + pnpmfiles: Vec::new(), + filtered_install: false, + config_dependencies: None, + settings, + }, + ) + .expect("seed workspace state"); + + let check = UpToDateFastPathCheck { + config, + manifest: &manifest, + dependency_groups: vec![DependencyGroup::Prod], + node_linker: pacquet_config::NodeLinker::Isolated, + }; + let root = install_already_up_to_date(&check); + assert_eq!(root.as_deref(), Some(&*project_root), "fresh state must short-circuit"); + + // Outdate the manifest relative to the recorded timestamp: the + // fast path must decline and leave the decision to the full + // install. The far-future mtime defeats filesystem mtime + // resolution without sleeping. + let future = std::time::SystemTime::now() + std::time::Duration::from_mins(2); + let file = std::fs::OpenOptions::new() + .append(true) + .open(&manifest_path) + .expect("open manifest for mtime bump"); + file.set_modified(future).expect("bump manifest mtime"); + drop(file); + // The manifest content still matches no lockfile (config.lockfile + // is off and no current lockfile exists), so the content re-check + // cannot vouch for it either. + assert_eq!(install_already_up_to_date(&check), None, "modified manifest must fall through"); +} + /// `--frozen-lockfile` disables the optimistic short-circuit because /// a headless install must always fail loudly on a missing or stale /// lockfile (matching pnpm's `installDeps` not calling @@ -5748,7 +5831,7 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], // The only difference vs the optimistic test above. @@ -5786,6 +5869,145 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() { // absent so the polarity of the gate is clear. } +/// `add` / `remove` mutate the manifest in memory and persist it only +/// after `Install::run` returns, so the on-disk mtimes the optimistic +/// check reads still describe the pre-mutation project. A partial +/// install (`is_full_install: false`) must therefore never take the +/// optimistic short-circuit — otherwise a fresh workspace state would +/// read as "already up to date" and the mutation would never be +/// resolved or materialized. Mirrors upstream `installDeps` calling +/// `checkDepsStatus` only for the plain-install mutation. +#[tokio::test] +async fn partial_install_disables_optimistic_short_circuit() { + static EVENTS: Mutex> = Mutex::new(Vec::new()); + EVENTS.lock().unwrap().clear(); + + struct RecordingReporter; + impl Reporter for RecordingReporter { + fn emit(event: &LogEvent) { + EVENTS.lock().unwrap().push(event.clone()); + } + } + + let dir = tempdir().unwrap(); + let store_dir = dir.path().join("pacquet-store"); + let project_root = dir.path().join("project"); + let modules_dir = project_root.join("node_modules"); + let virtual_store_dir = modules_dir.join(".pacquet"); + + std::fs::create_dir_all(&project_root).expect("create project root"); + let manifest_path = project_root.join("package.json"); + let mut manifest = PackageManifest::create_if_needed(manifest_path).unwrap(); + manifest.add_dependency("sibling", "link:../sibling", DependencyGroup::Prod).unwrap(); + manifest.save().unwrap(); + + let mut config = Config::new(); + config.lockfile = false; + config.store_dir = store_dir.into(); + config.modules_dir = modules_dir.clone(); + config.virtual_store_dir = virtual_store_dir.clone(); + let config = config.leak(); + + let lockfile: Lockfile = serde_saphyr::from_str(text_block! { + "lockfileVersion: '9.0'" + "importers:" + " .:" + " dependencies:" + " sibling:" + " specifier: link:../sibling" + " version: link:../sibling" + "packages: {}" + "snapshots: {}" + }) + .expect("parse lockfile"); + + let included = pacquet_modules_yaml::IncludedDependencies { + dependencies: true, + dev_dependencies: false, + optional_dependencies: false, + }; + + // Seed the same state the optimistic test uses, so the only + // difference between the two is `is_full_install`. + let seed_modules = Modules { + layout_version: Some(LayoutVersion), + node_linker: Some(NodeLinker::Isolated), + included, + hoist_pattern: config.hoist_pattern.clone(), + public_hoist_pattern: config.public_hoist_pattern.clone(), + store_dir: config.store_dir.display().to_string(), + virtual_store_dir: config.effective_virtual_store_dir().to_string_lossy().into_owned(), + virtual_store_dir_max_length: config.virtual_store_dir_max_length, + ..Default::default() + }; + write_modules_manifest::(&modules_dir, seed_modules).expect("seed .modules.yaml"); + lockfile.save_current_to_virtual_store_dir(&virtual_store_dir).expect("seed current lockfile"); + + let mut projects = std::collections::BTreeMap::new(); + projects.insert( + project_root.to_string_lossy().into_owned(), + workspace_state::ProjectEntry { + name: Some("project".to_string()), + version: Some("1.0.0".to_string()), + }, + ); + let settings = crate::optimistic_repeat_install::current_settings( + config, + pacquet_config::NodeLinker::Isolated, + included, + ); + workspace_state::update_workspace_state( + &project_root, + &pacquet_workspace_state::WorkspaceState { + last_validated_timestamp: pacquet_workspace_state::now_millis() + 60_000, + projects, + pnpmfiles: Vec::new(), + filtered_install: false, + config_dependencies: None, + settings, + }, + ) + .expect("seed workspace state"); + + Install { + tarball_mem_cache: Default::default(), + http_client: &Default::default(), + http_client_arc: std::sync::Arc::new(Default::default()), + config, + manifest: &manifest, + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), + lockfile_path: None, + dependency_groups: [DependencyGroup::Prod], + frozen_lockfile: false, + prefer_frozen_lockfile: None, + ignore_manifest_check: false, + skip_runtimes: false, + trust_lockfile: true, + update_checksums: false, + // The only difference vs the optimistic test above. + is_full_install: false, + supported_architectures: None, + node_linker: pacquet_config::NodeLinker::Isolated, + lockfile_only: false, + resolved_packages: &Default::default(), + update_seed_policy: crate::UpdateSeedPolicy::KeepAll, + auth_override: None, + resolution_observer: None, + } + .run::() + .await + .expect("partial install must still succeed via the regular dispatch"); + + let captured = EVENTS.lock().unwrap(); + assert!( + !captured.iter().any(|event| matches!( + event, + LogEvent::Pnpm(log) if log.message == "Already up to date" + )), + "the optimistic 'Already up to date' log MUST NOT fire for a partial install; got events: {captured:#?}", + ); +} + /// Regression: a single-project install with NO lockfile anywhere — /// `pnpm-lock.yaml` is gone and the virtual store has no current /// `lock.yaml` to stand in for it — must NOT short-circuit, even when @@ -5894,7 +6116,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing( http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -5977,7 +6199,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6033,7 +6255,7 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() { // *before* the lockfile is even loaded. (Matching pnpm's // dispatch ordering: `checkDepsStatus` runs before any // lockfile parse.) - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6121,7 +6343,7 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6185,7 +6407,7 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() { http_client_arc: std::sync::Arc::new(Default::default()), config: second_config, manifest: &touched_manifest, - lockfile: Some(&wanted_lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&wanted_lockfile)), lockfile_path: Some(&lockfile_path), dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6272,7 +6494,7 @@ async fn install_then_go_offline() -> (tempfile::TempDir, &'static Config, Packa http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6360,7 +6582,7 @@ async fn optimistic_repeat_install_short_circuits_offline_when_touched_manifest_ http_client_arc: std::sync::Arc::new(Default::default()), config: offline_config, manifest: &touched_manifest, - lockfile: Some(&wanted_lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&wanted_lockfile)), lockfile_path: Some(&lockfile_path), dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6440,7 +6662,7 @@ async fn optimistic_repeat_install_restores_missing_lockfile_offline() { http_client_arc: std::sync::Arc::new(Default::default()), config: offline_config, manifest: &touched_manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6584,7 +6806,7 @@ async fn fresh_lockfile_only_with_overrides( http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6690,7 +6912,7 @@ async fn fresh_install_applies_package_extensions_to_dependency_manifest() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: false, @@ -6790,7 +7012,7 @@ async fn frozen_lockfile_errors_when_package_extensions_drift_from_lockfile() { http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: Some(&lockfile), + lockfile: MaybeLazyLockfile::Loaded(Some(&lockfile)), lockfile_path: None, dependency_groups: [DependencyGroup::Prod], frozen_lockfile: true, @@ -6872,7 +7094,7 @@ async fn install_with_pnpmfile_reporter( http_client_arc: std::sync::Arc::new(Default::default()), config, manifest: &manifest, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), lockfile_path: None, dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional], frozen_lockfile: false, diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs index 82e04da523..a8d80880d6 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs @@ -56,7 +56,7 @@ use std::{ use pacquet_catalogs_types::Catalogs; use pacquet_config::{Config, LinkWorkspacePackages, NodeLinker}; -use pacquet_lockfile::{ImporterDepVersion, Lockfile, ProjectSnapshot}; +use pacquet_lockfile::{ImporterDepVersion, Lockfile, MaybeLazyLockfile, ProjectSnapshot}; use pacquet_modules_yaml::IncludedDependencies; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_workspace_state::{ @@ -107,15 +107,16 @@ pub struct OptimisticRepeatInstallCheck<'a> { /// the outer `try` converts into `upToDate: false` — and keys its /// comparisons off the lockfile mtimes instead. pub is_workspace_install: bool, - /// The wanted lockfile as loaded by the CLI (`None` when - /// `pnpm-lock.yaml` is absent or empty). Consulted only by the - /// modified-manifests content re-check; the pure-mtime fast path - /// never reads it. When `None` and `/lock.yaml` - /// exists, the current lockfile stands in as the wanted one — it - /// records exactly what the previous install materialized — and - /// `pnpm-lock.yaml` is regenerated from it before the check - /// reports up-to-date. - pub lockfile: Option<&'a Lockfile>, + /// The wanted lockfile (`None` once loaded when `pnpm-lock.yaml` + /// is absent or empty). Consulted only by the modified-manifests + /// content re-check; the pure-mtime fast path never reads it — + /// which is why it arrives lazily, so the common repeat-install + /// run skips the YAML parse entirely. When absent and + /// `/lock.yaml` exists, the current lockfile + /// stands in as the wanted one — it records exactly what the + /// previous install materialized — and `pnpm-lock.yaml` is + /// regenerated from it before the check reports up-to-date. + pub lockfile: MaybeLazyLockfile<'a>, /// Workspace catalogs, for resolving `catalog:` values inside /// `pnpm.overrides` before the lockfile settings comparison. pub catalogs: &'a Catalogs, @@ -287,7 +288,7 @@ fn regenerate_wanted_lockfile_if_missing( check: &OptimisticRepeatInstallCheck<'_>, loaded_current: Option, ) -> Result<(), &'static str> { - if check.lockfile.is_some() || !check.config.lockfile { + if check.lockfile.is_loaded_or_on_disk() || !check.config.lockfile { return Ok(()); } let current = match loaded_current { @@ -340,6 +341,7 @@ fn modified_manifests_match_lockfile( } = check; let mut loaded_current: Option = None; let mut wanted_is_current = false; + let lockfile = lockfile.get().map_err(|_| "the wanted lockfile cannot be read or parsed")?; let (wanted, wanted_mtime_ms): (&Lockfile, i64) = if let Some(wanted) = lockfile { let Some(mtime) = mtime_ms(&workspace_root.join(Lockfile::FILE_NAME)) else { return Err( diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs index f193cc922b..81d4f93554 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs @@ -2,7 +2,7 @@ use super::{ Decision, OptimisticRepeatInstallCheck, check_optimistic_repeat_install, current_settings, }; use pacquet_config::Config; -use pacquet_lockfile::Lockfile; +use pacquet_lockfile::{Lockfile, MaybeLazyLockfile}; use pacquet_modules_yaml::IncludedDependencies; use pacquet_package_manifest::PackageManifest; use pacquet_workspace_state::{ @@ -31,7 +31,7 @@ fn check( included: isolated_included(), project_manifests, is_workspace_install: false, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), catalogs: &BTreeMap::default(), }) } @@ -1112,7 +1112,7 @@ fn returns_skipped_when_sibling_node_modules_missing_for_project_with_deps() { (sibling_dir, &sibling_manifest), ], is_workspace_install: true, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), catalogs: &BTreeMap::default(), }); assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("node_modules"))); @@ -1174,7 +1174,7 @@ fn returns_up_to_date_in_workspace_mode_without_lockfile() { included: isolated_included(), project_manifests: &[(dir.path().to_path_buf(), &manifest)], is_workspace_install: true, - lockfile: None, + lockfile: MaybeLazyLockfile::Loaded(None), catalogs: &BTreeMap::default(), }); assert_eq!(decision, Decision::UpToDate); @@ -1249,7 +1249,7 @@ fn content_check_decision( included: isolated_included(), project_manifests, is_workspace_install, - lockfile: lockfile.as_ref(), + lockfile: MaybeLazyLockfile::Loaded(lockfile.as_ref()), catalogs: &BTreeMap::default(), }) } @@ -1423,7 +1423,7 @@ importers: (sibling_dir, &sibling_manifest), ], is_workspace_install: true, - lockfile: lockfile.as_ref(), + lockfile: MaybeLazyLockfile::Loaded(lockfile.as_ref()), catalogs: &BTreeMap::default(), }) } diff --git a/pacquet/crates/package-manager/src/remove.rs b/pacquet/crates/package-manager/src/remove.rs index eef68f5514..2942482afe 100644 --- a/pacquet/crates/package-manager/src/remove.rs +++ b/pacquet/crates/package-manager/src/remove.rs @@ -2,7 +2,7 @@ use crate::{Install, InstallError, ResolvedPackages, UpdateSeedPolicy}; use derive_more::{Display, Error}; use miette::Diagnostic; use pacquet_config::Config; -use pacquet_lockfile::Lockfile; +use pacquet_lockfile::{Lockfile, MaybeLazyLockfile}; use pacquet_network::ThrottledClient; use pacquet_package_manifest::{DependencyGroup, PackageManifest, PackageManifestError}; use pacquet_reporter::{LogEvent, LogLevel, PackageManifestLog, PackageManifestMessage, Reporter}; @@ -105,7 +105,7 @@ impl Remove<'_> { http_client_arc, config, manifest, - lockfile, + lockfile: MaybeLazyLockfile::Loaded(lockfile), lockfile_path, // `pnpm remove`'s `include` defaults to every dependency // group (`production`/`dev`/`optional` !== false), so the diff --git a/pacquet/crates/package-manager/src/update.rs b/pacquet/crates/package-manager/src/update.rs index a295f0e4f9..0ea34c9f58 100644 --- a/pacquet/crates/package-manager/src/update.rs +++ b/pacquet/crates/package-manager/src/update.rs @@ -10,7 +10,7 @@ use pacquet_catalogs_config::{ use pacquet_catalogs_protocol_parser::parse_catalog_protocol; use pacquet_catalogs_types::Catalogs; use pacquet_config::{CatalogMode, Config, matcher::create_matcher}; -use pacquet_lockfile::Lockfile; +use pacquet_lockfile::{Lockfile, MaybeLazyLockfile}; use pacquet_network::ThrottledClient; use pacquet_package_manifest::{DependencyGroup, PackageManifest, PackageManifestError}; use pacquet_registry::{PackageTag, PackageVersion}; @@ -492,7 +492,7 @@ impl Update<'_> { http_client_arc, config, manifest, - lockfile, + lockfile: MaybeLazyLockfile::Loaded(lockfile), lockfile_path, // `include` is always all-true for updates: the materialized // `node_modules` layout must not change just because the diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs index 9b37a06071..8a64b19493 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs @@ -151,9 +151,11 @@ pub async fn fetch_full_metadata_cached( pkg_name: pkg_name.to_string(), }); }; - return load_meta_async(Some(&path)).await.ok_or_else(|| { + let meta = load_meta_async(Some(&path)).await.ok_or_else(|| { FetchMetadataError::CacheMissingAfter304 { pkg_name: pkg_name.to_string() } - }); + })?; + renew_mirror_freshness(&path); + return Ok(meta); } let response = response @@ -214,5 +216,37 @@ pub async fn fetch_full_metadata_cached( meta.pipe(Ok) } +/// Bump the mirror file's mtime to "now" after a `304 Not Modified`. +/// +/// The publishedBy mtime shortcut in [`crate::pick_package()`] treats +/// a mirror younger than the maturity cutoff as authoritative. A 304 +/// proves the cached packument equals the registry's current one, so +/// the validation clock legitimately restarts here — without the +/// touch, a mirror older than `minimumReleaseAge` re-validates every +/// package on every subsequent install, because a 304 never rewrites +/// the file. +/// +/// The append-mode open carries the write-attributes access Windows' +/// `set_modified` needs (a read-only handle cannot set file times +/// there). The read-only fallback covers Unix mirrors whose mode +/// dropped write permission: `futimens`-style timestamp syscalls +/// require ownership, not write access. Best-effort: a failure only +/// costs the next install another conditional request. +fn renew_mirror_freshness(path: &Path) { + let touched = std::fs::OpenOptions::new() + .append(true) + .open(path) + .or_else(|_| std::fs::File::open(path)) + .and_then(|file| file.set_modified(std::time::SystemTime::now())); + if let Err(error) = touched { + tracing::debug!( + target: "pacquet_resolving_npm_resolver::cache", + ?error, + path = %path.display(), + "could not renew mirror freshness after 304", + ); + } +} + #[cfg(test)] mod tests; diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs index 2adaf25291..9c136e4e1c 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs @@ -112,6 +112,65 @@ async fn warm_cache_serves_from_mirror_on_304() { assert_eq!(second_pkg.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z")); } +/// A 304 renews the mirror's mtime so the publishedBy freshness +/// shortcut in `pick_package` can fire again on the next install — +/// without the touch, a mirror older than `minimumReleaseAge` +/// re-validates on every subsequent install forever. +#[tokio::test] +async fn a_304_renews_the_mirror_mtime() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/acme") + .with_status(200) + .with_header("etag", r#"W/"v1""#) + .with_body(PACKAGE_BODY) + .expect(1) + .create_async() + .await; + server + .mock("GET", "/acme") + .match_header("if-none-match", r#"W/"v1""#) + .with_status(304) + .expect(1) + .create_async() + .await; + + let cache = TempDir::new().expect("tempdir"); + let registry = format!("{}/", server.url()); + let http_client = ThrottledClient::default(); + let auth_headers = AuthHeaders::default(); + let opts = FetchFullMetadataCachedOptions { + registry: ®istry, + http_client: &http_client, + auth_headers: &auth_headers, + cache_dir: Some(cache.path()), + full_metadata: true, + retry_opts: no_retry_opts(), + }; + + fetch_full_metadata_cached("acme", &opts).await.expect("200 populates cache"); + let mirror_path = + get_pkg_mirror_path(cache.path(), FULL_META_DIR, ®istry, "acme").expect("path"); + + // Age the mirror far past any maturity cutoff. + let aged = std::time::SystemTime::now() - std::time::Duration::from_hours(365 * 24); + std::fs::OpenOptions::new() + .append(true) + .open(&mirror_path) + .expect("open mirror") + .set_modified(aged) + .expect("age mirror"); + + fetch_full_metadata_cached("acme", &opts).await.expect("304 reads from mirror"); + + let renewed = std::fs::metadata(&mirror_path).expect("stat mirror").modified().expect("mtime"); + let age = std::time::SystemTime::now().duration_since(renewed).expect("mtime in the past"); + assert!( + age < std::time::Duration::from_mins(1), + "mirror mtime must be renewed by the 304; still {age:?} old", + ); +} + /// Warm cache + stale `If-None-Match` → 200 → mirror is overwritten /// with the new body + new etag. #[tokio::test] diff --git a/pacquet/tasks/integrated-benchmark/src/latency_proxy/tests.rs b/pacquet/tasks/integrated-benchmark/src/latency_proxy/tests.rs index 9682a38a02..c0ea7492b0 100644 --- a/pacquet/tasks/integrated-benchmark/src/latency_proxy/tests.rs +++ b/pacquet/tasks/integrated-benchmark/src/latency_proxy/tests.rs @@ -163,13 +163,18 @@ fn slow_start_ramps_per_connection_throughput() { start.elapsed() }; - let flat = timed_transfer(false).min(timed_transfer(false)); - let ramped = timed_transfer(true); + let best_of = |samples: u32, slow_start: bool| { + (0..samples).map(|_| timed_transfer(slow_start)).min().expect("at least one sample") + }; // Flat: ~2×20ms latency + 256KiB/10MB/s ≈ 66 ms. Ramped: the first // windows (14.6 KB and doubling) each serialize at cwnd/RTT, adding ~3-4 - // window-times before the rate approaches the cap. Compare against the - // faster flat sample so scheduler noise on one baseline run does not hide - // the slow-start overhead. + // window-times before the rate approaches the cap. Compare the best of + // several samples on each side: the minimum is the noise-resistant + // estimator on a loaded CI runner — scheduler stalls only ever inflate a + // sample, while slow start's ramp overhead is structural and survives in + // every sample, including the minimum. + let flat = best_of(3, false); + let ramped = best_of(3, true); assert!( ramped > flat + Duration::from_millis(25), "slow start should add ramp-up time: flat {flat:?} vs ramped {ramped:?}", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 113a9ceace..88a71f3d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,8 +621,8 @@ catalogs: specifier: ^3.0.1 version: 3.0.1 esbuild: - specifier: ^0.28.0 - version: 0.28.0 + specifier: ^0.28.1 + version: 0.28.1 escape-string-regexp: specifier: ^5.0.0 version: 5.0.0 @@ -7928,7 +7928,7 @@ importers: version: 3.0.0 esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 execa: specifier: 'catalog:' version: safe-execa@0.3.0 @@ -8113,7 +8113,7 @@ importers: version: link:../../workspace/workspace-manifest-reader esbuild: specifier: 'catalog:' - version: 0.28.0 + version: 0.28.1 devDependencies: pd: specifier: workspace:* @@ -10691,158 +10691,158 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -13560,8 +13560,8 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -17979,82 +17979,82 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1(jiti@2.6.1))': @@ -21520,34 +21520,34 @@ snapshots: es-toolkit@1.47.0: {} - esbuild@0.28.0: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26ab2f7b0f..1acf77ae0e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -189,7 +189,7 @@ catalog: dint: ^5.1.0 dir-is-case-sensitive: ^3.0.0 encode-registry: ^3.0.1 - esbuild: ^0.28.0 + esbuild: ^0.28.1 escape-string-regexp: ^5.0.0 eslint: ^10.4.0 eslint-plugin-import-x: ^4.16.2 @@ -375,6 +375,8 @@ minimumReleaseAgeExclude: - cmd-extension - comver-to-semver - dir-is-case-sensitive + - esbuild@0.28.1 + - '@esbuild/*' - express@4.22.1 - glob@11.1.0 - graceful-git diff --git a/pnpr/crates/pnpr/src/resolver/resolve.rs b/pnpr/crates/pnpr/src/resolver/resolve.rs index 352101cf94..fc491c2303 100644 --- a/pnpr/crates/pnpr/src/resolver/resolve.rs +++ b/pnpr/crates/pnpr/src/resolver/resolve.rs @@ -159,7 +159,7 @@ pub async fn resolve( http_client_arc: Arc::clone(client), config, manifest: &manifest, - lockfile: input_lockfile, + lockfile: pacquet_lockfile::MaybeLazyLockfile::Loaded(input_lockfile), lockfile_path: input_lockfile.map(|_| lockfile_path.as_path()), dependency_groups: vec![ DependencyGroup::Prod, diff --git a/resolving/npm-resolver/src/pickPackage.ts b/resolving/npm-resolver/src/pickPackage.ts index b641320e84..79a452f021 100644 --- a/resolving/npm-resolver/src/pickPackage.ts +++ b/resolving/npm-resolver/src/pickPackage.ts @@ -334,6 +334,17 @@ export async function pickPackage ( if (fetchResult.notModified) { metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror)) if (metaCachedInStore != null) { + // The registry just vouched that the cached packument equals its + // current one, so the validation clock restarts now: bump the + // mirror's mtime so the publishedBy freshness shortcut above can + // fire again on the next install. Without this, a mirror older + // than minimumReleaseAge re-validates on every subsequent + // install — a 304 never rewrites the file. Fire-and-forget: a + // read-only cache dir only costs another conditional request. + if (!opts.dryRun) { + const now = new Date() + fs.utimes(pkgMirror, now, now).catch(() => {}) + } // The cached metadata may be abbreviated (no per-version `time`). // When minimumReleaseAge is active we need `time` for the maturity check, // so upgrade to full metadata via a follow-up fetch when warranted. diff --git a/resolving/npm-resolver/test/ifModifiedSince.test.ts b/resolving/npm-resolver/test/ifModifiedSince.test.ts index b210b4f471..dead8ba015 100644 --- a/resolving/npm-resolver/test/ifModifiedSince.test.ts +++ b/resolving/npm-resolver/test/ifModifiedSince.test.ts @@ -72,6 +72,53 @@ test('use local cache when registry returns 304 Not Modified', async () => { expect(resolveResult!.id).toBe('is-positive@3.1.0') }) +test('a 304 Not Modified renews the metadata file mtime so the publishedBy freshness shortcut can fire again', async () => { + const cacheDir = temporaryDirectory() + const metaDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`) + fs.mkdirSync(metaDir, { recursive: true }) + const metaPath = path.join(metaDir, 'is-positive.jsonl') + const headers = JSON.stringify({ etag: '"abc123"', modified: isPositiveMeta.modified }) + fs.writeFileSync(metaPath, `${headers}\n${JSON.stringify(isPositiveMeta)}`, 'utf8') + // Age the mirror far past any maturity cutoff. + const aged = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) + fs.utimesSync(metaPath, aged, aged) + + getMockAgent().get(registries.default.replace(/\/$/, '')) + .intercept({ + path: '/is-positive', + method: 'GET', + headers: { + 'if-none-match': '"abc123"', + }, + }) + .reply(304, '') + + const { resolveFromNpm } = createResolveFromNpm({ + storeDir: temporaryDirectory(), + cacheDir, + registries, + }) + const resolveResult = await resolveFromNpm( + { alias: 'is-positive', bareSpecifier: '^3.0.0' }, + {} + ) + expect(resolveResult!.id).toBe('is-positive@3.1.0') + + // The touch is fire-and-forget, so poll briefly instead of asserting + // immediately. + const renewed = () => fs.statSync(metaPath).mtime.getTime() > aged.getTime() + 1000 + await new Promise((resolve) => { + const start = Date.now() + const timer = setInterval(() => { + if (renewed() || Date.now() - start > 5000) { + clearInterval(timer) + resolve() + } + }, 50) + }) + expect(renewed()).toBe(true) +}) + test('store etag from 200 response in cache', async () => { const cacheDir = temporaryDirectory() const responseHeaders = {