From 8ef0f5630ad1c3b066e8524ca50c4e96213d8731 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sun, 24 May 2026 23:58:47 +0900 Subject: [PATCH] fix: recover pnpm symlink loops during workspace install --- src/workspace-package-manager.test.ts | 95 ++++++++++++++++++ src/workspace-package-manager.ts | 136 +++++++++++++++++++++----- 2 files changed, 204 insertions(+), 27 deletions(-) diff --git a/src/workspace-package-manager.test.ts b/src/workspace-package-manager.test.ts index 0989542..6288b03 100644 --- a/src/workspace-package-manager.test.ts +++ b/src/workspace-package-manager.test.ts @@ -218,6 +218,101 @@ describe('workspace package manager helpers', () => { expect(execFileSyncMock).not.toHaveBeenCalled(); }); + it('repairs a stale pnpm self-referential node_modules tree before install', () => { + const repoDir = path.join(tempRoot, 'stale-pnpm'); + fs.mkdirSync(path.join(repoDir, 'node_modules', '.pnpm'), { + recursive: true, + }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'stale-pnpm', + packageManager: 'pnpm@10.11.0', + scripts: { test: 'vitest run' }, + }), + ); + fs.writeFileSync( + path.join(repoDir, 'pnpm-lock.yaml'), + 'lockfileVersion: 9.0\n', + ); + const selfLoopPath = path.join( + repoDir, + 'node_modules', + '.pnpm', + 'node_modules', + ); + fs.symlinkSync(selfLoopPath, selfLoopPath); + + execFileSyncMock.mockImplementation((_file, _args, options) => { + const cwd = (options as { cwd: string }).cwd; + expect(fs.existsSync(selfLoopPath)).toBe(false); + fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), ''); + return ''; + }); + + const result = ensureWorkspaceDependenciesInstalled(repoDir); + + expect(result).toMatchObject({ + installed: true, + packageManager: 'pnpm', + commandText: 'corepack pnpm install --frozen-lockfile', + }); + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + expect(hasInstalledNodeModules(repoDir)).toBe(true); + }); + + it('retries once after pnpm reports an ELOOP install failure', () => { + const repoDir = path.join(tempRoot, 'eloop-retry'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'eloop-retry', + packageManager: 'pnpm@10.11.0', + scripts: { test: 'vitest run' }, + }), + ); + fs.writeFileSync( + path.join(repoDir, 'pnpm-lock.yaml'), + 'lockfileVersion: 9.0\n', + ); + + execFileSyncMock.mockImplementationOnce((_file, _args, options) => { + const cwd = (options as { cwd: string }).cwd; + fs.mkdirSync(path.join(cwd, 'node_modules', '.pnpm'), { + recursive: true, + }); + const selfLoopPath = path.join( + cwd, + 'node_modules', + '.pnpm', + 'node_modules', + ); + fs.symlinkSync(selfLoopPath, selfLoopPath); + throw { stderr: 'ELOOP: too many symbolic links encountered' }; + }); + execFileSyncMock.mockImplementationOnce((_file, _args, options) => { + const cwd = (options as { cwd: string }).cwd; + expect( + fs.existsSync(path.join(cwd, 'node_modules', '.pnpm', 'node_modules')), + ).toBe(false); + fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), ''); + return ''; + }); + + const result = ensureWorkspaceDependenciesInstalled(repoDir); + + expect(result).toMatchObject({ + installed: true, + packageManager: 'pnpm', + commandText: 'corepack pnpm install --frozen-lockfile', + }); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); + expect(hasInstalledNodeModules(repoDir)).toBe(true); + }); + it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => { const parentDir = path.join(tempRoot, 'parent'); fs.mkdirSync(parentDir, { recursive: true }); diff --git a/src/workspace-package-manager.ts b/src/workspace-package-manager.ts index d8022af..ea03635 100644 --- a/src/workspace-package-manager.ts +++ b/src/workspace-package-manager.ts @@ -226,11 +226,63 @@ function writeInstallFingerprint( ); } +function hasRecoverableSymlinkLoopAt(symlinkPath: string): boolean { + let stat: fs.Stats; + try { + stat = fs.lstatSync(symlinkPath); + } catch (error) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ELOOP' + ); + } + + if (!stat.isSymbolicLink()) { + return false; + } + + const target = fs.readlinkSync(symlinkPath); + const resolvedTarget = path.resolve(path.dirname(symlinkPath), target); + if (resolvedTarget === symlinkPath) { + return true; + } + + try { + fs.realpathSync(symlinkPath); + return false; + } catch (error) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ELOOP' + ); + } +} + +function hasRecoverableNodeModulesLoop(repoDir: string): boolean { + return hasRecoverableSymlinkLoopAt( + path.join(repoDir, 'node_modules', '.pnpm', 'node_modules'), + ); +} + +function removeNodeModulesTree(repoDir: string): void { + fs.rmSync(path.join(repoDir, 'node_modules'), { + recursive: true, + force: true, + }); +} + function hasRunnableNodeModulesTree(repoDir: string): boolean { const nodeModulesDir = path.join(repoDir, 'node_modules'); if (!fs.existsSync(nodeModulesDir)) { return false; } + if (hasRecoverableNodeModulesLoop(repoDir)) { + return false; + } try { return fs @@ -383,6 +435,42 @@ export function hasInstalledNodeModules(repoDir: string): boolean { return readInstallFingerprint(repoDir) === expectedFingerprint; } +function getInstallErrorDetail(error: unknown): string { + const stderr = + typeof error === 'object' && + error !== null && + 'stderr' in error && + typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr.trim() + : ''; + const stdout = + typeof error === 'object' && + error !== null && + 'stdout' in error && + typeof (error as { stdout?: unknown }).stdout === 'string' + ? (error as { stdout: string }).stdout.trim() + : ''; + return ( + stderr || stdout || (error instanceof Error ? error.message : String(error)) + ); +} + +function isRecoverableNodeModulesLoopInstallFailure(detail: string): boolean { + return /\bELOOP\b|too many symbolic links/i.test(detail); +} + +function runWorkspaceInstallCommand( + repoDir: string, + command: WorkspaceCommandSpec, +): void { + execFileSync(command.file, command.args, { + cwd: repoDir, + env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager), + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + export function ensureWorkspaceDependenciesInstalled( repoDir: string, ): WorkspaceDependencyInstallResult { @@ -403,35 +491,29 @@ export function ensureWorkspaceDependenciesInstalled( return { installed: false, packageManager }; } + if (hasRecoverableNodeModulesLoop(repoDir)) { + removeNodeModulesTree(repoDir); + } + try { - execFileSync(command.file, command.args, { - cwd: repoDir, - env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager), - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }); + runWorkspaceInstallCommand(repoDir, command); } catch (error) { - const stderr = - typeof error === 'object' && - error !== null && - 'stderr' in error && - typeof (error as { stderr?: unknown }).stderr === 'string' - ? (error as { stderr: string }).stderr.trim() - : ''; - const stdout = - typeof error === 'object' && - error !== null && - 'stdout' in error && - typeof (error as { stdout?: unknown }).stdout === 'string' - ? (error as { stdout: string }).stdout.trim() - : ''; - const detail = - stderr || - stdout || - (error instanceof Error ? error.message : String(error)); - throw new Error( - `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`, - ); + const detail = getInstallErrorDetail(error); + if (isRecoverableNodeModulesLoopInstallFailure(detail)) { + removeNodeModulesTree(repoDir); + try { + runWorkspaceInstallCommand(repoDir, command); + } catch (retryError) { + const retryDetail = getInstallErrorDetail(retryError); + throw new Error( + `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir} after cleaning node_modules: ${retryDetail}`, + ); + } + } else { + throw new Error( + `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`, + ); + } } const fingerprint = computeInstallFingerprint(repoDir);