Skip to content

fix(bins.linker): skip redundant .exe warning when hardlink is already correct#12284

Merged
zkochan merged 8 commits into
pnpm:mainfrom
sijie-Z:fix/12203-node-exe-warning-spam
Jun 16, 2026
Merged

fix(bins.linker): skip redundant .exe warning when hardlink is already correct#12284
zkochan merged 8 commits into
pnpm:mainfrom
sijie-Z:fix/12203-node-exe-warning-spam

Conversation

@sijie-Z

@sijie-Z sijie-Z commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Closes pnpm/pnpm#12203.

On Windows, node is linked by hardlinking node.exe directly rather than through a cmd-shim. The early-exit in linkBin() only recognized an existing cmd-shim, so on every warm install the existing (already correct) node.exe was treated as a conflict: pnpm warned The target bin directory already contains an exe called node and then removed and re-linked it. Because many commands re-link node, the warning was spammed.

@pnpm/bins.linker

Before warning and replacing an existing .exe, check whether it already refers to the link target via a new isSameFile() helper:

  • Compares the OS file identity (inode/device), read as BigInt to avoid the precision loss NTFS 64-bit file IDs suffer when cast to a Number. A zero/unreliable inode (common on Windows) is not treated as a match.
  • Falls back to a streaming, chunked (64 KB) content comparison when identity can't be established, so a byte-identical copy still counts as the same file without ever buffering a whole executable. A read error during the comparison is treated as "not the same file" so it can never abort bin linking.

The early-return is scoped to the node.exe path, so non-node commands still fall through to the existing warn + remove + cmdShim() regeneration and never end up with a partially populated bins directory.

pacquet

pacquet never emitted this warning, but its Windows link_node_bin unconditionally removed and re-linked node.exe on every install. Ported the same same-file early-return to cmd-shim so warm installs skip that churn, using same_file::Handle for the cheap identity check (promoted from a transitive to a workspace dependency) with the same chunked content fallback.

Tests

  • @pnpm/bins.linker: Windows regression tests covering the no-warning behavior on a repeat link and the identical-content-but-not-a-hardlink fallback.
  • pacquet: a Windows-only cmd-shim test asserting node.exe is left in place rather than removed and relinked.

Written by an agent (Claude Code, claude-opus-4-8).

@sijie-Z sijie-Z requested a review from zkochan as a code owner June 9, 2026 11:38
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (7) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Unreliable inode identity check 🐞 Bug ≡ Correctness
Description
linkBin() now returns early on Windows when exeStat.ino/dev matches targetStat, but on Windows
stat.ino is often 0, so two different files can appear “identical” and node.exe may not be replaced
when cmd.path changes. This can silently leave a stale or incorrect node.exe in the bins directory.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat && targetStat && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The PR adds an early return based solely on ino/dev equality. Elsewhere in the repo, tests
explicitly note that Windows stat.ino is often 0, which means this equality check can collide and
incorrectly skip relinking when the target executable changes.

bins/linker/src/index.ts[261-303]
installing/deps-installer/test/install/recordLockfileVerified.ts[194-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`linkBin()` (Windows path) uses `fs.stat(...).ino/dev` equality to decide the existing `*.exe` is already the correct hardlink and returns early. On Windows, `stat.ino` is often `0` (and can therefore collide), which can cause incorrect early returns and prevent updating `node.exe` when `cmd.path` changes.
### Issue Context
The repo itself documents that Windows `stat.ino` is often 0, meaning inode-based cache/identity shortcuts can accidentally match.
### Fix Focus Areas
- bins/linker/src/index.ts[261-303]
### Suggested fix
Implement a safer `isSameFile(exePath, cmd.path)` check:
- Prefer `fs.stat(path, { bigint: true })` to avoid precision loss.
- Only trust inode/dev equality when both are non-zero (e.g. `ino !== 0n` and `dev !== 0n`).
- If inode/dev are zero/unreliable, fall back to a cheap-but-safer comparison (e.g. compare `size` and a small content signature like first 64KB; or compare `size` + `mtimeNs` and, if still equal, sample bytes) before returning early.
- Keep existing warn+rimraf behavior when equality cannot be established confidently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Hardlink-only test assumption 🐞 Bug ☼ Reliability
Description
The new Windows regression test assumes node.exe will remain a hardlink and that file identity
lookup via same_file::Handle::from_path always succeeds, but the implementation explicitly
supports environments where hardlinking fails (falling back to copyFile) and where identity lookup
fails (falling back to size+content comparison). As a result, the test can be flaky or even panic on
legitimate Windows configurations even when the bin-linking behavior is correct (no warning and
correct executable bytes).
Code

bins/linker/test/index.ts[R765-770]

+    // The original hardlink must be preserved, i.e. node.exe is not deleted and
+    // recreated: it still shares its identity with the source binary.
+    const srcStat = fs.statSync(path.join(nodeDir, 'node.exe'))
+    const exeStat = fs.statSync(exePath)
+    expect(exeStat.ino).toBe(srcStat.ino)
+    expect(exeStat.dev).toBe(srcStat.dev)
Evidence
The cited implementation shows linkBin() attempts to create a hardlink and deliberately falls back
to copying the file when fs.link() fails, so hardlink identity is not a guaranteed postcondition;
nevertheless, the new test asserts hardlink identity unconditionally via ino/dev, which can fail
in valid scenarios (e.g., cross-device or policy-restricted hardlinking). Separately, the runtime
is_same_file() logic explicitly treats same_file::Handle::from_path errors as expected and falls
back to comparing metadata.len() plus chunked file content, while the Windows-only test
unwrap()s the Handle::from_path results, meaning it can panic on configurations where the
production code would simply take the fallback path.

bins/linker/src/index.ts[299-305]
bins/linker/test/index.ts[737-771]
pacquet/crates/cmd-shim/src/link_bins.rs[609-628]
pacquet/crates/cmd-shim/src/link_bins/tests.rs[1283-1316]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Windows-only regression test currently over-constrains behavior by (1) asserting `node.exe` is a hardlink via `ino/dev` equality even though the implementation intentionally permits a copy fallback when hardlinking fails, and (2) `unwrap()`ing `same_file::Handle::from_path` even though production code treats identity lookup failure as normal and falls back to size+content comparison. This makes the test flaky and can cause panics on legitimate Windows environments where hardlinks or file identity handles are unavailable.
## Issue Context
- In the JS/TS linker, `linkBin()` attempts `fs.link()` and falls back to `fs.copyFile()`, so a hardlink is not a guaranteed outcome; the test should either force a hardlink-capable setup/precondition or only assert hardlink identity when it can first prove hardlink creation succeeded.
- In the Rust implementation, `is_same_file()` explicitly handles `Handle::from_path` errors by falling back to `metadata.len()` plus chunked content comparison; the test should mirror this tolerance by avoiding `unwrap()` and by asserting observable behavior (e.g., correct bytes/no unnecessary relink) when handles are unavailable.
## Fix Focus Areas
- bins/linker/test/index.ts[737-771]
- bins/linker/src/index.ts[299-305]
- pacquet/crates/cmd-shim/src/link_bins/tests.rs[1275-1317]
- pacquet/crates/cmd-shim/src/link_bins.rs[609-628]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unhandled read error path 🐞 Bug ☼ Reliability
Description
On Windows, linkBin() awaits isSameFile() for node.exe without guarding against exceptions, and
haveEqualContents() can throw from FileHandle.read() inside its loop. A transient I/O/read error
during this optimization path will fail bin linking instead of safely treating the files as “not the
same” and proceeding with the existing remove+relink logic.
Code

bins/linker/src/index.ts[R409-422]

+    for (;;) {
+      // Reading sequentially is intentional: each iteration compares one chunk
+      // and stops early on a mismatch or EOF.
+      const [readA, readB] = await Promise.all([ // eslint-disable-line no-await-in-loop
+        fhA.read(bufA, 0, FILE_COMPARE_CHUNK_SIZE, position),
+        fhB.read(bufB, 0, FILE_COMPARE_CHUNK_SIZE, position),
+      ])
+      if (readA.bytesRead !== readB.bytesRead) return false
+      if (readA.bytesRead === 0) return true
+      if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) {
+        return false
+      }
+      position += readA.bytesRead
+    }
Evidence
The Windows node.exe path directly awaits isSameFile() and returns early based on its result; any
thrown error will therefore abort linkBin(). haveEqualContents() only guards fs.open() but not
fh.read() failures, unlike nearby helper code (hasWindowsShebang) which catches read errors and
returns a boolean instead of throwing.

