Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions src/test/py/bazel/bzlmod/repo_contents_cache_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 `<execroot>/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 `<execroot>/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()
Loading