React when Bazel/Blaze prefix changes

Previously if no Bazel/Blaze symlinks were present, we defaulted to the
`bazel-*` ones and never checked if that assumption was accurate. This
change starts watching the symlinks and recreates the analysis contexts
if a symlink gets added. This way we'll run the `BazelWorkspace` code
again and use the correct paths.

This is quite important since `bazel/blaze clean` does remove the
symlinks so if the analysis server starts at such a point, we would
never find any generated files if the workspaced turned out to be a
Blaze one.

Bug: http://b/175087705
Change-Id: If7bb21c7d69a3092832c18004691d56949e5af54
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/197540
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
Commit-Queue: Michal Terepeta <michalt@google.com>
diff --git a/pkg/analysis_server/lib/src/context_manager.dart b/pkg/analysis_server/lib/src/context_manager.dart
index 7b6cb5f..7292d9a 100644
--- a/pkg/analysis_server/lib/src/context_manager.dart
+++ b/pkg/analysis_server/lib/src/context_manager.dart
@@ -460,7 +460,9 @@
       for (var path in watched.paths) {
         bazelWatcherService!.stopWatching(watched.workspace, path);
       }
+      _stopWatchingBazelBinPaths(watched);
     }
+    bazelSearchSubscriptions.remove(rootFolder)?.cancel();
     driverMap.remove(rootFolder);
   }
 
@@ -474,6 +476,11 @@
     }
   }
 
+  List<String> _getPossibelBazelBinPaths(_BazelWatchedFiles watched) => [
+        pathContext.join(watched.workspace, 'bazel-bin'),
+        pathContext.join(watched.workspace, 'blaze-bin'),
+      ];
+
   /// Establishes watch(es) for the Bazel generated files provided in
   /// [notification].
   ///
@@ -493,10 +500,23 @@
   }
 
   /// Notifies the drivers that a generated Bazel file has changed.