bins/linker/src/index.ts[281-306]
bins/linker/src/index.ts[378-426]
bins/linker/src/index.ts[456-467]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`haveEqualContents()` can throw from `fh.read(...)` and the exception bubbles through `isSameFile()` to `linkBin()`, causing bin linking to fail during the new Windows node.exe early-exit check. This optimization should not make linking more fragile; on read/compare failure it should conservatively return `false` so the existing warn+rimraf+link/copy path can run.
### Issue Context
- `linkBin()` calls `isSameFile(exePath, cmd.path)` in the Windows node.exe conflict handling.
- `haveEqualContents()` handles `fs.open()` failures but does not handle `read()` failures.
### Fix Focus Areas
- bins/linker/src/index.ts[289-305]
- bins/linker/src/index.ts[395-426]
### Suggested fix
- Wrap the body of `haveEqualContents()` (or at least the read/compare loop) in a `try/catch` that returns `false` on any error, while keeping the existing `finally` that closes both file handles.
- Alternatively (or additionally), wrap `return haveEqualContents(...)` in `isSameFile()` with `.catch(() => false)` so compare errors never abort linking.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Full exe buffer compare 🐞 Bug ➹ Performance
Description
When inode/device identity can’t be used, isSameFile() reads both node.exe files fully into memory
and compares Buffers, which can add noticeable I/O and transient memory during install-time bin
linking on Windows. This is triggered on warm installs whenever node.exe already exists and Windows
reports unreliable file identity, and it runs on a hot path (linkBinsOfPackages()).
Code

bins/linker/src/index.ts[R386-391]

+  if (statA.size !== statB.size) return false
+  const [contentA, contentB] = await Promise.all([
+    fs.readFile(pathA).catch(() => null),
+    fs.readFile(pathB).catch(() => null),
+  ])
+  return contentA != null && contentB != null && contentA.equals(contentB)
Evidence
The fallback path reads full contents of both files into memory and compares them, and linkBin()
is invoked for commands during bin linking which is used by pnpm’s install pipeline.

bins/linker/src/index.ts[372-392]
bins/linker/src/index.ts[133-155]
installing/deps-installer/src/install/index.ts[1-6]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isSameFile()` falls back to `fs.readFile()` for both paths, loading entire executables into memory and doing two reads in parallel. For real `node.exe`, this can be a non-trivial amount of I/O and peak memory during Windows installs.
### Issue Context
This check runs from `linkBin()` during `linkBinsOfPackages()` calls, which are part of pnpm’s install/update flow.
### Fix Focus Areas
- bins/linker/src/index.ts[372-392]
### Suggested fix
Replace the full-buffer read fallback with a chunked/streaming comparison:
- Keep the existing `stat` + `size` checks.
- If inode/dev identity is not usable, open both files (`fs.open`) and compare fixed-size buffers (e.g. 64KB) in a loop until mismatch/EOF.
- Avoid `Promise.all([readFile(a), readFile(b)])` to reduce peak memory; either compare sequentially or compare chunk-by-chunk.
- Ensure file handles are closed in `finally` blocks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
5. Overbroad .exe early-exit 🐞 Bug ≡ Correctness
Description
On Windows, linkBin() now returns early whenever an existing "<cmd>.exe" is a hardlink to cmd.path,
even for non-node commands that are supposed to be (re)generated via cmdShim(). This can leave a
bins directory in a partial state (only an .exe present, missing .cmd/.ps1 shims) on reruns,
breaking expected bin layout and potentially command invocation.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat?.ino && targetStat?.ino && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The new early return is in the generic Windows .exe conflict block, but cmdShim() is the
mechanism that creates the expected Windows wrapper files for general bins; tests show Windows bins
are expected to include a .cmd wrapper. Returning before cmdShim() runs can therefore prevent
required wrapper creation when only an .exe exists.

bins/linker/src/index.ts[281-347]
bins/linker/test/index.ts[46-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`linkBin()` on Windows now returns early if `${cmd.name}.exe` is the same inode/dev as `cmd.path`. This early return happens before `cmdShim(...)` runs, so for non-`node` commands it can skip shim creation/repair when the bin directory is partially populated (e.g., only an `.exe` exists).
### Issue Context
The `.exe` conflict logic is intended primarily for the special `node` case (where pnpm links `node.exe` directly and does not create cmd-shims). For other commands, pnpm still relies on `cmdShim()` to create the expected `.cmd` (and often `.ps1`) wrappers.
### Fix Focus Areas
- bins/linker/src/index.ts[281-303]
### Suggested fix
Restrict the inode/dev early-return to the `node` direct-executable path only (or at minimum to cases where `cmd.path` itself is an `.exe` that you intentionally want to be the only artifact). For example:
- Move the inode/dev check under `if (cmd.name === 'node' && cmd.path.toLowerCase().endsWith('.exe')) { ... }`
- Keep the existing warn+rimraf behavior for other commands so `cmdShim(...)` always runs and repairs missing shims.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Misleading fallback comment 🐞 Bug ⚙ Maintainability
Description
The isSameFile() comment says the content-compare fallback is used when Windows reports an
unreliable zero inode, but the implementation falls back to content comparison whenever the
inode/dev pair does not prove identity (including non-zero but different inode/dev). This can
mislead maintainers about the intended semantics and when the content-compare path is taken.
Code

bins/linker/src/index.ts[R372-387]

+// Reports whether two paths refer to the same file. A matching inode/device
+// pair (read as BigInts to avoid the precision loss of NTFS 64-bit file IDs)
+// detects a hard link cheaply, but Windows often reports a zero inode, so when
+// the identity is unreliable we fall back to comparing the file contents after
+// a quick size check.
+async function isSameFile (pathA: string, pathB: string): Promise<boolean> {
+  const [statA, statB] = await Promise.all([
+    fs.stat(pathA, { bigint: true }).catch(() => null),
+    fs.stat(pathB, { bigint: true }).catch(() => null),
+  ])
+  if (statA == null || statB == null) return false
+  if (statA.ino && statB.ino && statA.ino === statB.ino && statA.dev === statB.dev) {
+    return true
+  }
+  if (statA.size !== statB.size) return false
+  return haveEqualContents(pathA, pathB)
Evidence
The comment states the fallback is for unreliable identity (zero inode), but the code
unconditionally falls back to haveEqualContents() when the inode/dev equality check does not
return true (after a size check).

bins/linker/src/index.ts[372-387]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isSameFile()`’s docstring implies the content-compare fallback only happens when Windows reports a zero inode (identity unreliable). However, the current implementation calls `haveEqualContents()` whenever the inode/dev check fails for any reason (including different inode/dev), as long as sizes match.
### Issue Context
This is a documentation/intent mismatch (not a runtime bug), but it affects maintainability and future changes in this hot-path.
### Fix Focus Areas
- bins/linker/src/index.ts[372-387]
### Suggested change
Update the comment to reflect the actual logic, e.g.:
- “If inode+dev match, treat as same file (hardlink). Otherwise (zero inode or different inode/dev), fall back to size + chunked content comparison.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Hardlink not asserted 🐞 Bug ⚙ Maintainability
Description
The new Windows regression test only checks that node.exe exists, has expected contents, and no
warning was emitted, but it does not verify that the existing hardlink relationship is preserved
across the second call. As a result, the test can still pass even if a future change unnecessarily
deletes and recreates node.exe with identical contents (missing the intended regression coverage).
Code

