angular-cli/scripts/windows-testing/convert-symlinks.mjs
Paul Gschwendtner 54c561af4a build: avoid WSL interop flake when preparing Windows CI environment
For our native Windows tests (verifying `ng` CLI outside WSL), we
currently build the test binaries inside WSL, leveraging bazel remote
execution.

The scripts involved in moving the built binaries to outside WSL rarely
flake due to a WSL interop bug when we invoke Windows commands from inside
WSL (which is an untypical thing to do; but a needed trick given some WSL limitation).

This commit tries to stabilize/avoid this flake by retrying if we
recognize this.
2025-03-13 09:11:26 +01:00

171 lines
5.8 KiB
JavaScript

/**
* @fileoverview Script that takes a directory and converts all its Unix symlinks
* to relative Windows-compatible symlinks. This is necessary because when building
* tests via Bazel inside WSL; the output cannot simply be used outside WSL to perform
* native Windows testing. This is a known limitation/bug of the WSL <> Windows interop.
*
* Symlinks are commonly used by Bazel inside the `.runfiles` directory, which is relevant
* for executing tests outside Bazel on the host machine. In addition, `rules_js` heavily
* relies on symlinks for node modules.
*
* Some more details in:
* - https://blog.trailofbits.com/2024/02/12/why-windows-cant-follow-wsl-symlinks/.
* - https://pnpm.io/symlinked-node-modules-structure.
*/
import path from 'node:path';
import fs from 'node:fs/promises';
import childProcess from 'node:child_process';
const [rootDir, cmdPath] = process.argv.slice(2);
// GitHub actions can set this environment variable when pressing the "re-run" button.
const debug = process.env.ACTIONS_STEP_DEBUG === 'true';
const skipDirectories = [
// Modules that we don't need and would unnecessarily slow-down this.
'_windows_amd64/bin/nodejs/node_modules',
];
const workspaceRootPaths = [/.*\.runfiles\/angular_cli\//, /^.*-fastbuild\/bin\//];
// Copying can be parallelized and doesn't cause any WSL flakiness (no exe is invoked).
const parallelCopyTasks = [];
async function transformDir(p) {
// We perform all command executions in parallel here to speed up.
// Note that we can't parallelize for the full recursive directory,
// as WSL and its interop would otherwise end up with some flaky errors.
// See: https://github.com/microsoft/WSL/issues/8677.
const tasks = [];
// We explore directories after all files were checked at this level.
const directoriesToVisit = [];
for (const file of await fs.readdir(p, { withFileTypes: true })) {
const subPath = path.join(p, file.name);
if (skipDirectories.some((d) => subPath.endsWith(d))) {
continue;
}
if (file.isSymbolicLink()) {
// Allow for parallel processing of directory entries.
tasks.push(
(async () => {
let target = '';
try {
target = await fs.realpath(subPath);
} catch (e) {
if (debug) {
console.error('Skipping', subPath);
}
return;
}
await fs.rm(subPath);
const subPathId = relativizeForSimilarWorkspacePaths(subPath);
const targetPathId = relativizeForSimilarWorkspacePaths(target);
const isSelfLink = subPathId === targetPathId;
// This is an actual file that needs to be copied. Copy contents.
// - the target path is equivalent to the link. This is a self-link from `.runfiles` to `bin/`.
// - the target path is outside any of our workspace roots.
if (isSelfLink || targetPathId.startsWith('..')) {
parallelCopyTasks.push(exec(`cp -Rf ${target} ${subPath}`));
return;
}
const relativeSubPath = relativizeToRoot(subPath);
const targetAtDestination = path.relative(path.dirname(subPathId), targetPathId);
const targetAtDestinationWindowsPath = targetAtDestination.replace(/\//g, '\\');
const wslSubPath = relativeSubPath.replace(/\//g, '\\');
if (debug) {
console.log({
targetAtDestination,
subPath,
relativeSubPath,
target,
targetPathId,
subPathId,
});
}
if ((await fs.stat(target)).isDirectory()) {
// This is a symlink to a directory, create a dir junction.
// Re-create this symlink on the Windows FS using the Windows mklink command.
await exec(
`${cmdPath} /c mklink /d "${wslSubPath}" "${targetAtDestinationWindowsPath}"`,
);
} else {
// This is a symlink to a file, create a file junction.
// Re-create this symlink on the Windows FS using the Windows mklink command.
await exec(`${cmdPath} /c mklink "${wslSubPath}" "${targetAtDestinationWindowsPath}"`);
}
})(),
);
} else if (file.isDirectory()) {
directoriesToVisit.push(subPath);
}
}
// Wait for all commands/tasks to complete, executed in parallel.
await Promise.all(tasks);
// Descend into other directories, sequentially to avoid WSL interop errors.
for (const d of directoriesToVisit) {
await transformDir(d);
}
}
function exec(cmd, maxRetries = 2) {
return new Promise((resolve, reject) => {
childProcess.exec(cmd, { cwd: rootDir }, (error) => {
if (error !== null) {
// Windows command spawned within WSL (which is untypical) seem to be flaky rarely.
// This logic tries to make it fully stable by re-trying if this surfaces:
// See: https://github.com/microsoft/WSL/issues/8677.
if (
maxRetries > 0 &&
error.stderr !== undefined &&
error.stderr.includes(`accept4 failed 110`)
) {
resolve(exec(cmd, maxRetries - 1));
return;
}
reject(error);
} else {
resolve();
}
});
});
}
function relativizeForSimilarWorkspacePaths(p) {
const workspaceRootMatch = workspaceRootPaths.find((r) => r.test(p));
if (workspaceRootMatch !== undefined) {
return p.replace(workspaceRootMatch, '');
}
return path.relative(rootDir, p);
}
function relativizeToRoot(p) {
const res = path.relative(rootDir, p);
if (!res.startsWith('..')) {
return res;
}
throw new Error('Could not relativize to root: ' + p);
}
try {
await transformDir(rootDir);
await Promise.all(parallelCopyTasks);
} catch (err) {
console.error('Could not convert symlinks:', err);
process.exitCode = 1;
}