diff --git a/.changeset/reuse-injected-hardlinks.md b/.changeset/reuse-injected-hardlinks.md
new file mode 100644
index 0000000000..33af343160
--- /dev/null
+++ b/.changeset/reuse-injected-hardlinks.md
@@ -0,0 +1,8 @@
+---
+"@pnpm/fs.indexed-pkg-importer": patch
+"@pnpm/installing.deps-restorer": patch
+"pnpm": patch
+"pacquet": patch
+---
+
+Sped up frozen installs of unchanged injected workspace packages that use hardlinks.
diff --git a/.github/scripts/compare-integrated-benchmarks.mjs b/.github/scripts/compare-integrated-benchmarks.mjs
index 2d7658104d..85911bd5a4 100644
--- a/.github/scripts/compare-integrated-benchmarks.mjs
+++ b/.github/scripts/compare-integrated-benchmarks.mjs
@@ -13,6 +13,7 @@ export const scenarios = [
'ISOLATED_FRESH_RESOLVE_HOT_CACHE_OFFLINE',
'ISOLATED_PEER_HEAVY_RESOLVE_HOT_CACHE_OFFLINE',
'ISOLATED_LINKED_WORKSPACE_RESOLVE_HOT_CACHE_OFFLINE',
+ 'ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE',
'ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE_COLD_PNPR',
'GVS_FRESH_RESTORE_HOT_CACHE_HOT_STORE',
]
@@ -76,7 +77,7 @@ export async function renderComparison (directory) {
let failed = false
for (const scenario of scenarios) {
const report = JSON.parse(await readFile(join(directory, `BENCHMARK_REPORT_${scenario}.json`), 'utf8'))
- const engines = scenario.includes('PEER_HEAVY') || scenario.includes('LINKED_WORKSPACE') ? ['pacquet'] : ['pacquet', 'pnpr']
+ const engines = scenario.includes('PEER_HEAVY') || scenario.includes('LINKED_WORKSPACE') || scenario.includes('INJECTED_WORKSPACE') ? ['pacquet'] : ['pacquet', 'pnpr']
for (const engine of engines) {
const result = compareConfirmed(report, engine)
failed ||= result.status === 'Regression'
diff --git a/.github/scripts/compare-integrated-benchmarks.test.mjs b/.github/scripts/compare-integrated-benchmarks.test.mjs
index 0640f78310..d2f9adbf19 100644
--- a/.github/scripts/compare-integrated-benchmarks.test.mjs
+++ b/.github/scripts/compare-integrated-benchmarks.test.mjs
@@ -73,7 +73,7 @@ test('all scenarios and both engines are checked, and missing reports fail', asy
try {
for (const scenario of scenarios) {
const input = report(stable, stable)
- if (!scenario.includes('PEER_HEAVY') && !scenario.includes('LINKED_WORKSPACE')) {
+ if (!scenario.includes('PEER_HEAVY') && !scenario.includes('LINKED_WORKSPACE') && !scenario.includes('INJECTED_WORKSPACE')) {
input.results.push(command('pnpr@HEAD', Array(10).fill(1.4)), command('pnpr@main', stable))
}
input.confirmation = { results: input.results }
@@ -82,7 +82,7 @@ test('all scenarios and both engines are checked, and missing reports fail', asy
const result = await renderComparison(directory)
assert.equal(result.failed, true)
assert.equal(result.markdown.match(/\| Regression \|/g).length, 10)
- assert.equal(result.markdown.match(/\| Within tolerance \|/g).length, 12)
+ assert.equal(result.markdown.match(/\| Within tolerance \|/g).length, 13)
const cli = new URL('./compare-integrated-benchmarks.mjs', import.meta.url)
const run = spawnSync(process.execPath, [fileURLToPath(cli), directory], { encoding: 'utf8' })
assert.equal(run.status, 1)
diff --git a/.github/workflows/pacquet-integrated-benchmark.yml b/.github/workflows/pacquet-integrated-benchmark.yml
index afe7dbe62e..95bcafb80f 100644
--- a/.github/workflows/pacquet-integrated-benchmark.yml
+++ b/.github/workflows/pacquet-integrated-benchmark.yml
@@ -272,6 +272,7 @@ jobs:
- isolated-linker.fresh-resolve.hot-cache.offline
- isolated-linker.peer-heavy-resolve.hot-cache.offline
- isolated-linker.linked-workspace-resolve.hot-cache.offline
+ - isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store
- isolated-linker.fresh-restore.cold-cache.cold-store.cold-pnpr
- gvs-linker.fresh-restore.hot-cache.hot-store
name: ${{ matrix.scenario }} on ${{ matrix.os }}
@@ -296,10 +297,9 @@ jobs:
# command exercises client-side peer resolution rather than server or
# tarball performance.
PEER_HEAVY_BENCHMARK_TARGETS: ${{ github.ref_name == 'main' && 'pacquet@HEAD pnpm@HEAD' || 'pacquet@HEAD pacquet@main pnpm@HEAD' }}
- # The linked-workspace case compares the PR against main only. Its
- # fixture is a pure `workspace:*` graph, so there is nothing for a pnpr
- # server to accelerate, and pnpm v11 never walks linked importers at all.
- LINKED_WORKSPACE_BENCHMARK_TARGETS: ${{ github.ref_name == 'main' && 'pacquet@HEAD' || 'pacquet@HEAD pacquet@main' }}
+ # The workspace fixtures use only local packages, so they compare the
+ # PR against main without a pnpr server.
+ WORKSPACE_BENCHMARK_TARGETS: ${{ github.ref_name == 'main' && 'pacquet@HEAD' || 'pacquet@HEAD pacquet@main' }}
BENCHMARK_BUILD_TARGETS: ${{ github.ref_name == 'main' && 'pacquet@HEAD pnpr@HEAD' || 'pacquet@HEAD pacquet@main pnpr@HEAD pnpr@main' }}
# Network emulation so both install strategies cross the link they
# would in production instead of free loopback (~GB/s, ~0 RTT), which
@@ -338,11 +338,12 @@ jobs:
- name: Cache pnpm-registry storage
# The generated fixtures never populate the npm proxy cache: peer-heavy
- # is seeded into hosted storage, linked-workspace names no registry
- # package at all.
+ # is seeded into hosted storage, while the workspace fixtures name
+ # no registry package.
if: >-
matrix.scenario != 'isolated-linker.peer-heavy-resolve.hot-cache.offline' &&
- matrix.scenario != 'isolated-linker.linked-workspace-resolve.hot-cache.offline'
+ matrix.scenario != 'isolated-linker.linked-workspace-resolve.hot-cache.offline' &&
+ matrix.scenario != 'isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store'
# Persists pnpm-registry's `--storage` dir across CI runs so
# cold-cache benchmark scenarios don't refetch ~2.3k unscoped
# packages from npmjs on every run. Replaces the older
@@ -585,10 +586,19 @@ jobs:
# (https://github.com/pnpm/pnpm/issues/14906).
timeout-minutes: 20
run: |
- node .github/scripts/run-integrated-benchmark.mjs --reuse-prebuilt-binaries --scenario=isolated-linker.linked-workspace-resolve.hot-cache.offline --registry=verdaccio --registry-latency-ms=${REGISTRY_LATENCY_MS} --registry-bandwidth-mbps=${REGISTRY_BANDWIDTH_MBPS} $LINKED_WORKSPACE_BENCHMARK_TARGETS
+ node .github/scripts/run-integrated-benchmark.mjs --reuse-prebuilt-binaries --scenario=isolated-linker.linked-workspace-resolve.hot-cache.offline --registry=verdaccio --registry-latency-ms=${REGISTRY_LATENCY_MS} --registry-bandwidth-mbps=${REGISTRY_BANDWIDTH_MBPS} $WORKSPACE_BENCHMARK_TARGETS
cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_LINKED_WORKSPACE_RESOLVE_HOT_CACHE_OFFLINE.md
cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_LINKED_WORKSPACE_RESOLVE_HOT_CACHE_OFFLINE.json
+ - name: 'Benchmark: Isolated linker: injected workspace repeat install, hot cache + hot store'
+ if: matrix.scenario == 'isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store'
+ shell: bash
+ timeout-minutes: 20
+ run: |
+ node .github/scripts/run-integrated-benchmark.mjs --reuse-prebuilt-binaries --scenario=isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store --registry=npm $WORKSPACE_BENCHMARK_TARGETS
+ cp bench-work-env/BENCHMARK_REPORT.md bench-work-env/BENCHMARK_REPORT_ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE.md
+ cp bench-work-env/BENCHMARK_REPORT.json bench-work-env/BENCHMARK_REPORT_ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE.json
+
- name: 'Benchmark: Isolated linker: fresh restore, cold cache + cold store + cold pnpr'
if: matrix.scenario == 'isolated-linker.fresh-restore.cold-cache.cold-store.cold-pnpr'
shell: bash
@@ -731,7 +741,7 @@ jobs:
echo
echo "**Commit:** [\`${SHA:0:12}\`](${{ github.server_url }}/${{ github.repository }}/commit/$SHA)"
echo
- echo 'Regular scenarios report direct and pnpr installs. The peer-heavy resolver scenario compares current Rust, main Rust, and TypeScript pnpm; the linked-workspace scenario compares current Rust against main Rust. Bencher consumes pacquet@HEAD and pnpr@HEAD.'
+ echo 'Regular scenarios report direct and pnpr installs. The peer-heavy resolver scenario compares current Rust, main Rust, and TypeScript pnpm; the linked and injected workspace scenarios compare current Rust against main Rust. Bencher consumes pacquet@HEAD and pnpr@HEAD.'
echo
echo 'The tables below show mean ± σ. Bencher tracks absolute minimum latency; PR regression checks compare HEAD and main samples from the same runner. Historical timing alerts do not gate PRs.'
echo
@@ -855,6 +865,18 @@ jobs:
echo
echo ''
echo
+ echo '### Scenario: Isolated linker: injected workspace repeat install, hot cache + hot store'
+ echo
+ cat bench-work-env/BENCHMARK_REPORT_ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE.md
+ echo
+ echo 'BENCHMARK_REPORT.json
'
+ echo
+ echo '```json'
+ jq 'del(.results[].exit_codes, .results[].times, .confirmation.results[]?.exit_codes, .confirmation.results[]?.times)' bench-work-env/BENCHMARK_REPORT_ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE.json
+ echo '```'
+ echo
+ echo ' '
+ echo
echo '### Scenario: Isolated linker: fresh restore, cold cache + cold store + cold pnpr'
echo
cat bench-work-env/BENCHMARK_REPORT_ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE_COLD_PNPR.md
@@ -935,6 +957,7 @@ jobs:
'ISOLATED_FRESH_RESOLVE_HOT_CACHE_OFFLINE:isolated-linker.fresh-resolve.hot-cache.offline' \
'ISOLATED_PEER_HEAVY_RESOLVE_HOT_CACHE_OFFLINE:isolated-linker.peer-heavy-resolve.hot-cache.offline' \
'ISOLATED_LINKED_WORKSPACE_RESOLVE_HOT_CACHE_OFFLINE:isolated-linker.linked-workspace-resolve.hot-cache.offline' \
+ 'ISOLATED_INJECTED_WORKSPACE_REPEAT_INSTALL_HOT_CACHE_HOT_STORE:isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store' \
'ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE_COLD_PNPR:isolated-linker.fresh-restore.cold-cache.cold-store.cold-pnpr' \
'GVS_FRESH_RESTORE_HOT_CACHE_HOT_STORE:gvs-linker.fresh-restore.hot-cache.hot-store'
diff --git a/pnpm/CONTRIBUTING.md b/pnpm/CONTRIBUTING.md
index 7c6a6c74eb..b647b1f753 100644
--- a/pnpm/CONTRIBUTING.md
+++ b/pnpm/CONTRIBUTING.md
@@ -206,6 +206,15 @@ just integrated-benchmark --scenario=isolated-linker.fresh-restore.cold-cache.co
just integrated-benchmark --help
```
+To measure frozen repeat installs of an unchanged injected workspace package:
+
+```sh
+just integrated-benchmark --scenario=isolated-linker.injected-workspace-repeat-install.hot-cache.hot-store pacquet@HEAD pacquet@main
+```
+
+This generated fixture uses hardlinked local files and needs no registry packages.
+Its untimed install creates the lockfile and populates `node_modules` before measurement.
+
### CI regression checks
Each PR benchmark scenario measures HEAD and main on the same runner. The check
@@ -216,8 +225,8 @@ A regression requires both the median slowdown and the gap between the fastest
retained HEAD sample and slowest retained main sample to exceed that tolerance.
The comparison trims `floor(sample count / 10)` samples from each tail and
requires at least nine successful samples per target. Missing or invalid data
-fails the check. Both pacquet and pnpr are checked, except in the two client-only
-resolver scenarios.
+fails the check. Both pacquet and pnpr are checked, except in client-only
+workspace and resolver scenarios.
Slowdowns above the tolerance without this sample separation are reported as
inconclusive and do not fail CI. Suspected regressions are automatically rerun
diff --git a/pnpm/crates/cli/tests/suite/inject_workspace_packages.rs b/pnpm/crates/cli/tests/suite/inject_workspace_packages.rs
index 898bd67a0e..0af479ee49 100644
--- a/pnpm/crates/cli/tests/suite/inject_workspace_packages.rs
+++ b/pnpm/crates/cli/tests/suite/inject_workspace_packages.rs
@@ -10,6 +10,7 @@
//! flag flipped re-resolves.
mod catalogs;
+mod hardlink_refresh;
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
diff --git a/pnpm/crates/cli/tests/suite/inject_workspace_packages/hardlink_refresh.rs b/pnpm/crates/cli/tests/suite/inject_workspace_packages/hardlink_refresh.rs
new file mode 100644
index 0000000000..8d1c503fda
--- /dev/null
+++ b/pnpm/crates/cli/tests/suite/inject_workspace_packages/hardlink_refresh.rs
@@ -0,0 +1,59 @@
+use assert_cmd::prelude::*;
+use pnpm_testing_utils::bin::CommandTempCwd;
+use std::fs;
+
+#[test]
+fn frozen_injected_installs_reuse_unchanged_hardlinks_and_refresh_replacements() {
+ for global_store in [false, true] {
+ let CommandTempCwd {
+ mut pacquet, root: _root, workspace, ..
+ } = CommandTempCwd::init();
+ fs::write(
+ workspace.join("package.json"),
+ serde_json::json!({
+ "name": "root", "private": true,
+ "dependencies": { "library": "workspace:*" },
+ })
+ .to_string(),
+ )
+ .unwrap();
+ fs::write(workspace.join("pnpm-workspace.yaml"), format!(
+ "packages:\n - packages/*\nstoreDir: ./store\ncacheDir: ./cache\ninjectWorkspacePackages: true\ndedupeInjectedDeps: false\nenableGlobalVirtualStore: {global_store}\n",
+ )).unwrap();
+ let source = workspace.join("packages/library");
+ fs::create_dir_all(&source).unwrap();
+ fs::write(source.join("package.json"), r#"{"name":"library","version":"1.0.0"}"#).unwrap();
+ fs::write(source.join("index.js"), "original").unwrap();
+ pacquet
+ .args(["install", "--offline", "--ignore-scripts", "--no-frozen-lockfile"])
+ .assert()
+ .success();
+ let target = fs::canonicalize(workspace.join("node_modules/library")).unwrap();
+ assert_ne!(target, fs::canonicalize(&source).unwrap());
+ let original = same_file::Handle::from_path(&target).unwrap();
+ let repeated = crate::_utils::pacquet_in(&workspace)
+ .args(["install", "--offline", "--frozen-lockfile", "--reporter=ndjson"])
+ .assert()
+ .success();
+ let stderr = String::from_utf8_lossy(&repeated.get_output().stderr);
+ let methods: Vec = stderr
+ .lines()
+ .map(|line| serde_json::from_str(line).unwrap())
+ .filter(|event: &serde_json::Value| event["name"] == "pnpm:package-import-method")
+ .collect();
+ assert_eq!(methods.len(), 1);
+ assert_eq!(methods[0]["method"], "hardlink");
+ assert_eq!(same_file::Handle::from_path(&target).unwrap(), original);
+
+ fs::write(source.join("replacement.js"), "modified").unwrap();
+ fs::rename(source.join("replacement.js"), source.join("index.js")).unwrap();
+ assert_eq!(fs::read_to_string(target.join("index.js")).unwrap(), "original");
+ crate::_utils::pacquet_in(&workspace)
+ .args(["install", "--offline", "--frozen-lockfile"])
+ .assert()
+ .success();
+ assert_eq!(fs::read_to_string(target.join("index.js")).unwrap(), "modified");
+ assert_ne!(same_file::Handle::from_path(&target).unwrap(), original);
+ assert!(same_file::is_same_file(source.join("index.js"), target.join("index.js")).unwrap());
+ }
+}
diff --git a/pnpm/crates/deps-restorer/Cargo.toml b/pnpm/crates/deps-restorer/Cargo.toml
index 47b2b4c8ab..92877f8af3 100644
--- a/pnpm/crates/deps-restorer/Cargo.toml
+++ b/pnpm/crates/deps-restorer/Cargo.toml
@@ -73,6 +73,7 @@ junction = { workspace = true }
mockito = { workspace = true }
p256 = { workspace = true }
pretty_assertions = { workspace = true }
+same-file = { workspace = true }
serde-saphyr = { workspace = true }
sha2 = { workspace = true }
tar = { workspace = true }
diff --git a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs
index 21b478e433..a225fb3149 100644
--- a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs
+++ b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs
@@ -405,6 +405,7 @@ fn slot_import_opts(
if interrupted_build || source_is_mutable || force_import {
return ImportIndexedDirOpts {
force: true,
+ reuse_hardlinks: source_is_mutable && !force_import && !interrupted_build,
keep_modules_dir: true,
safe_to_skip,
preserve_symlinks: source_is_mutable,
diff --git a/pnpm/crates/deps-restorer/src/import_indexed_dir.rs b/pnpm/crates/deps-restorer/src/import_indexed_dir.rs
index 24850396b9..71efe688bf 100644
--- a/pnpm/crates/deps-restorer/src/import_indexed_dir.rs
+++ b/pnpm/crates/deps-restorer/src/import_indexed_dir.rs
@@ -1,5 +1,6 @@
pub use placement::marker_present;
+mod hardlinks;
mod placement;
use placement::{all_files_match, file_matches_store_entry, populate_dir};
@@ -37,6 +38,8 @@ pub struct ImportIndexedDirOpts {
/// existing directory short-circuits this function (matches
/// pnpm's pre-existence check in `importIndexedPackage`).
pub force: bool,
+ /// Reuse an unchanged mutable directory when every imported file is still hardlinked.
+ pub reuse_hardlinks: bool,
/// When `true` (only meaningful with `force`), preserve
/// `dir_path/node_modules/` across the re-import so nested
/// dependencies survive the rebuild. Required by the hoisted
@@ -308,6 +311,16 @@ fn force_import_existing_dir(
opts.preserve_symlinks,
);
}
+ if opts.reuse_hardlinks
+ && import_method == PackageImportMethod::Hardlink
+ && hardlinks::directory_matches(dir_path, cas_paths, opts.keep_modules_dir)
+ {
+ crate::link_file::log_method_once::(
+ logged_methods,
+ pnpm_reporter::PackageImportMethod::Hardlink,
+ );
+ return Ok(());
+ }
if opts.safe_to_skip {
return import_into_shared_dir::(
logged_methods,
diff --git a/pnpm/crates/deps-restorer/src/import_indexed_dir/hardlinks.rs b/pnpm/crates/deps-restorer/src/import_indexed_dir/hardlinks.rs
new file mode 100644
index 0000000000..19bdca41af
--- /dev/null
+++ b/pnpm/crates/deps-restorer/src/import_indexed_dir/hardlinks.rs
@@ -0,0 +1,118 @@
+use super::placement::{
+ file_identity_matches,
+ symlinks::{imported_paths, is_symlink},
+};
+use std::{
+ collections::{HashMap, HashSet},
+ fs, io,
+ path::{Component, Path, PathBuf},
+};
+
+pub(super) fn directory_matches(
+ target: &Path,
+ files: &HashMap,
+ keep_modules_dir: bool,
+) -> bool {
+ if files.is_empty() || files.contains_key(crate::NEEDS_BUILD_MARKER) {
+ return false;
+ }
+ if !pnpm_fs::symlink_metadata_with_retry(target)
+ .is_ok_and(|meta| meta.is_dir() && !is_symlink(target))
+ {
+ return false;
+ }
+ let Some(expected) = ExpectedTree::new(files, keep_modules_dir) else { return false };
+ expected.matches(target).unwrap_or(false)
+}
+
+struct ExpectedTree<'a> {
+ files: HashMap,
+ directories: HashSet,
+ keep_modules_dir: bool,
+}
+
+enum EntryKind {
+ File,
+ Directory,
+ Preserved,
+}
+
+impl<'a> ExpectedTree<'a> {
+ fn new(files: &'a HashMap, keep_modules_dir: bool) -> Option {
+ let directories = imported_paths(files)
+ .into_iter()
+ .filter(|entry| !files.contains_key(*entry))
+ .map(PathBuf::from)
+ .collect();
+ let mut expected = ExpectedTree {
+ files: HashMap::with_capacity(files.len()),
+ directories,
+ keep_modules_dir,
+ };
+ for (relative, source) in files {
+ let path = Path::new(relative);
+ if path.as_os_str().is_empty()
+ || !path
+ .components()
+ .all(|part| matches!(part, Component::Normal(_)))
+ || path.starts_with("node_modules")
+ {
+ return None;
+ }
+ if expected.files.insert(path.to_path_buf(), source).is_some() {
+ return None;
+ }
+ }
+ Some(expected)
+ }
+
+ fn matches(&self, root: &Path) -> io::Result {
+ let mut pending = vec![PathBuf::new()];
+ let mut matched = 0;
+ while let Some(relative) = pending.pop() {
+ let Some(count) = self.matching_files_in_directory(root, &relative, &mut pending)?
+ else {
+ return Ok(false);
+ };
+ matched += count;
+ }
+ Ok(matched == self.files.len())
+ }
+
+ fn matching_files_in_directory(
+ &self,
+ root: &Path,
+ relative: &Path,
+ pending: &mut Vec,
+ ) -> io::Result