bins/linker/test/index.ts[R761-765]

+    expect(globalWarn).not.toHaveBeenCalled()
+    const exePath = path.join(binTarget, 'node.exe')
+    expect(fs.existsSync(exePath)).toBe(true)
+    expect(fs.readFileSync(exePath, 'utf8')).toBe('fake-node-binary')
+  })
Evidence
The test currently verifies only the warning behavior and file contents, but the implementation
being exercised is specifically about detecting “same file” via stat identity and returning early.
Without checking stat identity (ino/dev or link count), the test can’t prove the hardlink was
preserved rather than rewritten.

bins/linker/test/index.ts[737-765]
bins/linker/src/index.ts[281-303]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Windows regression test is intended to ensure that when `node.exe` is already the correct hardlink, `linkBinsOfPackages()` does not warn and does not relink/replace the existing `node.exe`. Currently the test only asserts file existence + content, which can’t distinguish between “same hardlink preserved” vs “file deleted and re-copied with same contents”.
### Issue Context
`linkBin()` uses inode/device equality to decide the early return for an existing `.exe` and uses `fs.link()` (hardlink) with a `fs.copyFile()` fallback when creating `node.exe`. The regression test should validate that the second linking call leaves the same underlying file in place.
### Fix Focus Areas
- bins/linker/test/index.ts[737-765]
### Suggested change
Enhance the test to assert hardlink identity, for example:
- After the first `linkBinsOfPackages()` call, compare `fs.statSync(path.join(nodeDir,'node.exe'))` with `fs.statSync(path.join(binTarget,'node.exe'))`:
- Prefer asserting `ino`+`dev` equality when available.
- Optionally also assert `nlink > 1` to confirm a hardlink was created.
- Record the target `stat` (ino/dev or at least `mtimeMs` + `size`) after the first call, run the second call, and assert the recorded identity is unchanged.
This makes the test fail if `node.exe` is unnecessarily removed/recreated even when its contents remain the same.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 496432e

Results up to commit b8d6281


🐞 Bugs (7) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Action required
1. Unreliable inode identity check 🐞 Bug ≡ Correctness
Description
linkBin() now returns early on Windows when exeStat.ino/dev matches targetStat, but on Windows
stat.ino is often 0, so two different files can appear “identical” and node.exe may not be replaced
when cmd.path changes. This can silently leave a stale or incorrect node.exe in the bins directory.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat && targetStat && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The PR adds an early return based solely on ino/dev equality. Elsewhere in the repo, tests
explicitly note that Windows stat.ino is often 0, which means this equality check can collide and
incorrectly skip relinking when the target executable changes.

bins/linker/src/index.ts[261-303]
installing/deps-installer/test/install/recordLockfileVerified.ts[194-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`linkBin()` (Windows path) uses `fs.stat(...).ino/dev` equality to decide the existing `*.exe` is already the correct hardlink and returns early. On Windows, `stat.ino` is often `0` (and can therefore collide), which can cause incorrect early returns and prevent updating `node.exe` when `cmd.path` changes.
### Issue Context
The repo itself documents that Windows `stat.ino` is often 0, meaning inode-based cache/identity shortcuts can accidentally match.
### Fix Focus Areas
- bins/linker/src/index.ts[261-303]
### Suggested fix
Implement a safer `isSameFile(exePath, cmd.path)` check:
- Prefer `fs.stat(path, { bigint: true })` to avoid precision loss.
- Only trust inode/dev equality when both are non-zero (e.g. `ino !== 0n` and `dev !== 0n`).
- If inode/dev are zero/unreliable, fall back to a cheap-but-safer comparison (e.g. compare `size` and a small content signature like first 64KB; or compare `size` + `mtimeNs` and, if still equal, sample bytes) before returning early.
- Keep existing warn+rimraf behavior when equality cannot be established confidently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Hardlink-only test assumption 🐞 Bug ☼ Reliability ⭐ New
Description
The new Windows regression test assumes node.exe will remain a hardlink and that file identity
lookup via same_file::Handle::from_path always succeeds, but the implementation explicitly
supports environments where hardlinking fails (falling back to copyFile) and where identity lookup
fails (falling back to size+content comparison). As a result, the test can be flaky or even panic on
legitimate Windows configurations even when the bin-linking behavior is correct (no warning and
correct executable bytes).
Code

bins/linker/test/index.ts[R765-770]

+    // The original hardlink must be preserved, i.e. node.exe is not deleted and
+    // recreated: it still shares its identity with the source binary.
+    const srcStat = fs.statSync(path.join(nodeDir, 'node.exe'))
+    const exeStat = fs.statSync(exePath)
+    expect(exeStat.ino).toBe(srcStat.ino)
+    expect(exeStat.dev).toBe(srcStat.dev)
Evidence
The cited implementation shows linkBin() attempts to create a hardlink and deliberately falls back
to copying the file when fs.link() fails, so hardlink identity is not a guaranteed postcondition;
nevertheless, the new test asserts hardlink identity unconditionally via ino/dev, which can fail
in valid scenarios (e.g., cross-device or policy-restricted hardlinking). Separately, the runtime
is_same_file() logic explicitly treats same_file::Handle::from_path errors as expected and falls
back to comparing metadata.len() plus chunked file content, while the Windows-only test
unwrap()s the Handle::from_path results, meaning it can panic on configurations where the
production code would simply take the fallback path.

bins/linker/src/index.ts[299-305]
bins/linker/test/index.ts[737-771]
pacquet/crates/cmd-shim/src/link_bins.rs[609-628]
pacquet/crates/cmd-shim/src/link_bins/tests.rs[1283-1316]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A Windows-only regression test currently over-constrains behavior by (1) asserting `node.exe` is a hardlink via `ino/dev` equality even though the implementation intentionally permits a copy fallback when hardlinking fails, and (2) `unwrap()`ing `same_file::Handle::from_path` even though production code treats identity lookup failure as normal and falls back to size+content comparison. This makes the test flaky and can cause panics on legitimate Windows environments where hardlinks or file identity handles are unavailable.

## Issue Context
- In the JS/TS linker, `linkBin()` attempts `fs.link()` and falls back to `fs.copyFile()`, so a hardlink is not a guaranteed outcome; the test should either force a hardlink-capable setup/precondition or only assert hardlink identity when it can first prove hardlink creation succeeded.
- In the Rust implementation, `is_same_file()` explicitly handles `Handle::from_path` errors by falling back to `metadata.len()` plus chunked content comparison; the test should mirror this tolerance by avoiding `unwrap()` and by asserting observable behavior (e.g., correct bytes/no unnecessary relink) when handles are unavailable.

## Fix Focus Areas
- bins/linker/test/index.ts[737-771]
- bins/linker/src/index.ts[299-305]
- pacquet/crates/cmd-shim/src/link_bins/tests.rs[1275-1317]
- pacquet/crates/cmd-shim/src/link_bins.rs[609-628]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unhandled read error path 🐞 Bug ☼ Reliability
Description
On Windows, linkBin() awaits isSameFile() for node.exe without guarding against exceptions, and
haveEqualContents() can throw from FileHandle.read() inside its loop. A transient I/O/read error
during this optimization path will fail bin linking instead of safely treating the files as “not the
same” and proceeding with the existing remove+relink logic.
Code

bins/linker/src/index.ts[R409-422]

+    for (;;) {
+      // Reading sequentially is intentional: each iteration compares one chunk
+      // and stops early on a mismatch or EOF.
+      const [readA, readB] = await Promise.all([ // eslint-disable-line no-await-in-loop
+        fhA.read(bufA, 0, FILE_COMPARE_CHUNK_SIZE, position),
+        fhB.read(bufB, 0, FILE_COMPARE_CHUNK_SIZE, position),
+      ])
+      if (readA.bytesRead !== readB.bytesRead) return false
+      if (readA.bytesRead === 0) return true
+      if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) {
+        return false
+      }
+      position += readA.bytesRead
+    }
Evidence
The Windows node.exe path directly awaits isSameFile() and returns early based on its result; any
thrown error will therefore abort linkBin(). haveEqualContents() only guards fs.open() but not
fh.read() failures, unlike nearby helper code (hasWindowsShebang) which catches read errors and
returns a boolean instead of throwing.

