Skip to content

Commit e9a683d

Browse files
authored
fix(builtin): account for racy deletion of symlink in linker (#2662)
On Windows, the linker has been prone to race conditions with respect to symlinking packages into the `node_modules` tree, causing ENOENT errors that causes the target to fail. Since the intention of `symlinkWithUnlink` and `deleteDirectory` is to unlink the directory anyway, we can simply ignore ENOENT errors for the directory that would be about to be unlinked.
1 parent 911529f commit e9a683d

File tree

2 files changed

+90
-21
lines changed

2 files changed

+90
-21
lines changed

internal/linker/index.js

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ function gracefulLstat(path) {
5050
}
5151
});
5252
}
53+
function gracefulReadlink(path) {
54+
try {
55+
return fs.readlinkSync(path);
56+
}
57+
catch (e) {
58+
if (e.code === 'ENOENT') {
59+
return null;
60+
}
61+
throw e;
62+
}
63+
}
64+
function gracefulReaddir(path) {
65+
return __awaiter(this, void 0, void 0, function* () {
66+
try {
67+
return yield fs.promises.readdir(path);
68+
}
69+
catch (e) {
70+
if (e.code === 'ENOENT') {
71+
return [];
72+
}
73+
throw e;
74+
}
75+
});
76+
}
5377
function unlink(moduleName) {
5478
return __awaiter(this, void 0, void 0, function* () {
5579
const stat = yield gracefulLstat(moduleName);
@@ -69,11 +93,12 @@ function unlink(moduleName) {
6993
function deleteDirectory(p) {
7094
return __awaiter(this, void 0, void 0, function* () {
7195
log_verbose("Deleting children of", p);
72-
for (let entry of yield fs.promises.readdir(p)) {
96+
for (let entry of yield gracefulReaddir(p)) {
7397
const childPath = path.join(p, entry);
7498
const stat = yield gracefulLstat(childPath);
7599
if (stat === null) {
76-
throw Error(`File does not exist, but is listed as directory entry: ${childPath}`);
100+
log_verbose(`File does not exist, but is listed as directory entry: ${childPath}`);
101+
continue;
77102
}
78103
if (stat.isDirectory()) {
79104
yield deleteDirectory(childPath);
@@ -277,11 +302,17 @@ function main(args, runfiles) {
277302
stats = yield gracefulLstat(p);
278303
}
279304
if (runfiles.manifest && execroot && stats !== null && stats.isSymbolicLink()) {
280-
const symlinkPath = fs.readlinkSync(p).replace(/\\/g, '/');
281-
if (path.relative(symlinkPath, target) != '' &&
282-
!path.relative(execroot, symlinkPath).startsWith('..')) {
283-
log_verbose(`Out-of-date symlink for ${p} to ${symlinkPath} detected. Target should be ${target}. Unlinking.`);
284-
yield unlink(p);
305+
const symlinkPathRaw = gracefulReadlink(p);
306+
if (symlinkPathRaw !== null) {
307+
const symlinkPath = symlinkPathRaw.replace(/\\/g, '/');
308+
if (path.relative(symlinkPath, target) != '' &&
309+
!path.relative(execroot, symlinkPath).startsWith('..')) {
310+
log_verbose(`Out-of-date symlink for ${p} to ${symlinkPath} detected. Target should be ${target}. Unlinking.`);
311+
yield unlink(p);
312+
}
313+
else {
314+
log_verbose(`The symlink at ${p} no longer exists, so no need to unlink it.`);
315+
}
285316
}
286317
}
287318
return symlink(target, p);

internal/linker/link_node_modules.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,36 @@ async function gracefulLstat(path: string): Promise<fs.Stats|null> {
5959
}
6060
}
6161

62+
/**
63+
* Resolves a symlink to its linked path for a given path. Returns `null` if the path
64+
* does not exist on disk.
65+
*/
66+
function gracefulReadlink(path: string): string|null {
67+
try {
68+
return fs.readlinkSync(path);
69+
} catch (e) {
70+
if (e.code === 'ENOENT') {
71+
return null;
72+
}
73+
throw e;
74+
}
75+
}
76+
77+
/**
78+
* Lists the names of files and directories that exist in the given path. Returns an empty
79+
* array if the path does not exist on disk.
80+
*/
81+
async function gracefulReaddir(path: string): Promise<string[]> {
82+
try {
83+
return await fs.promises.readdir(path);
84+
} catch (e) {
85+
if (e.code === 'ENOENT') {
86+
return [];
87+
}
88+
throw e;
89+
}
90+
}
91+
6292
/**
6393
* Deletes the given module name from the current working directory (i.e. symlink root).
6494
* If the module name resolves to a directory, the directory is deleted. Otherwise the
@@ -81,11 +111,12 @@ async function unlink(moduleName: string) {
81111
/** Asynchronously deletes a given directory (with contents). */
82112
async function deleteDirectory(p: string) {
83113
log_verbose("Deleting children of", p);
84-
for (let entry of await fs.promises.readdir(p)) {
114+
for (let entry of await gracefulReaddir(p)) {
85115
const childPath = path.join(p, entry);
86116
const stat = await gracefulLstat(childPath);
87117
if (stat === null) {
88-
throw Error(`File does not exist, but is listed as directory entry: ${childPath}`);
118+
log_verbose(`File does not exist, but is listed as directory entry: ${childPath}`);
119+
continue;
89120
}
90121
if (stat.isDirectory()) {
91122
await deleteDirectory(childPath);
@@ -413,18 +444,25 @@ export async function main(args: string[], runfiles: Runfiles) {
413444
// then this is guaranteed to be not an artifact from a previous linker run. If not we need to
414445
// check.
415446
if (runfiles.manifest && execroot && stats !== null && stats.isSymbolicLink()) {
416-
const symlinkPath = fs.readlinkSync(p).replace(/\\/g, '/');
417-
if (path.relative(symlinkPath, target) != '' &&
418-
!path.relative(execroot, symlinkPath).startsWith('..')) {
419-
// Left-over out-of-date symlink from previous run. This can happen if switching between
420-
// root configuration options such as `--noenable_runfiles` and/or
421-
// `--spawn_strategy=standalone`. It can also happen if two different targets link the same
422-
// module name to different targets in a non-sandboxed environment. The latter will lead to
423-
// undeterministic behavior.
424-
// TODO: can we detect the latter case and throw an apprioriate error?
425-
log_verbose(`Out-of-date symlink for ${p} to ${symlinkPath} detected. Target should be ${
426-
target}. Unlinking.`);
427-
await unlink(p);
447+
// Although `stats` suggests that the file exists as a symlink, it may have been deleted by
448+
// another process. Only proceed unlinking if the file actually still exists.
449+
const symlinkPathRaw = gracefulReadlink(p);
450+
if (symlinkPathRaw !== null) {
451+
const symlinkPath = symlinkPathRaw.replace(/\\/g, '/');
452+
if (path.relative(symlinkPath, target) != '' &&
453+
!path.relative(execroot, symlinkPath).startsWith('..')) {
454+
// Left-over out-of-date symlink from previous run. This can happen if switching between
455+
// root configuration options such as `--noenable_runfiles` and/or
456+
// `--spawn_strategy=standalone`. It can also happen if two different targets link the
457+
// same module name to different targets in a non-sandboxed environment. The latter will
458+
// lead to undeterministic behavior.
459+
// TODO: can we detect the latter case and throw an apprioriate error?
460+
log_verbose(`Out-of-date symlink for ${p} to ${symlinkPath} detected. Target should be ${
461+
target}. Unlinking.`);
462+
await unlink(p);
463+
} else {
464+
log_verbose(`The symlink at ${p} no longer exists, so no need to unlink it.`);
465+
}
428466
}
429467
}
430468
return symlink(target, p);

0 commit comments

Comments
 (0)