Skip to content

Commit 2979fad

Browse files
devversionalexeagle
authored andcommitted
fix(builtin): linker silently not generating expected links in windows
Currently the builtin linker sometimes does not generate links as defined in the module mappings on Windows. This results in unexpected and confusing runtime issues, and differences with other platforms. The linker fails to generate links if module mappings result in a different symlink/module hierarchy (as computed by `reduceModules`). For example, consider in a previous linker run, we linked the module `@angular/cdk/overlay` into `node_modules/@angular/cdk/overlay`. In a second run then, we actually link `@angular/cdk`. The linker will fail to do that as the `node_modules/@angular/cdk` folder already exists (due to missing sandbox/runfile symlinking in windows) We fix this by clearing such leftover linker directories so that the newly configured module mapping can be created. In order to avoid race conditions in non-sandboxed environments, we need to pay special attention to potential concurrent resource accesses, and also need to preserve possible child links from previous or concurrent linker runs.
1 parent 395a98c commit 2979fad

3 files changed

Lines changed: 337 additions & 34 deletions

File tree

internal/linker/index.js

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,56 @@ function mkdirp(p) {
4545
}
4646
});
4747
}
48+
function gracefulLstat(path) {
49+
return __awaiter(this, void 0, void 0, function* () {
50+
try {
51+
return yield fs.promises.lstat(path);
52+
}
53+
catch (e) {
54+
if (e.code === 'ENOENT') {
55+
return null;
56+
}
57+
throw e;
58+
}
59+
});
60+
}
61+
function unlink(moduleName) {
62+
return __awaiter(this, void 0, void 0, function* () {
63+
const stat = yield gracefulLstat(moduleName);
64+
if (stat === null) {
65+
return;
66+
}
67+
log_verbose(`unlink( ${moduleName} )`);
68+
if (stat.isDirectory()) {
69+
yield deleteDirectory(moduleName);
70+
}
71+
else {
72+
log_verbose("Deleting file: ", moduleName);
73+
yield fs.promises.unlink(moduleName);
74+
}
75+
});
76+
}
77+
function deleteDirectory(p) {
78+
return __awaiter(this, void 0, void 0, function* () {
79+
log_verbose("Deleting children of", p);
80+
for (let entry of yield fs.promises.readdir(p)) {
81+
const childPath = path.join(p, entry);
82+
const stat = yield gracefulLstat(childPath);
83+
if (stat === null) {
84+
throw Error(`File does not exist, but is listed as directory entry: ${childPath}`);
85+
}
86+
if (stat.isDirectory()) {
87+
yield deleteDirectory(childPath);
88+
}
89+
else {
90+
log_verbose("Deleting file", childPath);
91+
yield fs.promises.unlink(childPath);
92+
}
93+
}
94+
log_verbose("Cleaning up dir", p);
95+
yield fs.promises.rmdir(p);
96+
});
97+
}
4898
function symlink(target, p) {
4999
return __awaiter(this, void 0, void 0, function* () {
50100
log_verbose(`symlink( ${p} -> ${target} )`);
@@ -210,21 +260,12 @@ class Runfiles {
210260
exports.Runfiles = Runfiles;
211261
function exists(p) {
212262
return __awaiter(this, void 0, void 0, function* () {
213-
try {
214-
yield fs.promises.stat(p);
215-
return true;
216-
}
217-
catch (e) {
218-
if (e.code === 'ENOENT') {
219-
return false;
220-
}
221-
throw e;
222-
}
263+
return ((yield gracefulLstat(p)) !== null);
223264
});
224265
}
225266
function existsSync(p) {
226267
try {
227-
fs.statSync(p);
268+
fs.lstatSync(p);
228269
return true;
229270
}
230271
catch (e) {
@@ -324,6 +365,23 @@ function isDirectChildLink([parentRel, parentPath], [childRel, childPath]) {
324365
function isNameLinkPathTopAligned(namePath, [, linkPath]) {
325366
return path.basename(namePath) === path.basename(linkPath);
326367
}
368+
function visitDirectoryPreserveLinks(dirPath, visit) {
369+
return __awaiter(this, void 0, void 0, function* () {
370+
for (const entry of yield fs.promises.readdir(dirPath)) {
371+
const childPath = path.join(dirPath, entry);
372+
const stat = yield gracefulLstat(childPath);
373+
if (stat === null) {
374+
continue;
375+
}
376+
if (stat.isDirectory()) {
377+
yield visitDirectoryPreserveLinks(childPath, visit);
378+
}
379+
else {
380+
yield visit(childPath, stat);
381+
}
382+
}
383+
});
384+
}
327385
function main(args, runfiles) {
328386
return __awaiter(this, void 0, void 0, function* () {
329387
if (!args || args.length < 1)
@@ -346,6 +404,41 @@ function main(args, runfiles) {
346404
}
347405
yield symlink(rootDir, 'node_modules');
348406
process.chdir(rootDir);
407+
function isLeftoverDirectoryFromLinker(stats, modulePath) {
408+
return __awaiter(this, void 0, void 0, function* () {
409+
if (runfiles.manifest === undefined) {
410+
return false;
411+
}
412+
if (!stats.isDirectory()) {
413+
return false;
414+
}
415+
let isLeftoverFromPreviousLink = true;
416+
yield visitDirectoryPreserveLinks(modulePath, (childPath, childStats) => __awaiter(this, void 0, void 0, function* () {
417+
if (!childStats.isSymbolicLink()) {
418+
isLeftoverFromPreviousLink = false;
419+
}
420+
}));
421+
return isLeftoverFromPreviousLink;
422+
});
423+
}
424+
function createSymlinkAndPreserveContents(stats, modulePath, target) {
425+
return __awaiter(this, void 0, void 0, function* () {
426+
const tmpPath = `${modulePath}__linker_tmp`;
427+
log_verbose(`createSymlinkAndPreserveContents( ${modulePath} )`);
428+
yield symlink(target, tmpPath);
429+
yield visitDirectoryPreserveLinks(modulePath, (childPath, stat) => __awaiter(this, void 0, void 0, function* () {
430+
if (stat.isSymbolicLink()) {
431+
const targetPath = path.join(tmpPath, path.relative(modulePath, childPath));
432+
log_verbose(`Cloning symlink into temporary created link ( ${childPath} )`);
433+
yield mkdirp(path.dirname(targetPath));
434+
yield symlink(targetPath, yield fs.promises.realpath(childPath));
435+
}
436+
}));
437+
log_verbose(`Removing existing module so that new link can take place ( ${modulePath} )`);
438+
yield unlink(modulePath);
439+
yield fs.promises.rename(tmpPath, modulePath);
440+
});
441+
}
349442
function linkModules(m) {
350443
return __awaiter(this, void 0, void 0, function* () {
351444
yield mkdirp(path.dirname(m.name));
@@ -381,16 +474,22 @@ function main(args, runfiles) {
381474
}
382475
break;
383476
}
384-
yield symlink(target, m.name);
477+
const stats = yield gracefulLstat(m.name);
478+
if (stats !== null && (yield isLeftoverDirectoryFromLinker(stats, m.name))) {
479+
yield createSymlinkAndPreserveContents(stats, m.name, target);
480+
}
481+
else {
482+
yield symlink(target, m.name);
483+
}
385484
}
386485
if (m.children) {
387486
yield Promise.all(m.children.map(linkModules));
388487
}
389488
});
390489
}
391-
const moduleHeirarchy = reduceModules(modules);
392-
log_verbose(`mapping hierarchy ${JSON.stringify(moduleHeirarchy)}`);
393-
const links = moduleHeirarchy.map(linkModules);
490+
const moduleHierarchy = reduceModules(modules);
491+
log_verbose(`mapping hierarchy ${JSON.stringify(moduleHierarchy)}`);
492+
const links = moduleHierarchy.map(linkModules);
394493
let code = 0;
395494
yield Promise.all(links).catch(e => {
396495
log_error(e);

internal/linker/link_node_modules.ts

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,60 @@ async function mkdirp(p: string) {
4747
}
4848
}
4949

50+
/**
51+
* Gets the `lstat` results for a given path. Returns `null` if the path
52+
* does not exist on disk.
53+
*/
54+
async function gracefulLstat(path: string): Promise<fs.Stats|null> {
55+
try {
56+
return await fs.promises.lstat(path);
57+
} catch (e) {
58+
if (e.code === 'ENOENT') {
59+
return null;
60+
}
61+
throw e;
62+
}
63+
}
64+
65+
/**
66+
* Deletes the given module name from the current working directory (i.e. symlink root).
67+
* If the module name resolves to a directory, the directory is deleted. Otherwise the
68+
* existing file or junction is unlinked.
69+
*/
70+
async function unlink(moduleName: string) {
71+
const stat = await gracefulLstat(moduleName);
72+
if (stat === null) {
73+
return;
74+
}
75+
log_verbose(`unlink( ${moduleName} )`);
76+
if (stat.isDirectory()) {
77+
await deleteDirectory(moduleName);
78+
} else {
79+
log_verbose("Deleting file: ", moduleName);
80+
await fs.promises.unlink(moduleName);
81+
}
82+
}
83+
84+
/** Asynchronously deletes a given directory (with contents). */
85+
async function deleteDirectory(p: string) {
86+
log_verbose("Deleting children of", p);
87+
for (let entry of await fs.promises.readdir(p)) {
88+
const childPath = path.join(p, entry);
89+
const stat = await gracefulLstat(childPath);
90+
if (stat === null) {
91+
throw Error(`File does not exist, but is listed as directory entry: ${childPath}`);
92+
}
93+
if (stat.isDirectory()) {
94+
await deleteDirectory(childPath);
95+
} else {
96+
log_verbose("Deleting file", childPath);
97+
await fs.promises.unlink(childPath);
98+
}
99+
}
100+
log_verbose("Cleaning up dir", p);
101+
await fs.promises.rmdir(p);
102+
}
103+
50104
async function symlink(target: string, p: string): Promise<boolean> {
51105
log_verbose(`symlink( ${p} -> ${target} )`);
52106

@@ -55,7 +109,7 @@ async function symlink(target: string, p: string): Promise<boolean> {
55109
// it is necessary for the time being.
56110
if (!await exists(target)) {
57111
// This can happen if a module mapping is propogated from a dependency
58-
// but the targat that generated the mapping in not in the deps. We don't
112+
// but the target that generated the mapping in not in the deps. We don't
59113
// want to create symlinks to non-existant targets as this will
60114
// break any nested symlinks that may be created under the module name
61115
// after this.
@@ -334,20 +388,12 @@ declare global {
334388
// There is no fs.promises.exists function because
335389
// node core is of the opinion that exists is always too racey to rely on.
336390
async function exists(p: string) {
337-
try {
338-
await fs.promises.stat(p)
339-
return true;
340-
} catch (e) {
341-
if (e.code === 'ENOENT') {
342-
return false;
343-
}
344-
throw e;
345-
}
391+
return (await gracefulLstat(p) !== null);
346392
}
347393

348394
function existsSync(p: string) {
349395
try {
350-
fs.statSync(p)
396+
fs.lstatSync(p);
351397
return true;
352398
} catch (e) {
353399
if (e.code === 'ENOENT') {
@@ -508,6 +554,22 @@ function isNameLinkPathTopAligned(namePath: string, [, linkPath]: Link) {
508554
return path.basename(namePath) === path.basename(linkPath);
509555
}
510556

557+
async function visitDirectoryPreserveLinks(
558+
dirPath: string, visit: (filePath: string, stat: fs.Stats) => Promise<void>) {
559+
for (const entry of await fs.promises.readdir(dirPath)) {
560+
const childPath = path.join(dirPath, entry);
561+
const stat = await gracefulLstat(childPath);
562+
if (stat === null) {
563+
continue;
564+
}
565+
if (stat.isDirectory()) {
566+
await visitDirectoryPreserveLinks(childPath, visit);
567+
} else {
568+
await visit(childPath, stat);
569+
}
570+
}
571+
}
572+
511573
// See link_node_modules.bzl where these link roots types
512574
// are used to indicate which root the linker should target
513575
// for each package:
@@ -567,6 +629,64 @@ export async function main(args: string[], runfiles: Runfiles) {
567629
// symlinks will be created under node_modules
568630
process.chdir(rootDir);
569631

632+
/**
633+
* Whether the given module resolves to a directory that has been created by a previous linker
634+
* run purely to make space for deep module links. e.g. consider a mapping for `my-pkg/a11y`.
635+
* The linker will create folders like `node_modules/my-pkg/` so that the `a11y` symbolic
636+
* junction can be created. The `my-pkg` folder is then considered a leftover from a previous
637+
* linker run as it only contains symbolic links and no actual source files.
638+
*/
639+
async function isLeftoverDirectoryFromLinker(stats: fs.Stats, modulePath: string) {
640+
// If we are running without a runfiles manifest (i.e. in sandbox or with symlinked runfiles),
641+
// then this is guaranteed to be not an artifact from a previous linker run.
642+
if (runfiles.manifest === undefined) {
643+
return false;
644+
}
645+
if (!stats.isDirectory()) {
646+
return false;
647+
}
648+
let isLeftoverFromPreviousLink = true;
649+
// If the directory contains actual files, this cannot be a leftover from a previous
650+
// linker run. The linker only creates directories in the node modules that hold
651+
// symbolic links for configured module mappings.
652+
await visitDirectoryPreserveLinks(modulePath, async (childPath, childStats) => {
653+
if (!childStats.isSymbolicLink()) {
654+
isLeftoverFromPreviousLink = false;
655+
}
656+
});
657+
return isLeftoverFromPreviousLink;
658+
}
659+
660+
/**
661+
* Creates a symlink for the given module. Existing child symlinks which are part of
662+
* the module are preserved in order to not cause race conditions in non-sandbox
663+
* environments where multiple actions rely on the same node modules root.
664+
*
665+
* To avoid unexpected resource removal, a new temporary link for the target is created.
666+
* Then all symlinks from the existing module are cloned. Once done, the existing module
667+
* is unlinked while the temporary link takes place for the given module. This ensures
668+
* that the module link is never removed at any time (causing race condition failures).
669+
*/
670+
async function createSymlinkAndPreserveContents(stats: fs.Stats, modulePath: string,
671+
target: string) {
672+
const tmpPath = `${modulePath}__linker_tmp`;
673+
log_verbose(`createSymlinkAndPreserveContents( ${modulePath} )`);
674+
675+
await symlink(target, tmpPath);
676+
await visitDirectoryPreserveLinks(modulePath, async (childPath, stat) => {
677+
if (stat.isSymbolicLink()) {
678+
const targetPath = path.join(tmpPath, path.relative(modulePath, childPath));
679+
log_verbose(`Cloning symlink into temporary created link ( ${childPath} )`);
680+
await mkdirp(path.dirname(targetPath));
681+
await symlink(targetPath, await fs.promises.realpath(childPath));
682+
}
683+
});
684+
685+
log_verbose(`Removing existing module so that new link can take place ( ${modulePath} )`);
686+
await unlink(modulePath);
687+
await fs.promises.rename(tmpPath, modulePath);
688+
}
689+
570690
async function linkModules(m: LinkerTreeElement) {
571691
// ensure the parent directory exist
572692
await mkdirp(path.dirname(m.name));
@@ -605,7 +725,20 @@ export async function main(args: string[], runfiles: Runfiles) {
605725
break;
606726
}
607727

608-
await symlink(target, m.name);
728+
const stats = await gracefulLstat(m.name);
729+
// In environments where runfiles are not symlinked (e.g. Windows), existing linked
730+
// modules are preserved. This could cause issues when a link is created at higher level
731+
// as a conflicting directory is already on disk. e.g. consider in a previous run, we
732+
// linked the modules `my-pkg/overlay`. Later on, in another run, we have a module mapping
733+
// for `my-pkg` itself. The linker cannot create `my-pkg` because the directory `my-pkg`
734+
// already exists. To ensure that the desired link is generated, we create the new desired
735+
// link and move all previous nested links from the old module into the new link. Read more
736+
// about this in the description of `createSymlinkAndPreserveContents`.
737+
if (stats !== null && await isLeftoverDirectoryFromLinker(stats, m.name)) {
738+
await createSymlinkAndPreserveContents(stats, m.name, target);
739+
} else {
740+
await symlink(target, m.name);
741+
}
609742
}
610743

611744
// Process each child branch concurrently
@@ -614,11 +747,11 @@ export async function main(args: string[], runfiles: Runfiles) {
614747
}
615748
}
616749

617-
const moduleHeirarchy = reduceModules(modules);
618-
log_verbose(`mapping hierarchy ${JSON.stringify(moduleHeirarchy)}`);
750+
const moduleHierarchy = reduceModules(modules);
751+
log_verbose(`mapping hierarchy ${JSON.stringify(moduleHierarchy)}`);
619752

620753
// Process each root branch concurrently
621-
const links = moduleHeirarchy.map(linkModules);
754+
const links = moduleHierarchy.map(linkModules);
622755

623756
let code = 0;
624757
await Promise.all(links).catch(e => {

0 commit comments

Comments
 (0)