diff --git a/src/test/py/bazel/bzlmod/repo_contents_cache_test.py b/src/test/py/bazel/bzlmod/repo_contents_cache_test.py index 204f676710c940..93d015b257387f 100644 --- a/src/test/py/bazel/bzlmod/repo_contents_cache_test.py +++ b/src/test/py/bazel/bzlmod/repo_contents_cache_test.py @@ -101,6 +101,23 @@ def assertRepoNotCached(self, repo_dir): except OSError: pass # Not a symlink means not cached, which is expected + def _rmtreeWithRetry(self, path, attempts=10, delay=0.5): + """Like shutil.rmtree, but retries on Windows file-lock errors. + + `bazel shutdown` returns before the OS has finished releasing file + handles in the output base, so a follow-up rmtree may transiently hit + PermissionError on Windows. We retry a few times before giving up. + """ + last_err = None + for _ in range(attempts): + try: + shutil.rmtree(path) + return + except OSError as e: + last_err = e + time.sleep(delay) + raise last_err + def testCachedAfterCleanExpunge(self): self.ScratchFile( 'MODULE.bazel', @@ -763,6 +780,191 @@ def testCachedRepoWithSymlinks_symlinksEnabledOnWindows(self): # by the replanting logic, so cross-repo symlinks prevent caching. self.doTestCachedRepoWithSymlinks(expect_cross_repo_cached=False) + def doTestRepoWithWorkspaceSymlinkSurvivesWorkspaceMove(self): + """Regression test for https://github.com/bazelbuild/bazel/issues/29515. + + A reproducible repo that symlinks a file from the main workspace has those + symlinks replanted to relative paths through `../_main/...`. On Windows + (which resolves symlinks logically) those targets dangle when the repo is + read through the execroot unless a corresponding `_main` symlink exists + under `/external/` too. After the fix, the build must succeed + even when the cached repo is reused from a completely different workspace + (with the original workspace and output base nuked between builds). + + The workspace is set up under a subdirectory of `_test_cwd` (rather than + in `_test_cwd` itself) so that we can delete it without breaking the + `tempfile.TemporaryFile(dir=self._test_cwd)` calls inside `RunBazel`. + """ + # Files that make up the workspace. Each file is written to both the + # original and the new workspace; the original is later deleted entirely. + ws_files = { + 'payload/BUILD.bazel': [], + 'payload/hello.txt': ['Hello from workspace!'], + 'MODULE.bazel': [ + 'ext = use_extension("extension.bzl", "ext")', + 'use_repo(ext, "linked", "same_repo_only")', + ], + 'extension.bzl': [ + 'def _linked_impl(ctx):', + ' ctx.file("REPO.bazel")', + ' # Workspace symlink: replantSymlinks rewrites this as', + ' # ../_main/payload/hello.txt before the repo is (potentially)', + ' # placed in the contents cache.', + ' ctx.symlink(', + ' ctx.path(Label("@@//payload:hello.txt")),', + ' "linked_hello.txt",', + ' )', + ' ctx.file("BUILD", "exports_files([\\"linked_hello.txt\\"])")', + ' return ctx.repo_metadata(reproducible = True)', + 'linked = repository_rule(implementation = _linked_impl)', + '', + 'def _same_repo_only_impl(ctx):', + ' ctx.file("REPO.bazel")', + ' ctx.file("data", "Hello from same-repo!")', + ' # Same-repo absolute symlink: replanted to a portable relative', + ' # path, so this repo is eligible for the contents cache.', + ' ctx.symlink(ctx.path("data"), "sym_same_repo")', + ' ctx.file("BUILD", "exports_files([\\"sym_same_repo\\"])")', + ' return ctx.repo_metadata(reproducible = True)', + 'same_repo_only = repository_rule(', + ' implementation = _same_repo_only_impl,', + ')', + '', + 'def _ext_impl(ctx):', + ' linked(name = "linked")', + ' same_repo_only(name = "same_repo_only")', + 'ext = module_extension(implementation = _ext_impl)', + ], + 'BUILD': [ + 'genrule(', + ' name = "use_linked",', + ' srcs = [', + ' "@linked//:linked_hello.txt",', + ' "@same_repo_only//:sym_same_repo",', + ' ],', + ' outs = ["output.txt"],', + # cmd_bat uses native cmd.exe with execroot-relative Windows paths + # to match the original reproducer; on Windows the bug only + # manifests through that resolver, not through MSYS bash/cat which + # dereferences symlinks differently. + ' cmd = "cat $(execpath @linked//:linked_hello.txt) ' + '$(execpath @same_repo_only//:sym_same_repo) > $@",', + ' cmd_bat = "type $(execpath @linked//:linked_hello.txt) ' + '$(execpath @same_repo_only//:sym_same_repo) > $@",', + ')', + ], + } + for rel_path, lines in ws_files.items(): + self.ScratchFile('original_ws/' + rel_path, lines) + + # First build in the original workspace: fetches and (where eligible) + # caches the repos. Use an explicit output base so we can delete it. + original_ws = os.path.join(self._test_cwd, 'original_ws') + original_output_base = tempfile.mkdtemp(dir=self._tests_root) + self.RunBazel( + [ + f'--output_base={original_output_base}', + 'build', + '//:use_linked', + ], + cwd=original_ws, + ) + output = os.path.join(original_ws, 'bazel-bin/output.txt') + self.AssertFileContentContains(output, 'Hello from workspace!') + self.AssertFileContentContains(output, 'Hello from same-repo!') + + # Let Bazel itself delete the output base via `clean --expunge` (which + # also shuts the server down). On Windows direct rmtree of a still-warm + # output base races with the OS releasing file handles, but the Bazel + # client knows how to wait for its own server to fully exit. + self.RunBazel( + [f'--output_base={original_output_base}', 'clean', '--expunge'], + cwd=original_ws, + ) + + # Set up a fresh workspace from scratch (no copy from the original to + # ensure the original's bazel-* convenience symlinks don't leak in). + new_ws = os.path.join(self._test_cwd, 'new_ws') + for rel_path, lines in ws_files.items(): + self.ScratchFile('new_ws/' + rel_path, lines) + + # Nuke the original workspace. The original output base is already gone + # (clean --expunge above). After this point only the contents cache and + # the new workspace remain on disk. + shutil.rmtree(original_ws) + if os.path.exists(original_output_base): + # clean --expunge may leave behind an empty shell on some platforms; + # remove what's left, retrying a few times for Windows file-handle lag. + self._rmtreeWithRetry(original_output_base) + + # Build from the new workspace with a fresh output base. The + # `same_repo_only` repo must come straight from the cache; the + # `linked` repo with the workspace symlink must work (either from the + # cache or refetched) and resolve correctly against the new workspace. + new_output_base = tempfile.mkdtemp(dir=self._tests_root) + self.RunBazel( + [ + f'--output_base={new_output_base}', + 'build', + '//:use_linked', + ], + cwd=new_ws, + ) + output = os.path.join(new_ws, 'bazel-bin/output.txt') + self.AssertFileContentContains(output, 'Hello from workspace!') + self.AssertFileContentContains(output, 'Hello from same-repo!') + + # On platforms that natively support symbolic links (always on Unix; on + # Windows only with Developer Mode), the fix materializes a `_main` + # symlink under `/external/` pointing at the workspace, so the + # relative `../_main/...` targets that replantSymlinks produces resolve + # correctly when read through the execroot. Verify it's there. + # + # The behavioral check above (the build itself) only catches the bug on + # Windows with real symlinks (which Bazel CI Windows runners don't have), + # so this structural assertion is what actually guards the fix on the + # Linux/macOS test runs. + _, stdout, _ = self.RunBazel( + [f'--output_base={new_output_base}', 'info', 'execution_root'], + cwd=new_ws, + ) + execroot = stdout[0].strip() + workspace_link = os.path.join(execroot, 'external', '_main') + if not self.IsWindows() or self._realSymlinksWork(): + self.assertTrue( + os.path.lexists(workspace_link), + msg=f'expected workspace symlink to be planted at {workspace_link}', + ) + + def _realSymlinksWork(self): + """Best-effort check for whether the OS can create a real symlink here.""" + probe_dir = tempfile.mkdtemp(dir=self._tests_root) + target = os.path.join(probe_dir, 'target') + os.mkdir(target) + link = os.path.join(probe_dir, 'link') + try: + os.symlink(target, link, target_is_directory=True) + return True + except OSError: + return False + + def testRepoWithWorkspaceSymlinkSurvivesWorkspaceMove(self): + self.doTestRepoWithWorkspaceSymlinkSurvivesWorkspaceMove() + + def testRepoWithWorkspaceSymlinkSurvivesWorkspaceMove_symlinksEnabledOnWindows( + self, + ): + if not self.IsWindows(): + self.skipTest('This test is only relevant on Windows') + self.ScratchFile( + '.bazelrc', + [ + 'startup --windows_enable_symlinks', + ], + mode='a', + ) + self.doTestRepoWithWorkspaceSymlinkSurvivesWorkspaceMove() + if __name__ == '__main__': absltest.main()