bins/linker/src/index.ts[281-306]
bins/linker/src/index.ts[378-426]
bins/linker/src/index.ts[456-467]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`haveEqualContents()` can throw from `fh.read(...)` and the exception bubbles through `isSameFile()` to `linkBin()`, causing bin linking to fail during the new Windows node.exe early-exit check. This optimization should not make linking more fragile; on read/compare failure it should conservatively return `false` so the existing warn+rimraf+link/copy path can run.
### Issue Context
- `linkBin()` calls `isSameFile(exePath, cmd.path)` in the Windows node.exe conflict handling.
- `haveEqualContents()` handles `fs.open()` failures but does not handle `read()` failures.
### Fix Focus Areas
- bins/linker/src/index.ts[289-305]
- bins/linker/src/index.ts[395-426]
### Suggested fix
- Wrap the body of `haveEqualContents()` (or at least the read/compare loop) in a `try/catch` that returns `false` on any error, while keeping the existing `finally` that closes both file handles.
- Alternatively (or additionally), wrap `return haveEqualContents(...)` in `isSameFile()` with `.catch(() => false)` so compare errors never abort linking.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Full exe buffer compare 🐞 Bug ➹ Performance
Description
When inode/device identity can’t be used, isSameFile() reads both node.exe files fully into memory
and compares Buffers, which can add noticeable I/O and transient memory during install-time bin
linking on Windows. This is triggered on warm installs whenever node.exe already exists and Windows
reports unreliable file identity, and it runs on a hot path (linkBinsOfPackages()).
Code

bins/linker/src/index.ts[R386-391]

+  if (statA.size !== statB.size) return false
+  const [contentA, contentB] = await Promise.all([
+    fs.readFile(pathA).catch(() => null),
+    fs.readFile(pathB).catch(() => null),
+  ])
+  return contentA != null && contentB != null && contentA.equals(contentB)
Evidence
The fallback path reads full contents of both files into memory and compares them, and linkBin()
is invoked for commands during bin linking which is used by pnpm’s install pipeline.

bins/linker/src/index.ts[372-392]
bins/linker/src/index.ts[133-155]
installing/deps-installer/src/install/index.ts[1-6]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isSameFile()` falls back to `fs.readFile()` for both paths, loading entire executables into memory and doing two reads in parallel. For real `node.exe`, this can be a non-trivial amount of I/O and peak memory during Windows installs.
### Issue Context
This check runs from `linkBin()` during `linkBinsOfPackages()` calls, which are part of pnpm’s install/update flow.
### Fix Focus Areas
- bins/linker/src/index.ts[372-392]
### Suggested fix
Replace the full-buffer read fallback with a chunked/streaming comparison:
- Keep the existing `stat` + `size` checks.
- If inode/dev identity is not usable, open both files (`fs.open`) and compare fixed-size buffers (e.g. 64KB) in a loop until mismatch/EOF.
- Avoid `Promise.all([readFile(a), readFile(b)])` to reduce peak memory; either compare sequentially or compare chunk-by-chunk.
- Ensure file handles are closed in `finally` blocks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
5. Overbroad .exe early-exit 🐞 Bug ≡ Correctness
Description
On Windows, linkBin() now returns early whenever an existing "<cmd>.exe" is a hardlink to cmd.path,
even for non-node commands that are supposed to be (re)generated via cmdShim(). This can leave a
bins directory in a partial state (only an .exe present, missing .cmd/.ps1 shims) on reruns,
breaking expected bin layout and potentially command invocation.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat?.ino && targetStat?.ino && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The new early return is in the generic Windows .exe conflict block, but cmdShim() is the
mechanism that creates the expected Windows wrapper files for general bins; tests show Windows bins
are expected to include a .cmd wrapper. Returning before cmdShim() runs can therefore prevent
required wrapper creation when only an .exe exists.

bins/linker/src/index.ts[281-347]
bins/linker/test/index.ts[46-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`linkBin()` on Windows now returns early if `${cmd.name}.exe` is the same inode/dev as `cmd.path`. This early return happens before `cmdShim(...)` runs, so for non-`node` commands it can skip shim creation/repair when the bin directory is partially populated (e.g., only an `.exe` exists).
### Issue Context
The `.exe` conflict logic is intended primarily for the special `node` case (where pnpm links `node.exe` directly and does not create cmd-shims). For other commands, pnpm still relies on `cmdShim()` to create the expected `.cmd` (and often `.ps1`) wrappers.
### Fix Focus Areas
- bins/linker/src/index.ts[281-303]
### Suggested fix
Restrict the inode/dev early-return to the `node` direct-executable path only (or at minimum to cases where `cmd.path` itself is an `.exe` that you intentionally want to be the only artifact). For example:
- Move the inode/dev check under `if (cmd.name === 'node' && cmd.path.toLowerCase().endsWith('.exe')) { ... }`
- Keep the existing warn+rimraf behavior for other commands so `cmdShim(...)` always runs and repairs missing shims.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
6. Misleading fallback comment 🐞 Bug ⚙ Maintainability
Description
The isSameFile() comment says the content-compare fallback is used when Windows reports an
unreliable zero inode, but the implementation falls back to content comparison whenever the
inode/dev pair does not prove identity (including non-zero but different inode/dev). This can
mislead maintainers about the intended semantics and when the content-compare path is taken.
Code

bins/linker/src/index.ts[R372-387]

+// Reports whether two paths refer to the same file. A matching inode/device
+// pair (read as BigInts to avoid the precision loss of NTFS 64-bit file IDs)
+// detects a hard link cheaply, but Windows often reports a zero inode, so when
+// the identity is unreliable we fall back to comparing the file contents after
+// a quick size check.
+async function isSameFile (pathA: string, pathB: string): Promise<boolean> {
+  const [statA, statB] = await Promise.all([
+    fs.stat(pathA, { bigint: true }).catch(() => null),
+    fs.stat(pathB, { bigint: true }).catch(() => null),
+  ])
+  if (statA == null || statB == null) return false
+  if (statA.ino && statB.ino && statA.ino === statB.ino && statA.dev === statB.dev) {
+    return true
+  }
+  if (statA.size !== statB.size) return false
+  return haveEqualContents(pathA, pathB)
Evidence
The comment states the fallback is for unreliable identity (zero inode), but the code
unconditionally falls back to haveEqualContents() when the inode/dev equality check does not
return true (after a size check).

bins/linker/src/index.ts[372-387]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isSameFile()`’s docstring implies the content-compare fallback only happens when Windows reports a zero inode (identity unreliable). However, the current implementation calls `haveEqualContents()` whenever the inode/dev check fails for any reason (including different inode/dev), as long as sizes match.
### Issue Context
This is a documentation/intent mismatch (not a runtime bug), but it affects maintainability and future changes in this hot-path.
### Fix Focus Areas
- bins/linker/src/index.ts[372-387]
### Suggested change
Update the comment to reflect the actual logic, e.g.:
- “If inode+dev match, treat as same file (hardlink). Otherwise (zero inode or different inode/dev), fall back to size + chunked content comparison.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Hardlink not asserted 🐞 Bug ⚙ Maintainability
Description
The new Windows regression test only checks that node.exe exists, has expected contents, and no
warning was emitted, but it does not verify that the existing hardlink relationship is preserved
across the second call. As a result, the test can still pass even if a future change unnecessarily
deletes and recreates node.exe with identical contents (missing the intended regression coverage).
Code

