fix: recover pnpm symlink loops during workspace install

This commit is contained in:
ejclaw
2026-05-24 23:58:47 +09:00
parent 7b66f91572
commit 8ef0f5630a
2 changed files with 204 additions and 27 deletions

View File

@@ -218,6 +218,101 @@ describe('workspace package manager helpers', () => {
expect(execFileSyncMock).not.toHaveBeenCalled(); 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', () => { it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
const parentDir = path.join(tempRoot, 'parent'); const parentDir = path.join(tempRoot, 'parent');
fs.mkdirSync(parentDir, { recursive: true }); fs.mkdirSync(parentDir, { recursive: true });

View File

@@ -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 { function hasRunnableNodeModulesTree(repoDir: string): boolean {
const nodeModulesDir = path.join(repoDir, 'node_modules'); const nodeModulesDir = path.join(repoDir, 'node_modules');
if (!fs.existsSync(nodeModulesDir)) { if (!fs.existsSync(nodeModulesDir)) {
return false; return false;
} }
if (hasRecoverableNodeModulesLoop(repoDir)) {
return false;
}
try { try {
return fs return fs
@@ -383,6 +435,42 @@ export function hasInstalledNodeModules(repoDir: string): boolean {
return readInstallFingerprint(repoDir) === expectedFingerprint; 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( export function ensureWorkspaceDependenciesInstalled(
repoDir: string, repoDir: string,
): WorkspaceDependencyInstallResult { ): WorkspaceDependencyInstallResult {
@@ -403,35 +491,29 @@ export function ensureWorkspaceDependenciesInstalled(
return { installed: false, packageManager }; return { installed: false, packageManager };
} }
if (hasRecoverableNodeModulesLoop(repoDir)) {
removeNodeModulesTree(repoDir);
}
try { try {
execFileSync(command.file, command.args, { runWorkspaceInstallCommand(repoDir, command);
cwd: repoDir,
env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager),
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (error) { } catch (error) {
const stderr = const detail = getInstallErrorDetail(error);
typeof error === 'object' && if (isRecoverableNodeModulesLoopInstallFailure(detail)) {
error !== null && removeNodeModulesTree(repoDir);
'stderr' in error && try {
typeof (error as { stderr?: unknown }).stderr === 'string' runWorkspaceInstallCommand(repoDir, command);
? (error as { stderr: string }).stderr.trim() } catch (retryError) {
: ''; const retryDetail = getInstallErrorDetail(retryError);
const stdout = throw new Error(
typeof error === 'object' && `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir} after cleaning node_modules: ${retryDetail}`,
error !== null && );
'stdout' in error && }
typeof (error as { stdout?: unknown }).stdout === 'string' } else {
? (error as { stdout: string }).stdout.trim() throw new Error(
: ''; `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`,
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 fingerprint = computeInstallFingerprint(repoDir); const fingerprint = computeInstallFingerprint(repoDir);