@@ -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+
50104async 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.
336390async 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
348394function 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