bins/linker/test/index.ts[R761-765]

+    expect(globalWarn).not.toHaveBeenCalled()
+    const exePath = path.join(binTarget, 'node.exe')
+    expect(fs.existsSync(exePath)).toBe(true)
+    expect(fs.readFileSync(exePath, 'utf8')).toBe('fake-node-binary')
+  })
Evidence
The test currently verifies only the warning behavior and file contents, but the implementation
being exercised is specifically about detecting “same file” via stat identity and returning early.
Without checking stat identity (ino/dev or link count), the test can’t prove the hardlink was
preserved rather than rewritten.

bins/linker/test/index.ts[737-765]
bins/linker/src/index.ts[281-303]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Windows regression test is intended to ensure that when `node.exe` is already the correct hardlink, `linkBinsOfPackages()` does not warn and does not relink/replace the existing `node.exe`. Currently the test only asserts file existence + content, which can’t distinguish between “same hardlink preserved” vs “file deleted and re-copied with same contents”.
### Issue Context
`linkBin()` uses inode/device equality to decide the early return for an existing `.exe` and uses `fs.link()` (hardlink) with a `fs.copyFile()` fallback when creating `node.exe`. The regression test should validate that the second linking call leaves the same underlying file in place.
### Fix Focus Areas
- bins/linker/test/index.ts[737-765]
### Suggested change
Enhance the test to assert hardlink identity, for example:
- After the first `linkBinsOfPackages()` call, compare `fs.statSync(path.join(nodeDir,'node.exe'))` with `fs.statSync(path.join(binTarget,'node.exe'))`:
- Prefer asserting `ino`+`dev` equality when available.
- Optionally also assert `nlink > 1` to confirm a hardlink was created.
- Record the target `stat` (ino/dev or at least `mtimeMs` + `size`) after the first call, run the second call, and assert the recorded identity is unchanged.
This makes the test fail if `node.exe` is unnecessarily removed/recreated even when its contents remain the same.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit ffb5f08


🐞 Bugs (6) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Action required
1. Unreliable inode identity check 🐞 Bug ≡ Correctness
Description
linkBin() now returns early on Windows when exeStat.ino/dev matches targetStat, but on Windows
stat.ino is often 0, so two different files can appear “identical” and node.exe may not be replaced
when cmd.path changes. This can silently leave a stale or incorrect node.exe in the bins directory.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat && targetStat && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The PR adds an early return based solely on ino/dev equality. Elsewhere in the repo, tests
explicitly note that Windows stat.ino is often 0, which means this equality check can collide and
incorrectly skip relinking when the target executable changes.

bins/linker/src/index.ts[261-303]
installing/deps-installer/test/install/recordLockfileVerified.ts[194-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`linkBin()` (Windows path) uses `fs.stat(...).ino/dev` equality to decide the existing `*.exe` is already the correct hardlink and returns early. On Windows, `stat.ino` is often `0` (and can therefore collide), which can cause incorrect early returns and prevent updating `node.exe` when `cmd.path` changes.
### Issue Context
The repo itself documents that Windows `stat.ino` is often 0, meaning inode-based cache/identity shortcuts can accidentally match.
### Fix Focus Areas
- bins/linker/src/index.ts[261-303]
### Suggested fix
Implement a safer `isSameFile(exePath, cmd.path)` check:
- Prefer `fs.stat(path, { bigint: true })` to avoid precision loss.
- Only trust inode/dev equality when both are non-zero (e.g. `ino !== 0n` and `dev !== 0n`).
- If inode/dev are zero/unreliable, fall back to a cheap-but-safer comparison (e.g. compare `size` and a small content signature like first 64KB; or compare `size` + `mtimeNs` and, if still equal, sample bytes) before returning early.
- Keep existing warn+rimraf behavior when equality cannot be established confidently.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Unhandled read error path 🐞 Bug ☼ Reliability ⭐ New
Description
On Windows, linkBin() awaits isSameFile() for node.exe without guarding against exceptions, and
haveEqualContents() can throw from FileHandle.read() inside its loop. A transient I/O/read error
during this optimization path will fail bin linking instead of safely treating the files as “not the
same” and proceeding with the existing remove+relink logic.
Code

bins/linker/src/index.ts[R409-422]

+    for (;;) {
+      // Reading sequentially is intentional: each iteration compares one chunk
+      // and stops early on a mismatch or EOF.
+      const [readA, readB] = await Promise.all([ // eslint-disable-line no-await-in-loop
+        fhA.read(bufA, 0, FILE_COMPARE_CHUNK_SIZE, position),
+        fhB.read(bufB, 0, FILE_COMPARE_CHUNK_SIZE, position),
+      ])
+      if (readA.bytesRead !== readB.bytesRead) return false
+      if (readA.bytesRead === 0) return true
+      if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) {
+        return false
+      }
+      position += readA.bytesRead
+    }
Evidence
The Windows node.exe path directly awaits isSameFile() and returns early based on its result; any
thrown error will therefore abort linkBin(). haveEqualContents() only guards fs.open() but not
fh.read() failures, unlike nearby helper code (hasWindowsShebang) which catches read errors and
returns a boolean instead of throwing.

bins/linker/src/index.ts[281-306]
bins/linker/src/index.ts[378-426]
bins/linker/src/index.ts[456-467]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`haveEqualContents()` can throw from `fh.read(...)` and the exception bubbles through `isSameFile()` to `linkBin()`, causing bin linking to fail during the new Windows node.exe early-exit check. This optimization should not make linking more fragile; on read/compare failure it should conservatively return `false` so the existing warn+rimraf+link/copy path can run.

### Issue Context
- `linkBin()` calls `isSameFile(exePath, cmd.path)` in the Windows node.exe conflict handling.
- `haveEqualContents()` handles `fs.open()` failures but does not handle `read()` failures.

### Fix Focus Areas
- bins/linker/src/index.ts[289-305]
- bins/linker/src/index.ts[395-426]