-  void _handleBazelWatchEvents(List<WatchEvent> events) {
+  void _handleBazelWatchEvents(List<WatchEvent> allEvents) {
+    // First check if we have any changes to the bazel-*/blaze-* paths.  If
+    // we do, we'll simply recreate all contexts to make sure that we follow the
+    // correct paths.
+    var bazelSymlinkPaths = bazelWatchedPathsPerFolder.values
+        .expand((watched) => _getPossibelBazelBinPaths(watched))
+        .toSet();
+    if (allEvents.any((event) => bazelSymlinkPaths.contains(event.path))) {
+      refresh();
+      return;
+    }
+
+    var fileEvents =
+        allEvents.where((event) => !bazelSymlinkPaths.contains(event.path));
     for (var driver in driverMap.values) {
       var needsUriReset = false;
-      for (var event in events) {
+      for (var event in fileEvents) {
         if (event.type == ChangeType.ADD) {
           driver.addFile(event.path);
           needsUriReset = true;
@@ -599,6 +619,28 @@
         existingExcludedSet.containsAll(excludedPaths);
   }
 
+  /// Starts watching for the `bazel-bin` and `blaze-bin` symlinks.
+  ///
+  /// This is important since these symlinks might not be present when the
+  /// server starts up, in which case `BazelWorkspace` assumes by default the
+  /// Bazel ones.  So we want to detect if the symlinks get created to reset
+  /// everything and repeat the search for the folders.
+  void _startWatchingBazelBinPaths(_BazelWatchedFiles watched) {
+    var watcherService = bazelWatcherService;
+    if (watcherService == null) return;
+    var paths = _getPossibelBazelBinPaths(watched);
+    watcherService.startWatching(
+        watched.workspace, BazelSearchInfo(paths[0], paths));
+  }
+
+  /// Stops watching for the `bazel-bin` and `blaze-bin` symlinks.
+  void _stopWatchingBazelBinPaths(_BazelWatchedFiles watched) {
+    var watcherService = bazelWatcherService;
+    if (watcherService == null) return;
+    var paths = _getPossibelBazelBinPaths(watched);
+    watcherService.stopWatching(watched.workspace, paths[0]);
+  }
+
   /// Listens to files generated by Bazel that were found or searched for.
   ///
   /// This is handled specially because the files are outside the package
@@ -607,13 +649,19 @@
   /// Does nothing if the [driver] is not in a Bazel workspace.
   void _watchBazelFilesIfNeeded(Folder folder, AnalysisDriver analysisDriver) {
     if (!experimentalEnableBazelWatching) return;
+    var watcherService = bazelWatcherService;
+    if (watcherService == null) return;
+
     var workspace = analysisDriver.analysisContext?.contextRoot.workspace;
     if (workspace is BazelWorkspace &&
         !bazelSearchSubscriptions.containsKey(folder)) {
-      var searchSubscription = workspace.bazelCandidateFiles.listen(
+      bazelSearchSubscriptions[folder] = workspace.bazelCandidateFiles.listen(
           (notification) =>
               _handleBazelSearchInfo(folder, workspace.root, notification));
-      bazelSearchSubscriptions[folder] = searchSubscription;
+
+      var watched = _BazelWatchedFiles(workspace.root);
+      bazelWatchedPathsPerFolder[folder] = watched;
+      _startWatchingBazelBinPaths(watched);
     }
   }
 }
diff --git a/pkg/analysis_server/test/integration/server/bazel_changes_test.dart b/pkg/analysis_server/test/integration/server/bazel_changes_test.dart
index f904df3..da935af 100644
--- a/pkg/analysis_server/test/integration/server/bazel_changes_test.dart
+++ b/pkg/analysis_server/test/integration/server/bazel_changes_test.dart
@@ -35,9 +35,9 @@
   late String bazelRoot;
   late String tmpPath;
   late String workspacePath;
-  late String bazelOutPath;
-  late String bazelBinPath;
-  late String bazelGenfilesPath;
+  late String bazelOrBlazeOutPath;
+  late String bazelOrBlazeBinPath;
+  late String bazelOrBlazeGenfilesPath;
   late Directory oldSourceDirectory;
 
   String inTmpDir(String relative) =>
@@ -61,21 +61,19 @@
     sourceDirectory = Directory(inWorkspace('third_party/dart/project'));
     sourceDirectory.createSync(recursive: true);
 
-    bazelRoot = inTmpDir('bazel_root');
+    bazelRoot = inTmpDir('bazel_or_blaze_root');
     Directory(bazelRoot).createSync(recursive: true);
 
-    bazelOutPath = '$bazelRoot/execroot/bazel_workspace/bazel-out';
-    bazelBinPath = '$bazelRoot/execroot/bazel_workspace/bazel-out/bin';
-    bazelGenfilesPath =
-        '$bazelRoot/execroot/bazel_workspace/bazel-out/genfiles';
+    bazelOrBlazeOutPath =
+        '$bazelRoot/execroot/bazel_or_blaze_workspace/bazel_or_blaze-out';
+    bazelOrBlazeBinPath =
+        '$bazelRoot/execroot/bazel_or_blaze_workspace/bazel_or_blaze-out/bin';
+    bazelOrBlazeGenfilesPath =
+        '$bazelRoot/execroot/bazel_or_blaze_workspace/bazel_or_blaze-out/genfiles';
 
-    Directory(inTmpDir(bazelOutPath)).createSync(recursive: true);
-    Directory(inTmpDir(bazelBinPath)).createSync(recursive: true);
-    Directory(inTmpDir(bazelGenfilesPath)).createSync(recursive: true);
-
-    Link(inWorkspace('bazel-out')).createSync(bazelOutPath);
-    Link(inWorkspace('bazel-bin')).createSync(bazelBinPath);
-    Link(inWorkspace('bazel-genfiles')).createSync(bazelGenfilesPath);
+    Directory(inTmpDir(bazelOrBlazeOutPath)).createSync(recursive: true);
+    Directory(inTmpDir(bazelOrBlazeBinPath)).createSync(recursive: true);
+    Directory(inTmpDir(bazelOrBlazeGenfilesPath)).createSync(recursive: true);
 
     commandLogPath = inTmpDir('$bazelRoot/command.log');
   }
@@ -91,6 +89,17 @@
   // not run from a snapshot.
   @TestTimeout(Timeout.factor(2))
   Future<void> test_bazelChanges() async {
+    await testChangesImpl('bazel');
+  }
+
+  // Add a bit more time -- the isolate take a while to start when the test is
+  // not run from a snapshot.
+  @TestTimeout(Timeout.factor(2))
+  Future<void> test_blazeChanges() async {
+    await testChangesImpl('blaze');
+  }
+
+  Future<void> testChangesImpl(String prefix) async {
     var testFile = inWorkspace('${sourceDirectory.path}/lib/test.dart');
 
     var errors = <AnalysisError>[];
@@ -126,8 +135,9 @@
 
     await resetCompleterAndErrors();
     var generatedFilePath = inWorkspace(
-        '$bazelGenfilesPath/third_party/dart/project/lib/generated.dart');
+        '$bazelOrBlazeGenfilesPath/third_party/dart/project/lib/generated.dart');
     writeFile(generatedFilePath, 'my_fun() {}');
+    _createSymlinks(prefix);
     writeFile(commandLogPath, 'Build completed successfully');
 
     await processedNotification.future;
@@ -145,6 +155,7 @@
     // Now delete the file completely.
     await resetCompleterAndErrors();
     File(generatedFilePath).deleteSync();
+    _deleteSymlinks(prefix);
     writeFile(commandLogPath, 'Build did NOT complete successfully');
 
     await processedNotification.future;
@@ -153,9 +164,22 @@
     // And finally re-add the correct file -- errors should go away once again.
     await resetCompleterAndErrors();
     writeFile(generatedFilePath, 'my_fun() {}');
+    _createSymlinks(prefix);
     writeFile(commandLogPath, 'Build completed successfully');
 
     await processedNotification.future;
     expect(errors, isEmpty);
   }
+
+  void _createSymlinks(String prefix) {
+    Link(inWorkspace('$prefix-out')).createSync(bazelOrBlazeOutPath);
+    Link(inWorkspace('$prefix-bin')).createSync(bazelOrBlazeBinPath);
+    Link(inWorkspace('$prefix-genfiles')).createSync(bazelOrBlazeGenfilesPath);
+  }
+
+  void _deleteSymlinks(String prefix) {
+    Link(inWorkspace('$prefix-out')).deleteSync();
+    Link(inWorkspace('$prefix-bin')).deleteSync();
+    Link(inWorkspace('$prefix-genfiles')).deleteSync();
+  }
 }
diff --git a/pkg/analyzer/lib/src/workspace/bazel_watcher.dart b/pkg/analyzer/lib/src/workspace/bazel_watcher.dart
index 50ceb0c..05ce4a6 100644
--- a/pkg/analyzer/lib/src/workspace/bazel_watcher.dart
+++ b/pkg/analyzer/lib/src/workspace/bazel_watcher.dart
@@ -110,10 +110,21 @@
   /// exist.
   _TimestampAndLength? _pollOne(String path) {
     try {
-      var file = _provider.getFile(path);
-      var timestamp = file.modificationStamp;
-      var length = file.lengthSync;
-      return _TimestampAndLength(timestamp, length);
+      // This might seem a bit convoluted but is necessary to deal with a
+      // symlink to a directory (e.g., `bazel-bin`).
+      var resource = _provider.getResource(
+          _provider.getResource(path).resolveSymbolicLinksSync().path);
+      if (resource is File) {
+        var timestamp = resource.modificationStamp;
+        var length = resource.lengthSync;
+        return _TimestampAndLength(timestamp, length);
+      } else if (resource is Folder) {
+        // `ResourceProvider` doesn't currently support getting timestamps of a
+        // folder, so we use a dummy value here. But it's still useful: this
+        // will correctly generate `ADD` or `REMOVE` events (we'll be just
+        // unable to generate any `CHANGE` events).
+        return _TimestampAndLength(0, 0);
+      }
     } on FileSystemException catch (_) {
       // File doesn't exist, so return null.
       return null;
diff --git a/pkg/analyzer/test/src/workspace/bazel_watcher_test.dart b/pkg/analyzer/test/src/workspace/bazel_watcher_test.dart
index 16df3e4..84faee8 100644
--- a/pkg/analyzer/test/src/workspace/bazel_watcher_test.dart
+++ b/pkg/analyzer/test/src/workspace/bazel_watcher_test.dart
@@ -233,6 +233,47 @@
     recPort.close();
   }
 
+  void test_bazelFileWatcherWithFolder() async {
+    _addResources([
+      '/workspace/WORKSPACE',
+    ]);
+
+    // The `_addResources`/`_deleteResources` functions recognize a folder by a
+    // trailing `/`, but everywhere else we need to use normalized paths.
+    var addFolder = (path) => _addResources(['$path/']);
+    var deleteFolder = (path) => _deleteResources(['$path/']);
+
+    var candidates = [
+      convertPath('/workspace/bazel-out'),
+      convertPath('/workspace/blaze-out'),
+    ];
+    var watcher = BazelFilePoller(resourceProvider, candidates);
+
+    // First do some tests with the first candidate path.
+    addFolder(candidates[0]);
+    var event = watcher.poll()!;
+
+    expect(event.type, ChangeType.ADD);
+    expect(event.path, candidates[0]);
+
+    deleteFolder(candidates[0]);
+    event = watcher.poll()!;
+
+    expect(event.type, ChangeType.REMOVE);
+    expect(event.path, candidates[0]);
+
+    // Now check that if we add the *second* candidate, we'll get the
+    // notification for it.
+    addFolder(candidates[1]);
+    event = watcher.poll()!;
+
+    expect(event.type, ChangeType.ADD);
+    expect(event.path, candidates[1]);
+
+    // Next poll should be `null` since there were no changes.
+    expect(watcher.poll(), isNull);
+  }
+
   /// Create new files and directories from [paths].
   void _addResources(List<String> paths) {
     for (String path in paths) {