From 1d8df050c9ed1c4713ffb787d5ff30f110e68ace Mon Sep 17 00:00:00 2001 From: Jeffrey Wall <265409180+jeffwall-curlewlabs@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:46:35 -0700 Subject: [PATCH] Watch linked directories through their links Bazel records a watched path that resolves to a directory as only that: a directory. A link moved from one directory to another therefore looks unchanged, and the key went stale in two places: - A search path directory named by a link was listed, and its entries were watched, under the resolved path. Pointing the link at other tools changed nothing Bazel watched, so the key kept the old tools, in a running server and in a new one. - The xcode-select selection is a link to the developer directory. Its watch recorded only "directory", and the version file watched was the old selection's, so selecting another Xcode left the key on the old one. Search path listings and entries are now watched through the directory as named, so they follow the link. The selection is watched through usr/bin/xcodebuild under it, whose bytes differ between Xcode builds and which the Command Line Tools lack. Bazel still cannot see a link moved to a copy with the same names and bytes; the README says so. What the key digests is unchanged, so every host keeps its key. Only watch paths move: on a Linux host where /bin links to usr/bin, the same files are now watched through /bin. tests/check_platform.py now names its scratch tools through a link and moves the link in a running server; the previous host_key.py reuses the old result at that step, and this one executes. --- README.md | 7 +++++++ host_key.py | 14 +++++++++++--- tests/check_platform.py | 16 +++++++++++++++- tests/host_key_test.py | 24 ++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9875f81..de0b363 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,13 @@ Bazel reads each watched file in full when a new server first uses the repository. Leaving the system volume out spares that check a read of every system tool, which on a freshly booted host can take tens of seconds. +Bazel records a watched link to a directory only as a directory, not where it +points, so the rule watches through such links instead: a search path directory +named by a link, and the `xcode-select` selection, through its `xcodebuild`. +Pointing one at different tools recomputes the key. Pointing one at a copy with +the same names and bytes does not, and the key keeps naming the old path until +something else recomputes it; the tools that run are the same. + ## Comparing two machines The key and every field it digests are in `key.json`: diff --git a/host_key.py b/host_key.py index 9d0f788..b0ff4a0 100755 --- a/host_key.py +++ b/host_key.py @@ -173,8 +173,12 @@ def search_path_identity( digest.update(f"{directory}={resolved}\n".encode()) if directory != resolved: watches.file(directory) - if not on_system_volume(resolved, system_volume): - watches.listing(resolved) + # Watches go through the directory as named. Bazel records a watched + # link to a directory only as a directory, so a watch on the resolved + # path would never see the link moved; through the link, it reaches + # whatever the link points at now. + if not on_system_volume(directory, system_volume): + watches.listing(directory) if resolved in listed: continue listed.add(resolved) @@ -182,7 +186,7 @@ def search_path_identity( digest.update(b"absent\n") continue # PATH search looks at direct entries only, never into subdirectories. - for entry in sorted(resolved.iterdir()): + for entry in sorted(directory.iterdir()): identity = entry_identity(entry, digests, watches, system_volume) digest.update(f"{identity}\n".encode()) return digest.hexdigest() @@ -212,6 +216,10 @@ def developer_tools_version(developer_dir: Path, watches: Watches) -> str: def macos_fields(watches: Watches) -> dict[str, object]: watches.file(MACOS_VERSION) watches.file(XCODE_SELECTION) + # The selection is a link to a directory, which Bazel records only as a + # directory. Moving it is seen through this file instead: its bytes differ + # from one Xcode build to the next, and the Command Line Tools have none. + watches.file(XCODE_SELECTION / "usr" / "bin" / "xcodebuild") try: developer_dir = command_output("/usr/bin/xcode-select", "--print-path") except subprocess.CalledProcessError: diff --git a/tests/check_platform.py b/tests/check_platform.py index eb93c2a..7edcd1d 100755 --- a/tests/check_platform.py +++ b/tests/check_platform.py @@ -154,8 +154,10 @@ def expect(step: str, stderr: str, wanted: str) -> None: def check_watched_changes(bazel: str, module_root: Path, scratch: Path) -> None: + # The search path names the tools through a link to their directory. tools = scratch / "tools" - tools.mkdir() + (scratch / "tools-one").mkdir() + tools.symlink_to("tools-one") tool = tools / "tool" tool.write_bytes(b"one") prefix = scratch / "brew" @@ -195,6 +197,18 @@ def check_watched_changes(bazel: str, module_root: Path, scratch: Path) -> None: tool.write_bytes(b"one") write_sbom(keg, BOTTLE) expect("original tools restored", consumer.build(second), "disk cache hit") + + # Bazel records a watched link to a directory only as a directory, so + # only a watch through the link sees it pointed at other tools. + (scratch / "tools-two").mkdir() + (scratch / "tools-two" / "tool").write_bytes(b"other") + tools.unlink() + tools.symlink_to("tools-two") + expect( + "tool directory link moved, running server", + consumer.build(second), + "executed", + ) finally: consumer.shutdown() diff --git a/tests/host_key_test.py b/tests/host_key_test.py index 0ae71e7..16806c8 100644 --- a/tests/host_key_test.py +++ b/tests/host_key_test.py @@ -172,6 +172,18 @@ def test_retargeted_link_rekeys(self) -> None: link.symlink_to("second") self.assertNotEqual(before, self.identity()) + def test_linked_directory_is_watched_through_its_link(self) -> None: + # Bazel records a watched link to a directory only as a directory, so + # watches under the resolved path would never see the link moved. + (self.directory / "one").mkdir() + (self.directory / "one" / "tool").write_bytes(b"one") + linked = self.directory / "tools" + linked.symlink_to("one") + watches = host_key.Watches() + host_key.search_path_identity([linked], watches, SYSTEM_VOLUME) + self.assertIn(str(linked / "tool"), watches.files) + self.assertIn(str(linked), watches.directories) + @unittest.skipIf(os.geteuid() == 0, "root reads every file") def test_unreadable_file_ignores_timestamps(self) -> None: # An inode or a timestamp would differ between hosts holding the same @@ -235,5 +247,17 @@ def test_path_through_a_movable_directory_link_is_not_covered(self) -> None: self.assertFalse(covered(self.directory / "bin" / "sh", SYSTEM_VOLUME)) +@unittest.skipUnless(sys.platform == "darwin", "only macOS selects an Xcode") +class XcodeSelectionTest(unittest.TestCase): + def test_selection_is_watched_through_its_link(self) -> None: + # xcode-select moves a link to a directory, which Bazel records only as + # a directory; without a file watched through it, a newly selected + # Xcode would keep the old one's key. + watches = host_key.Watches() + host_key.macos_fields(watches) + through = host_key.XCODE_SELECTION / "usr" / "bin" / "xcodebuild" + self.assertIn(str(through), watches.files) + + if __name__ == "__main__": unittest.main()