### Suggested fix
- Wrap the body of `haveEqualContents()` (or at least the read/compare loop) in a `try/catch` that returns `false` on any error, while keeping the existing `finally` that closes both file handles.
- Alternatively (or additionally), wrap `return haveEqualContents(...)` in `isSameFile()` with `.catch(() => false)` so compare errors never abort linking.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Full exe buffer compare 🐞 Bug ➹ Performance
Description
When inode/device identity can’t be used, isSameFile() reads both node.exe files fully into memory
and compares Buffers, which can add noticeable I/O and transient memory during install-time bin
linking on Windows. This is triggered on warm installs whenever node.exe already exists and Windows
reports unreliable file identity, and it runs on a hot path (linkBinsOfPackages()).
Code

bins/linker/src/index.ts[R386-391]

+  if (statA.size !== statB.size) return false
+  const [contentA, contentB] = await Promise.all([
+    fs.readFile(pathA).catch(() => null),
+    fs.readFile(pathB).catch(() => null),
+  ])
+  return contentA != null && contentB != null && contentA.equals(contentB)
Evidence
The fallback path reads full contents of both files into memory and compares them, and linkBin()
is invoked for commands during bin linking which is used by pnpm’s install pipeline.

bins/linker/src/index.ts[372-392]
bins/linker/src/index.ts[133-155]
installing/deps-installer/src/install/index.ts[1-6]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`isSameFile()` falls back to `fs.readFile()` for both paths, loading entire executables into memory and doing two reads in parallel. For real `node.exe`, this can be a non-trivial amount of I/O and peak memory during Windows installs.
### Issue Context
This check runs from `linkBin()` during `linkBinsOfPackages()` calls, which are part of pnpm’s install/update flow.
### Fix Focus Areas
- bins/linker/src/index.ts[372-392]
### Suggested fix
Replace the full-buffer read fallback with a chunked/streaming comparison:
- Keep the existing `stat` + `size` checks.
- If inode/dev identity is not usable, open both files (`fs.open`) and compare fixed-size buffers (e.g. 64KB) in a loop until mismatch/EOF.
- Avoid `Promise.all([readFile(a), readFile(b)])` to reduce peak memory; either compare sequentially or compare chunk-by-chunk.
- Ensure file handles are closed in `finally` blocks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Overbroad .exe early-exit 🐞 Bug ≡ Correctness
Description
On Windows, linkBin() now returns early whenever an existing "<cmd>.exe" is a hardlink to cmd.path,
even for non-node commands that are supposed to be (re)generated via cmdShim(). This can leave a
bins directory in a partial state (only an .exe present, missing .cmd/.ps1 shims) on reruns,
breaking expected bin layout and potentially command invocation.
Code

bins/linker/src/index.ts[R284-288]

+      const exeStat = await fs.stat(exePath).catch(() => null)
+      const targetStat = await fs.stat(cmd.path).catch(() => null)
+      if (exeStat?.ino && targetStat?.ino && exeStat.ino === targetStat.ino && exeStat.dev === targetStat.dev) {
+        return
+      }
Evidence
The new early return is in the generic Windows .exe conflict block, but cmdShim() is the
mechanism that creates the expected Windows wrapper files for general bins; tests show Windows bins
are expected to include a .cmd wrapper. Returning before cmdShim() runs can therefore prevent
required wrapper creation when only an .exe exists.

[bins/linker/src/index.ts[281-347]]...

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an inode/device identity check in a new isSameFile helper before replacing a Windows ${cmd.name}.exe so the linker skips removing/recreating the exe when it is already the correct hardlink or has identical content. Includes two Windows tests verifying the warning is suppressed in hardlink and content-matching scenarios, and a changeset documenting patch releases.

Changes

Windows Binary Hardlink Deduplication

Layer / File(s) Summary
File identity detection and Windows exe deduplication
bins/linker/src/index.ts
isSameFile(pathA, pathB) compares filesystem paths by stat inode/device equality with fallback to size and content comparison. In linkBin, before removing an existing Windows .exe, the code now calls isSameFile and returns early when the existing file is already the correct source, avoiding redundant warning and rewrite.
Test coverage and changeset documentation
bins/linker/test/index.ts, .changeset/fix-bin-linker-node-exe-warning.md
Two Windows-only tests verify that linkBinsOfPackages() suppresses the warning when the target node.exe matches the source as a hardlink and when it has identical content but may not be a hardlink. Changeset documents patch bumps for @pnpm/bins.linker and pnpm.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 I checked the bins beneath Windows light,
Two runs, one file — all perfectly right.
No needless warn, no needless remake,
Just quiet hops and fewer mistakes. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: skipping redundant .exe warnings when the hardlink is already correct, which matches the core fix implemented in the PR.
Linked Issues check ✅ Passed All coding objectives from issue #12203 are met: the PR implements inode/device comparison, content-based fallback, preserves warning for genuine conflicts, scopes the fix to node.exe, uses BigInt for precision, and adds regression tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing issue #12203: the inode comparison logic, content fallback, test cases, and changeset entry are all necessary to fix the redundant warning problem.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread bins/linker/src/index.ts Outdated
sijie-Z added a commit to sijie-Z/pnpm that referenced this pull request Jun 9, 2026
Use truthy check on ino before comparing — on some Windows configurations
stat.ino returns 0 for all files, which would make any two files appear
identical. Fall through to the original warn-and-replace behavior when
ino is not available.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-sonnet-4-6).
@sijie-Z sijie-Z force-pushed the fix/12203-node-exe-warning-spam branch from 6d4d50f to 99fa076 Compare June 9, 2026 12:22
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 99fa076

Use truthy check on ino before comparing — on some Windows configurations
stat.ino returns 0 for all files, which would make any two files appear
identical. Fall through to the original warn-and-replace behavior when
ino is not available.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-sonnet-4-6).
@zkochan zkochan force-pushed the fix/12203-node-exe-warning-spam branch from 99fa076 to 8a61866 Compare June 16, 2026 08:11
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8a61866

Comment thread bins/linker/src/index.ts Outdated
…ecision loss

NTFS 64-bit file IDs lose precision when read as a JS Number, which could
cause two distinct files to compare equal. Read inode/device with
{ bigint: true } so the hard-link identity check is exact.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9239b97

Restrict the existing-exe early-return on Windows to the node.exe path so
non-node commands still get their cmd-shims (re)generated. When the inode
identity is unreliable (Windows often reports a zero inode), fall back to a
size-then-content comparison so an identical node.exe is still detected
without warning.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3a3303a

Comment thread bins/linker/src/index.ts Outdated
Stream the content fallback in 64KB chunks and stop at the first mismatch,
so an unreliable-inode comparison never buffers a whole executable in memory.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f04be93

…ameFile comment

Verify the regression test that the second linkBinsOfPackages call keeps
node.exe sharing identity with the source binary, so a future delete+recreate
regression is caught. Reword the isSameFile comment to describe the content
fallback accurately (it covers any unprovable identity, not only zero inodes).

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ffb5f08

Comment thread bins/linker/src/index.ts
Port pnpm's same-file early-return for the Windows node.exe path: when the
existing node.exe already refers to the source binary, skip the remove +
hardlink churn on warm installs. Identity is checked cheaply via
same_file::Handle (file index + volume serial), falling back to a chunked
content comparison when the OS doesn't expose a reliable index.

Mirrors pnpm#12284 on the TypeScript side. same-file is already a
transitive dependency (via walkdir); promoted to a workspace dependency.

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b8d6281

@zkochan

zkochan commented Jun 16, 2026

Copy link
Copy Markdown
Member

