From e311affef7f445b56bd46fee42bbaef2c875efc5 Mon Sep 17 00:00:00 2001 From: Vitaly Sakharuk Date: Wed, 11 Mar 2026 13:02:10 +0100 Subject: [PATCH 1/2] fix: fall back to copy when hard links fail in pnpm linker When using nodeLinker: pnpm, the content-addressable index creates hard links from the index to node_modules/.store/. This fails in several scenarios: - EXDEV: index and project on different volumes (common in Docker, CI, and Windows multi-drive setups) - EMLINK: inode hard link limit exceeded (Linux/macOS) - UNKNOWN (link syscall): NTFS hard link limit of 1024 per file (Windows); libuv does not map ERROR_TOO_MANY_LINKS to EMLINK - EPERM (link syscall): insufficient privileges for hard links This commit adds a shouldFallbackToCopy() helper that detects these errors, and wraps the linkPromise call in copyFileViaIndex() with a try/catch that gracefully degrades to copying the file content. This matches the behavior of the real pnpm CLI. Fixes yarnpkg/berry#5326 --- .../sources/algorithms/copyPromise.ts | 36 +++- .../yarnpkg-fslib/tests/copyPromise.test.ts | 192 ++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/yarnpkg-fslib/tests/copyPromise.test.ts diff --git a/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts b/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts index b6b0b85d6723..f60b514eeb01 100644 --- a/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts +++ b/packages/yarnpkg-fslib/sources/algorithms/copyPromise.ts @@ -171,6 +171,28 @@ async function copyFolder(prelayout: Operation return updated; } +/** + * Determines whether a hard link error should trigger a copy fallback. + * + * - EXDEV: cross-device link (index and project on different volumes) + * - EMLINK: too many links (inode limit on Linux/macOS) + * - UNKNOWN + link syscall: NTFS hard link limit on Windows (1024 per file); + * libuv does not map ERROR_TOO_MANY_LINKS to EMLINK + * - EPERM + link syscall: insufficient privileges for hard links (e.g. + * Windows without Developer Mode enabled) + */ +function shouldFallbackToCopy(err: any): boolean { + if (err.code === `EXDEV`) + return true; + if (err.code === `EMLINK`) + return true; + if (err.code === `UNKNOWN` && err.syscall === `link`) + return true; + if (err.code === `EPERM` && err.syscall === `link`) + return true; + return false; +} + async function copyFileViaIndex(prelayout: Operations, postlayout: Operations, destinationFs: FakeFS, destination: P1, destinationStat: Stats | null, sourceFs: FakeFS, source: P2, sourceStat: Stats, opts: CopyOptions, linkStrategy: HardlinkFromIndexStrategy) { const sourceHash = await sourceFs.checksumFilePromise(source, {algorithm: `sha1`}); @@ -264,7 +286,19 @@ async function copyFileViaIndex(prelayout: Ope } if (!destinationStat) { - await destinationFs.linkPromise(indexPath, destination); + try { + await destinationFs.linkPromise(indexPath, destination); + } catch (err) { + if (shouldFallbackToCopy(err)) { + const content = await destinationFs.readFilePromise(indexPath); + await destinationFs.writeFilePromise(destination, content); + if (sourceMode !== defaultMode) { + await destinationFs.chmodPromise(destination, sourceMode); + } + } else { + throw err; + } + } } }); diff --git a/packages/yarnpkg-fslib/tests/copyPromise.test.ts b/packages/yarnpkg-fslib/tests/copyPromise.test.ts new file mode 100644 index 000000000000..2f189c5992e0 --- /dev/null +++ b/packages/yarnpkg-fslib/tests/copyPromise.test.ts @@ -0,0 +1,192 @@ +import {NodeFS} from '../sources/NodeFS'; +import {xfs, ppath, Filename} from '../sources'; +import {setupCopyIndex} from '../sources'; + +const nodeFs = new NodeFS(); + +function makeLinkError(code: string, syscall = `link`): NodeJS.ErrnoException { + const err: NodeJS.ErrnoException = new Error(`${code}: simulated link failure`); + err.code = code; + err.syscall = syscall; + return err; +} + +describe(`copyPromise`, () => { + describe(`HardlinkFromIndex fallback`, () => { + async function setupIndexEnv() { + const tmpdir = await xfs.mktempPromise(); + const indexPath = ppath.join(tmpdir, `index` as Filename); + await setupCopyIndex(nodeFs, {indexPath}); + + const sourceDir = ppath.join(tmpdir, `source` as Filename); + const destDir = ppath.join(tmpdir, `dest` as Filename); + + await xfs.mkdirPromise(sourceDir); + await xfs.mkdirPromise(destDir); + + const sourceFile = ppath.join(sourceDir, `file.txt` as Filename); + await xfs.writeFilePromise(sourceFile, `Hello World`); + + return {tmpdir, indexPath, sourceDir, destDir, sourceFile}; + } + + it(`should hardlink from index under normal conditions`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + await nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }); + + await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`); + + const destStat = await xfs.statPromise(destFile); + expect(destStat.nlink).toBeGreaterThan(1); + }); + + for (const errorCode of [`EXDEV`, `EMLINK`]) { + it(`should fall back to copy on ${errorCode}`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + const originalLink = nodeFs.linkPromise.bind(nodeFs); + let linkCallCount = 0; + const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => { + linkCallCount++; + // Allow the first link (temp → index), fail the second (index → destination) + if (linkCallCount <= 1) + return originalLink(existingPath, newPath); + throw makeLinkError(errorCode); + }); + + try { + await nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }); + + await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`); + + // Destination should be a copy, not a hard link (nlink === 1) + const destStat = await xfs.statPromise(destFile); + expect(destStat.nlink).toBe(1); + } finally { + spy.mockRestore(); + } + }); + } + + it(`should fall back to copy on UNKNOWN with link syscall (NTFS limit)`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + const originalLink = nodeFs.linkPromise.bind(nodeFs); + let linkCallCount = 0; + const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => { + linkCallCount++; + if (linkCallCount <= 1) + return originalLink(existingPath, newPath); + throw makeLinkError(`UNKNOWN`, `link`); + }); + + try { + await nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }); + + await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`); + } finally { + spy.mockRestore(); + } + }); + + it(`should fall back to copy on EPERM with link syscall`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + const originalLink = nodeFs.linkPromise.bind(nodeFs); + let linkCallCount = 0; + const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => { + linkCallCount++; + if (linkCallCount <= 1) + return originalLink(existingPath, newPath); + throw makeLinkError(`EPERM`, `link`); + }); + + try { + await nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }); + + await expect(xfs.readFilePromise(destFile, `utf8`)).resolves.toBe(`Hello World`); + } finally { + spy.mockRestore(); + } + }); + + it(`should NOT fall back on UNKNOWN without link syscall`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + const originalLink = nodeFs.linkPromise.bind(nodeFs); + let linkCallCount = 0; + const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => { + linkCallCount++; + if (linkCallCount <= 1) + return originalLink(existingPath, newPath); + throw makeLinkError(`UNKNOWN`, `open`); + }); + + try { + await expect( + nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }), + ).rejects.toMatchObject({code: `UNKNOWN`}); + } finally { + spy.mockRestore(); + } + }); + + it(`should propagate non-link errors`, async () => { + const {indexPath, sourceFile, destDir} = await setupIndexEnv(); + const destFile = ppath.join(destDir, `file.txt` as Filename); + + const originalLink = nodeFs.linkPromise.bind(nodeFs); + let linkCallCount = 0; + const spy = jest.spyOn(nodeFs, `linkPromise`).mockImplementation(async (existingPath, newPath) => { + linkCallCount++; + if (linkCallCount <= 1) + return originalLink(existingPath, newPath); + throw makeLinkError(`EACCES`, `link`); + }); + + try { + await expect( + nodeFs.copyPromise(destFile, sourceFile, { + linkStrategy: {type: `HardlinkFromIndex`, indexPath, autoRepair: true}, + stableTime: true, + overwrite: true, + stableSort: true, + }), + ).rejects.toMatchObject({code: `EACCES`}); + } finally { + spy.mockRestore(); + } + }); + }); +}); From a7d4d0a5f6716d1d0d2f4714d77a0ea8d365109c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Mon, 30 Mar 2026 15:56:43 +0200 Subject: [PATCH 2/2] Update packages/yarnpkg-fslib/tests/copyPromise.test.ts Co-authored-by: Tobias Weibel --- packages/yarnpkg-fslib/tests/copyPromise.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/yarnpkg-fslib/tests/copyPromise.test.ts b/packages/yarnpkg-fslib/tests/copyPromise.test.ts index 2f189c5992e0..7c1c2cd9d9fe 100644 --- a/packages/yarnpkg-fslib/tests/copyPromise.test.ts +++ b/packages/yarnpkg-fslib/tests/copyPromise.test.ts @@ -1,6 +1,6 @@ -import {NodeFS} from '../sources/NodeFS'; +import {NodeFS} from '../sources/NodeFS'; import {xfs, ppath, Filename} from '../sources'; -import {setupCopyIndex} from '../sources'; +import {setupCopyIndex} from '../sources'; const nodeFs = new NodeFS();