Skip to content

Commit a9e2a15

Browse files
fmeumcopybara-github
authored andcommitted
Fix comparator used in ConcurrentArtifactPathTrie
The default comparator for `PathFragment` may sort unrelated paths between a path and a child path, which results in `contains` behaving incorrectly as soon as paths such as `foo/bar` and `foo/bar-baz` are present in the set. This is a bug introduced by #26639. This is fixed by introducing a new `Comparator` for `PathFragment` that sorts `/` lower than any other character. This will also be used in future work on #21378 and is thus placed in `PathFragment`. It may become the default in the future, but not until Blaze has been confirmed to not rely on the order. Closes #26714. PiperOrigin-RevId: 792519419 Change-Id: I8bf8fc6b15cc5bcf0d303057a828fbd644032bab
1 parent 2f3430d commit a9e2a15

File tree

5 files changed

+188
-7
lines changed

5 files changed

+188
-7
lines changed

src/main/java/com/google/devtools/build/lib/remote/RemoteOutputChecker.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,8 @@ public void maybeInvalidateSkyframeValues(MemoizingEvaluator memoizingEvaluator)
368368
*/
369369
private static final class ConcurrentArtifactPathTrie {
370370
// Invariant: no path in this set is a prefix of another path.
371-
private final ConcurrentSkipListSet<PathFragment> paths = new ConcurrentSkipListSet<>();
371+
private final ConcurrentSkipListSet<PathFragment> paths =
372+
new ConcurrentSkipListSet<>(PathFragment.HIERARCHICAL_COMPARATOR);
372373

373374
/**
374375
* Adds the given {@link ActionInput} to the trie.
@@ -389,8 +390,9 @@ void add(ActionInput input) {
389390

390391
/** Checks whether the given {@link PathFragment} is contained in an artifact in the trie. */
391392
boolean contains(PathFragment execPath) {
392-
// By the invariant of this set, if a prefix of execPath is present, it must sort right before
393-
// it (or be equal to it).
393+
// By the invariant of this set, there is at most one prefix of execPath in the set. Since the
394+
// comparator sorts all children of a path right after the path itself, if such a prefix
395+
// exists, it must thus sort right before execPath (or be equal to it).
394396
var floorPath = paths.floor(execPath);
395397
return floorPath != null && execPath.startsWith(floorPath);
396398
}

src/main/java/com/google/devtools/build/lib/vfs/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ java_library(
4646
"//src/main/java/com/google/devtools/build/lib/actions:commandline_item",
4747
"//src/main/java/com/google/devtools/build/lib/skyframe/serialization",
4848
"//src/main/java/com/google/devtools/build/lib/skyframe/serialization/autocodec:serialization-constant",
49+
"//src/main/java/com/google/devtools/build/lib/unsafe:string",
4950
"//src/main/java/com/google/devtools/build/lib/util:filetype",
5051
"//src/main/java/com/google/devtools/build/lib/util:os",
5152
"//third_party:error_prone_annotations",

src/main/java/com/google/devtools/build/lib/vfs/PathFragment.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@
2222
import com.google.devtools.build.lib.skyframe.serialization.LeafSerializationContext;
2323
import com.google.devtools.build.lib.skyframe.serialization.SerializationException;
2424
import com.google.devtools.build.lib.skyframe.serialization.autocodec.SerializationConstant;
25+
import com.google.devtools.build.lib.unsafe.StringUnsafe;
2526
import com.google.devtools.build.lib.util.FileType;
2627
import com.google.errorprone.annotations.Immutable;
2728
import com.google.protobuf.CodedInputStream;
2829
import com.google.protobuf.CodedOutputStream;
2930
import java.io.IOException;
31+
import java.util.Arrays;
32+
import java.util.Comparator;
3033
import java.util.function.UnaryOperator;
3134
import javax.annotation.Nullable;
3235

@@ -59,6 +62,56 @@ public abstract sealed class PathFragment
5962
public static final PathFragment EMPTY_FRAGMENT = new RelativePathFragment("");
6063

6164
public static final char SEPARATOR_CHAR = '/';
65+
66+
/**
67+
* Compares two path fragments lexicographically as sequences of case-sensitive path segments. The
68+
* relative ordering of relative and absolute paths is unspecified.
69+
*
70+
* <p>The ordering imposed by this comparator differs from that of {@link
71+
* #compareTo(PathFragment)} as it sorts {@code foo/bar-baz/quz} after {@code foo/bar/quz} - it
72+
* has the property that the children of a path are sorted directly after their parent.
73+
*
74+
* <p>Note that the ordering imposed by this comparator is <em>not</em> consistent with equals if
75+
* applied to paths that differ only in case on Windows. Paths of artifacts in a single build are
76+
* known to not be affected by this as Bazel ensures that there is only a single artifact per
77+
* equivalence class of {@link PathFragment}.
78+
*/
79+
// TODO(bazel-team): Consider making this the default comparator for PathFragment and revisit the
80+
// choice to assume case sensitivity based on the host OS. Windows case sensitivity is
81+
// configurable on a per-directory basis:
82+
// https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity
83+
public static final Comparator<PathFragment> HIERARCHICAL_COMPARATOR =
84+
(p1, p2) -> {
85+
// Bazel's Strings contain raw UTF-8 bytes (see StringEncoding), which can be compared
86+
// byte-by-byte.
87+
var b1 = StringUnsafe.getInternalStringBytes(p1.getPathString());
88+
var b2 = StringUnsafe.getInternalStringBytes(p2.getPathString());
89+
// This is based on String.compareTo for the case of two Latin-1 coders.
90+
int k = Arrays.mismatch(b1, b2);
91+
if (k == -1) {
92+
return 0;
93+
}
94+
if (k >= b1.length) {
95+
// b1 is a prefix of b2.
96+
return -1;
97+
}
98+
if (k >= b2.length) {
99+
// b2 is a prefix of b1.
100+
return 1;
101+
}
102+
byte c1 = b1[k];
103+
byte c2 = b2[k];
104+
if (c1 == '/') {
105+
// Sort a/b/c before a/b-c.
106+
return -1;
107+
}
108+
if (c2 == '/') {
109+
// Sort a/b-c after a/b/c.
110+
return 1;
111+
}
112+
return Byte.compareUnsigned(c1, c2);
113+
};
114+
62115
private static final char ADDITIONAL_SEPARATOR_CHAR = OS.additionalSeparator();
63116

64117
private final String normalizedPath;
@@ -426,6 +479,14 @@ public int hashCode() {
426479
return OS.hash(this.normalizedPath);
427480
}
428481

482+
/**
483+
* Compares this path fragment to another path fragment as normalized strings, possibly ignoring
484+
* casing based on the host OS.
485+
*
486+
* <p>{@code dir/foo, dir/foo-bar/data.txt, dir/foo/data.txt} is sorted according to this method,
487+
* which is not consistent with viewing a path as a sequence of segments. See {@link
488+
* #HIERARCHICAL_COMPARATOR} for an alternative comparator.
489+
*/
429490
@Override
430491
public int compareTo(PathFragment o) {
431492
return OS.compare(this.normalizedPath, o.normalizedPath);
@@ -697,8 +758,8 @@ public String getDriveStr() {
697758
}
698759

699760
/**
700-
* Returns a relative PathFragment created from this absolute PathFragment using the
701-
* same segments and drive letter.
761+
* Returns a relative PathFragment created from this absolute PathFragment using the same segments
762+
* and drive letter.
702763
*/
703764
public PathFragment toRelative() {
704765
Preconditions.checkArgument(isAbsolute());
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2025 The Bazel Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package com.google.devtools.build.lib.remote;
15+
16+
import static com.google.common.truth.Truth.assertThat;
17+
18+
import com.google.common.collect.ImmutableList;
19+
import com.google.devtools.build.lib.actions.ArtifactRoot;
20+
import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
21+
import com.google.devtools.build.lib.remote.options.RemoteOutputsMode;
22+
import com.google.devtools.build.lib.vfs.DigestHashFunction;
23+
import com.google.devtools.build.lib.vfs.FileSystem;
24+
import com.google.devtools.build.lib.vfs.PathFragment;
25+
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
26+
import org.junit.Test;
27+
import org.junit.runner.RunWith;
28+
import org.junit.runners.JUnit4;
29+
30+
/** Tests for {@link RemoteOutputChecker} */
31+
@RunWith(JUnit4.class)
32+
public class RemoteOutputCheckerTest {
33+
private final RemoteOutputChecker remoteOutputChecker =
34+
new RemoteOutputChecker("build", RemoteOutputsMode.MINIMAL, ImmutableList.of());
35+
private final FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);
36+
private final ArtifactRoot execRoot =
37+
ArtifactRoot.asDerivedRoot(fs.getPath("/execroot"), ArtifactRoot.RootType.OUTPUT, "out");
38+
39+
@Test
40+
public void testShouldDownloadOutput() {
41+
remoteOutputChecker.addOutputToDownload(
42+
ActionsTestUtil.createTreeArtifactWithGeneratingAction(execRoot, "foo/bar"));
43+
remoteOutputChecker.addOutputToDownload(
44+
ActionsTestUtil.createArtifact(execRoot, "foo/bar-baz"));
45+
assertThat(
46+
remoteOutputChecker.shouldDownloadOutput(PathFragment.create("out/foo/bar-quz"), null))
47+
.isFalse();
48+
assertThat(remoteOutputChecker.shouldDownloadOutput(PathFragment.create("out/foo/bar"), null))
49+
.isTrue();
50+
assertThat(
51+
remoteOutputChecker.shouldDownloadOutput(
52+
PathFragment.create("out/foo/bar/data.txt"), PathFragment.create("out/foo/bar")))
53+
.isTrue();
54+
assertThat(
55+
remoteOutputChecker.shouldDownloadOutput(PathFragment.create("out/foo/bar-baz"), null))
56+
.isTrue();
57+
}
58+
}

src/test/java/com/google/devtools/build/lib/vfs/PathFragmentTest.java

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import static com.google.common.collect.ImmutableList.toImmutableList;
1717
import static com.google.common.truth.Truth.assertThat;
1818
import static com.google.devtools.build.lib.vfs.PathFragment.EMPTY_FRAGMENT;
19+
import static com.google.devtools.build.lib.vfs.PathFragment.HIERARCHICAL_COMPARATOR;
1920
import static com.google.devtools.build.lib.vfs.PathFragment.create;
2021
import static org.junit.Assert.assertThrows;
2122

@@ -497,7 +498,7 @@ private static ImmutableSet<PathFragment> toPathsSet(String... strs) {
497498

498499
@Test
499500
public void testCompareTo() {
500-
List<String> pathStrs =
501+
ImmutableList<String> pathStrs =
501502
ImmutableList.of(
502503
"",
503504
"/",
@@ -550,6 +551,64 @@ public void testCompareTo() {
550551
assertThat(paths).isEqualTo(expectedOrder);
551552
}
552553

554+
@Test
555+
public void testHierarchicalComparator() {
556+
List<String> pathStrs =
557+
ImmutableList.of(
558+
"",
559+
"/",
560+
"foo",
561+
"/foo",
562+
"foo/bar",
563+
"foo.bar",
564+
"foo/bar.baz",
565+
"foo/bar/baz",
566+
"foo/barfile",
567+
"foo/Bar",
568+
"Foo/bar");
569+
List<PathFragment> paths = toPaths(pathStrs);
570+
// First test that compareTo is self-consistent.
571+
for (PathFragment x : paths) {
572+
for (PathFragment y : paths) {
573+
for (PathFragment z : paths) {
574+
// Anti-symmetry
575+
assertThat(-1 * Integer.signum(HIERARCHICAL_COMPARATOR.compare(y, x)))
576+
.isEqualTo(Integer.signum(HIERARCHICAL_COMPARATOR.compare(x, y)));
577+
// Transitivity
578+
if (HIERARCHICAL_COMPARATOR.compare(x, y) > 0
579+
&& HIERARCHICAL_COMPARATOR.compare(y, z) > 0) {
580+
assertThat(HIERARCHICAL_COMPARATOR.compare(x, z)).isGreaterThan(0);
581+
}
582+
// "Substitutability"
583+
if (HIERARCHICAL_COMPARATOR.compare(x, y) == 0) {
584+
assertThat(Integer.signum(HIERARCHICAL_COMPARATOR.compare(y, z)))
585+
.isEqualTo(Integer.signum(HIERARCHICAL_COMPARATOR.compare(x, z)));
586+
}
587+
// Consistency with equals
588+
assertThat(x.equals(y)).isEqualTo(HIERARCHICAL_COMPARATOR.compare(x, y) == 0);
589+
}
590+
}
591+
}
592+
// Now test that compareTo does what we expect. The exact ordering here doesn't matter much.
593+
Collections.shuffle(paths);
594+
paths.sort(HIERARCHICAL_COMPARATOR);
595+
List<PathFragment> expectedOrder =
596+
toPaths(
597+
ImmutableList.of(
598+
"",
599+
"/",
600+
"/foo",
601+
"Foo/bar",
602+
"foo",
603+
"foo/Bar",
604+
"foo/bar",
605+
"foo/bar/baz",
606+
"foo/bar.baz",
607+
"foo/barfile",
608+
"foo.bar"));
609+
assertThat(paths).isEqualTo(expectedOrder);
610+
}
611+
553612
@Test
554613
public void testGetSafePathString() {
555614
assertThat(create("/").getSafePathString()).isEqualTo("/");
@@ -624,7 +683,7 @@ public void testSerializationSimple() throws Exception {
624683
@Test
625684
public void testSerializationAbsolute() throws Exception {
626685
checkSerialization("/foo");
627-
}
686+
}
628687

629688
@Test
630689
public void testSerializationNested() throws Exception {

0 commit comments

Comments
 (0)