Pushed a round of changes addressing the bot review and extending the fix:

@pnpm/bins.linker (TypeScript)

  • Scoped the same-file early-return to the node.exe path only, so non-node commands still fall through to cmdShim() regeneration (addresses "Overbroad .exe early-exit").
  • Replaced the raw stat().ino equality with an isSameFile() helper: identity is read as BigInt to avoid NTFS 64-bit file-ID precision loss, a zero/unreliable inode no longer counts as a match, and it falls back to a chunked (64 KB) content comparison — so it never buffers a whole executable (addresses "Unreliable inode identity check" and "Full exe buffer compare").
  • Strengthened the regression test to assert the existing hardlink is preserved (not silently deleted + recreated), and added a second test for the identical-content-but-not-a-hardlink fallback. Clarified the isSameFile doc comment.

pacquet (Rust parity)

  • pacquet's bin linker never emitted this warning, so there was no user-visible drift, but I ported the same-file early-return to cmd-shim's Windows link_node_bin for behavior parity — it now skips the remove + relink churn when node.exe already refers to the source binary. Identity is checked via same_file::Handle (promoted from a transitive to a workspace dependency) with the same chunked content fallback, plus a Windows-only regression test. Verified against the x86_64-pc-windows-gnu target (check + clippy + doc) and the host test suite.

Written by an agent (Claude Code, claude-opus-4-8).

Comment thread bins/linker/test/index.ts Outdated
@codecov-commenter

codecov-commenter commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.00%. Comparing base (d36b6f8) to head (496432e).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12284      +/-   ##
==========================================
- Coverage   88.00%   88.00%   -0.01%     
==========================================
  Files         308      308              
  Lines       41395    41418      +23     
==========================================
+ Hits        36431    36451      +20     
- Misses       4964     4967       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Treat a transient read error during the content comparison as 'not the same
file' so bin linking falls back to the existing warn + remove + relink path
instead of failing. Also relax the regression test to assert only the
absence of the warning (the real regression signal, coupled with the rimraf
in the same branch) rather than inode identity, which a copyFile fallback
would not satisfy.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
The same-file regression test treated a failed same_file::Handle lookup as a
hard error, but the production code tolerates it and falls back to content
comparison. Degrade to treating the files as distinct instead of unwrapping.

Addresses review feedback on pnpm#12284

---
Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 496432e

@github-actions

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.278 ± 0.238 3.930 4.712 1.71 ± 0.21
pacquet@main 4.144 ± 0.227 3.735 4.479 1.65 ± 0.21
pnpr@HEAD 2.508 ± 0.280 2.178 2.956 1.00
pnpr@main 2.543 ± 0.322 2.121 3.107 1.01 ± 0.17
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.27773792856,
      "stddev": 0.23798258126383465,
      "median": 4.20175115766,
      "user": 3.1586060599999994,
      "system": 2.7288637400000004,
      "min": 3.92979734916,
      "max": 4.711955507160001,
      "times": [
        4.21021896516,
        4.07153721716,
        4.61447473816,
        4.33690864516,
        4.711955507160001,
        4.193283350160001,
        4.17165002416,
        4.36257335216,
        4.17498013716,
        3.92979734916
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.14420756546,
      "stddev": 0.2271294453410863,
      "median": 4.156029331160001,
      "user": 3.07991656,
      "system": 2.71123024,
      "min": 3.73524680416,
      "max": 4.47862992916,
      "times": [
        4.1525737971600005,
        4.1594848651600005,
        4.15077321216,
        4.47862992916,
        4.240527585160001,
        4.35969555616,
        3.73524680416,
        4.23614003516,
        3.7970147431599997,
        4.131989127160001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.50774445786,
      "stddev": 0.28004523104684165,
      "median": 2.4150904316599995,
      "user": 2.0734189599999997,
      "system": 2.2568932399999997,
      "min": 2.17799804516,
      "max": 2.9564037541599997,
      "times": [
        2.3308354681599996,
        2.85903262516,
        2.31335353916,
        2.4070059911599997,
        2.64863733216,
        2.76744808216,
        2.1935548691599998,
        2.4231748721599997,
        2.9564037541599997,
        2.17799804516
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.54287908056,
      "stddev": 0.3224493300259856,
      "median": 2.51282959116,
      "user": 2.0775401599999994,
      "system": 2.2598755400000003,
      "min": 2.12107491416,
      "max": 3.10669542116,
      "times": [
        2.1767645011599996,
        2.2488402431599996,
        2.46882027116,
        2.9450592951599996,
        2.50903627416,
        2.76568234916,
        2.57019462816,
        3.10669542116,
        2.51662290816,
        2.12107491416
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 698.6 ± 156.9 594.9 1064.3 1.12 ± 0.27
pacquet@main 624.1 ± 47.5 584.6 727.0 1.00
pnpr@HEAD 805.0 ± 107.3 644.7 947.2 1.29 ± 0.20
pnpr@main 879.5 ± 111.5 644.9 1053.6 1.41 ± 0.21
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6986334734599999,
      "stddev": 0.1569402599712927,
      "median": 0.60713064906,
      "user": 0.29282594,
      "system": 1.02788558,
      "min": 0.59492906756,
      "max": 1.06427708756,
      "times": [
        0.87150976256,
        0.59492906756,
        0.71833476056,
        0.5990238155600001,
        0.72455324456,
        0.61150449456,
        0.60107597556,
        0.60275680356,
        1.06427708756,
        0.59836972256
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6241160647599999,
      "stddev": 0.04749729683495283,
      "median": 0.60465570606,
      "user": 0.29178993999999997,
      "system": 1.02574648,
      "min": 0.58462413656,
      "max": 0.72697640056,
      "times": [
        0.69708068356,
        0.58462413656,
        0.6035524585600001,
        0.60254649156,
        0.59918925056,
        0.60575895356,
        0.61508507256,
        0.60736118456,
        0.72697640056,
        0.59898601556
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.80501232686,
      "stddev": 0.10734519104403198,
      "median": 0.81088405056,
      "user": 0.30668693999999996,
      "system": 1.0723043799999998,
      "min": 0.64473837456,
      "max": 0.94717454256,
      "times": [
        0.8568632175600001,
        0.64473837456,
        0.92212231656,
        0.94717454256,
        0.8907211755600001,
        0.87492509556,
        0.75902141656,
        0.71897398656,
        0.67067825956,
        0.76490488356
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.8794818246600002,
      "stddev": 0.11149963900664836,
      "median": 0.88104700856,
      "user": 0.30169974,
      "system": 1.0605624800000002,
      "min": 0.64488012956,
      "max": 1.05364637056,
      "times": [
        0.86693613956,
        0.83204158356,
        1.0084431815600001,
        0.82417083256,
        0.64488012956,
        0.9193644775600001,
        0.83941662856,
        1.05364637056,
        0.91076102556,
        0.89515787756
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.146 ± 0.207 3.973 4.666 1.76 ± 0.22
pacquet@main 4.089 ± 0.116 3.985 4.335 1.74 ± 0.20
pnpr@HEAD 2.353 ± 0.265 2.084 2.921 1.00
pnpr@main 2.425 ± 0.150 2.242 2.686 1.03 ± 0.13
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.14575157414,
      "stddev": 0.2067992754004891,
      "median": 4.093687882739999,
      "user": 2.9664041,
      "system": 2.5964394799999995,
      "min": 3.97269664874,
      "max": 4.66551186774,
      "times": [
        4.11247114774,
        4.17373657474,
        3.97269664874,
        3.98542955974,
        4.66551186774,
        4.00915466974,
        4.00631254674,
        4.07490461774,
        4.24160214474,
        4.21569596374
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.088655215339999,
      "stddev": 0.11610924320296122,
      "median": 4.06025334674,
      "user": 2.9435762999999997,
      "system": 2.6086413799999995,
      "min": 3.98476155974,
      "max": 4.33470087374,
      "times": [
        4.06575707674,
        4.02793922274,
        3.9924653127400003,
        4.03089028574,
        4.05645067374,
        4.33470087374,
        4.06405601974,
        3.98476155974,
        4.06483562574,
        4.2646955027399995
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.3532701125399997,
      "stddev": 0.26528488538446066,
      "median": 2.2630128122400004,
      "user": 1.970158,
      "system": 2.2067654799999996,
      "min": 2.08395379774,
      "max": 2.92058396474,
      "times": [
        2.28022451174,
        2.21421216974,
        2.5739409267399997,
        2.28840494574,
        2.08395379774,
        2.18001866574,
        2.12874617474,
        2.61681485574,
        2.92058396474,
        2.24580111274
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.4248103258400002,
      "stddev": 0.15031386725372037,
      "median": 2.44437726174,
      "user": 1.9561575999999998,
      "system": 2.22309938,
      "min": 2.24229551074,
      "max": 2.68578648974,
      "times": [
        2.24229551074,
        2.53360666574,
        2.24654910074,
        2.49842003974,
        2.33858251174,
        2.51170597074,
        2.26712450074,
        2.68578648974,
        2.53369798474,
        2.3903344837400002
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.231 ± 0.053 1.180 1.345 1.73 ± 0.42
pacquet@main 1.249 ± 0.110 1.182 1.548 1.76 ± 0.45
pnpr@HEAD 0.739 ± 0.208 0.628 1.277 1.04 ± 0.39
pnpr@main 0.710 ± 0.170 0.622 1.151 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.2308433120200002,
      "stddev": 0.05322104091554628,
      "median": 1.21695251062,
      "user": 1.0780991199999999,
      "system": 1.3554419599999998,
      "min": 1.17960626562,
      "max": 1.34484300062,
      "times": [
        1.21389566162,
        1.2227412316200001,
        1.17960626562,
        1.21163675462,
        1.22447435962,
        1.20291056762,
        1.30683432862,
        1.34484300062,
        1.18148159062,
        1.2200093596200001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.2493833777200003,
      "stddev": 0.1095469212887822,
      "median": 1.21454855862,
      "user": 1.06048072,
      "system": 1.36226116,
      "min": 1.18233671862,
      "max": 1.54765956262,
      "times": [
        1.25330774662,
        1.18233671862,
        1.24066450862,
        1.19044123862,
        1.54765956262,
        1.1909481156200001,
        1.23653384962,
        1.19256326762,
        1.2708670346200002,
        1.18851173462
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.73894071252,
      "stddev": 0.2082910119514682,
      "median": 0.6514677941200001,
      "user": 0.26316532000000004,
      "system": 1.0095050600000002,
      "min": 0.6278028276200001,
      "max": 1.27724605062,
      "times": [
        0.6419813916200001,
        0.6470788406200001,
        0.6278028276200001,
        0.6598790666200001,
        0.6558567476200001,
        0.6434705796200001,
        0.6346109216200001,
        1.27724605062,
        0.92262349062,
        0.6788572086200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.70977995952,
      "stddev": 0.17009805454586824,
      "median": 0.6373554226200001,
      "user": 0.25737691999999995,
      "system": 0.9919445600000001,
      "min": 0.62152466362,
      "max": 1.15137594062,
      "times": [
        0.6306244846200001,
        0.6277072316200001,
        0.6619750926200001,
        0.65254233662,
        0.6440863606200001,
        0.6235183496200001,
        0.62152466362,
        0.6300187506200001,
        0.8544263846200001,
        1.15137594062
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.753 ± 0.097 2.647 2.955 4.17 ± 0.32
pacquet@main 2.771 ± 0.064 2.713 2.907 4.19 ± 0.30
pnpr@HEAD 0.673 ± 0.052 0.626 0.754 1.02 ± 0.10
pnpr@main 0.661 ± 0.044 0.625 0.736 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.75323565496,
      "stddev": 0.09666543919594382,
      "median": 2.72261864586,
      "user": 1.42148618,
      "system": 1.5613922799999997,
      "min": 2.64661263836,
      "max": 2.95468115836,
      "times": [
        2.64661263836,
        2.71168613736,
        2.6880595343600002,
        2.6905016303600005,
        2.6664353803600003,
        2.8209474993600003,
        2.7693102303600003,
        2.73355115436,
        2.95468115836,
        2.8505711863600003
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.7706273933600007,
      "stddev": 0.06386117713760264,
      "median": 2.73393152686,
      "user": 1.4376838799999998,
      "system": 1.5783054799999996,
      "min": 2.7134069373600003,
      "max": 2.9069151003600004,
      "times": [
        2.72595662836,
        2.8282904503600004,
        2.7134069373600003,
        2.7295039823600002,
        2.7357600543600005,
        2.7214540263600004,
        2.7321029993600003,
        2.79747374736,
        2.9069151003600004,
        2.81541000736
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6726953953600001,
      "stddev": 0.051820707327420994,
      "median": 0.64478914836,
      "user": 0.2772349799999999,
      "system": 1.00861018,
      "min": 0.62642986236,
      "max": 0.75384570436,
      "times": [
        0.64022154736,
        0.62642986236,
        0.62809488436,
        0.66816809136,
        0.75384570436,
        0.7445020263600001,
        0.64870449236,
        0.73886047736,
        0.63725306336,
        0.64087380436
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6609638077600001,
      "stddev": 0.04431550356460514,
      "median": 0.63882340786,
      "user": 0.2712273799999999,
      "system": 0.99060608,
      "min": 0.62488268936,
      "max": 0.7362877023600001,
      "times": [
        0.63518773236,
        0.7153712613600001,
        0.7362877023600001,
        0.72026460936,
        0.6334991533600001,
        0.64245908336,
        0.64705919836,
        0.62488268936,
        0.62602957936,
        0.6285970683600001
      ]
    }
  ]
}

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12284
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,145.75 ms
(-0.48%)Baseline: 4,165.92 ms
4,999.11 ms
(82.93%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
2,753.24 ms
(-7.92%)Baseline: 2,990.12 ms
3,588.15 ms
(76.73%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,230.84 ms
(-6.07%)Baseline: 1,310.44 ms
1,572.53 ms
(78.27%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,277.74 ms
(+8.49%)Baseline: 3,943.00 ms
4,731.60 ms
(90.41%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
698.63 ms
(+12.85%)Baseline: 619.08 ms
742.90 ms
(94.04%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12284
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,353.27 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
672.70 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
738.94 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,507.74 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
805.01 ms
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan enabled auto-merge (squash) June 16, 2026 11:55
@zkochan zkochan merged commit 3d1fd20 into pnpm:main Jun 16, 2026
25 checks passed
@welcome

welcome Bot commented Jun 16, 2026

Copy link
Copy Markdown

Congrats on merging your first pull request! 🎉🎉🎉

@sijie-Z sijie-Z deleted the fix/12203-node-exe-warning-spam branch June 16, 2026 12:46
@sijie-Z

sijie-Z commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing and improving this fix. I learned a lot from the review process. Appreciate the detailed feedback and the additional hardening work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"The target bin directory already contains an exe called node" warning is spammed on many commands

3 participants