extensions: Fix a race adding component extensions multiple times

- Make `AddComponentFromDirWithManifestFilename` and
  `FinishAddComponentFromDir` track pending extension loads on the file
  task runner;
- Update `AddChromeOsSpeechSynthesisExtensions` and a couple other
  callers to check `IsPendingAdd` in addition to `Exists`;
- Add unit tests to verify the pending load is tracked as expected;
- Minor clean-ups;

Bug: 442902361
Change-Id: I3deaed45449feddce2e8426a8bb2d1c7c1e46ee9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6916700
Commit-Queue: Xiyuan Xia <xiyuan@chromium.org>
Reviewed-by: James Cook <jamescook@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1511591}
diff --git a/chrome/browser/ash/accessibility/accessibility_extension_loader.cc b/chrome/browser/ash/accessibility/accessibility_extension_loader.cc
index 4761147..1167311 100644
--- a/chrome/browser/ash/accessibility/accessibility_extension_loader.cc
+++ b/chrome/browser/ash/accessibility/accessibility_extension_loader.cc
@@ -49,7 +49,7 @@
   // If the extension was already enabled, but not for this profile, add it
   // to this profile.
   auto* component_loader = extensions::ComponentLoader::Get(browser_context_);
-  if (!component_loader->Exists(extension_id_)) {
+  if (!component_loader->ExistsOrPendingAdd(extension_id_)) {
     LoadExtension(browser_context_, std::move(done_callback));
   }
 }
@@ -109,7 +109,7 @@
   extensions::ComponentLoader::Get(browser_context)
       ->AddComponentFromDirWithManifestFilename(
           extension_path_, extension_id_.c_str(), manifest_filename_,
-          guest_manifest_filename_, std::move(done_cb));
+          guest_manifest_filename_, std::move(done_cb), {});
 }
 
 void AccessibilityExtensionLoader::ReinstallExtensionForKiosk(
diff --git a/chrome/browser/ash/accessibility/accessibility_manager.cc b/chrome/browser/ash/accessibility/accessibility_manager.cc
index 4158472..aaea4ae 100644
--- a/chrome/browser/ash/accessibility/accessibility_manager.cc
+++ b/chrome/browser/ash/accessibility/accessibility_manager.cc
@@ -2319,8 +2319,10 @@
 
   auto* component_loader = extensions::ComponentLoader::Get(profile_);
 
-  if (component_loader->Exists(extension_misc::kEnhancedNetworkTtsExtensionId))
+  if (component_loader->ExistsOrPendingAdd(
+          extension_misc::kEnhancedNetworkTtsExtensionId)) {
     return;
+  }
 
   base::FilePath resources_path;
   if (!base::PathService::Get(chrome::DIR_RESOURCES, &resources_path)) {
@@ -2344,7 +2346,8 @@
       extension_misc::kEnhancedNetworkTtsExtensionId, manifest_filename,
       guest_manifest_filename,
       base::BindOnce(&AccessibilityManager::PostLoadEnhancedNetworkTts,
-                     base::Unretained(this)));
+                     base::Unretained(this)),
+      {});
 }
 
 void AccessibilityManager::UnloadEnhancedNetworkTts() {
diff --git a/chrome/browser/ash/file_manager/file_manager_test_util.cc b/chrome/browser/ash/file_manager/file_manager_test_util.cc
index e001054..31ea191c 100644
--- a/chrome/browser/ash/file_manager/file_manager_test_util.cc
+++ b/chrome/browser/ash/file_manager/file_manager_test_util.cc
@@ -149,7 +149,7 @@
       data_dir.Append("chromeos/file_manager/quickoffice"),
       extension_misc::kQuickOfficeComponentExtensionId,
       extensions::kManifestFilename, extensions::kManifestFilename,
-      run_loop.QuitClosure());
+      run_loop.QuitClosure(), {});
   run_loop.Run();
 #endif
   // AddDefaultComponentExtensions() is normally invoked during
diff --git a/chrome/browser/extensions/component_loader.cc b/chrome/browser/extensions/component_loader.cc
index 44cbe7b..e272ff2a 100644
--- a/chrome/browser/extensions/component_loader.cc
+++ b/chrome/browser/extensions/component_loader.cc
@@ -9,6 +9,7 @@
 #include <string_view>
 
 #include "base/command_line.h"
+#include "base/containers/contains.h"
 #include "base/feature_list.h"
 #include "base/files/file_util.h"
 #include "base/functional/bind.h"
@@ -341,6 +342,12 @@
       break;
     }
   }
+
+#if BUILDFLAG(IS_CHROMEOS)
+  if (IsPendingAdd(id)) {
+    pending_extension_ids_.erase(id);
+  }
+#endif  // BUILDFLAG(IS_CHROMEOS)
 }
 
 bool ComponentLoader::Exists(const ExtensionId& id) const {
@@ -440,7 +447,7 @@
   AddComponentFromDirWithManifestFilename(
       path, extension_misc::kGuestModeTestExtensionId,
       extensions::kManifestFilename, extensions::kManifestFilename,
-      base::RepeatingClosure());
+      /*done_cb=*/{}, /*error_cb=*/{});
 }
 
 void ComponentLoader::AddKeyboardApp() {
@@ -571,7 +578,7 @@
     AddComponentFromDirWithManifestFilename(
         base::FilePath("/usr/share/chromeos-assets/quickoffice"),
         extension_misc::kQuickOfficeComponentExtensionId,
-        extensions::kManifestFilename, extensions::kManifestFilename, {});
+        extensions::kManifestFilename, extensions::kManifestFilename, {}, {});
 #endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)
 #endif  // BUILDFLAG(IS_CHROMEOS)
 
@@ -629,14 +636,27 @@
 }
 
 #if BUILDFLAG(IS_CHROMEOS)
+bool ComponentLoader::IsPendingAdd(const ExtensionId& extension_id) const {
+  return base::Contains(pending_extension_ids_, extension_id);
+}
+
+bool ComponentLoader::ExistsOrPendingAdd(
+    const ExtensionId& extension_id) const {
+  return Exists(extension_id) || IsPendingAdd(extension_id);
+}
+
 void ComponentLoader::AddComponentFromDirWithManifestFilename(
     const base::FilePath& root_directory,
     const ExtensionId& extension_id,
     const base::FilePath::CharType* manifest_file_name,
     const base::FilePath::CharType* guest_manifest_file_name,
-    base::OnceClosure done_cb) {
+    base::OnceClosure done_cb,
+    base::OnceClosure error_cb) {
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
 
+  CHECK(!IsPendingAdd(extension_id));
+  pending_extension_ids_.emplace(extension_id);
+
   const base::FilePath::CharType* manifest_filename =
       IsNormalSession() ? manifest_file_name : guest_manifest_file_name;
 
@@ -646,7 +666,8 @@
                      manifest_filename, true),
       base::BindOnce(&ComponentLoader::FinishAddComponentFromDir,
                      weak_factory_.GetWeakPtr(), root_directory, extension_id,
-                     std::nullopt, std::nullopt, std::move(done_cb)));
+                     std::nullopt, std::nullopt, std::move(done_cb),
+                     std::move(error_cb)));
 }
 
 void ComponentLoader::FinishAddComponentFromDir(
@@ -655,9 +676,23 @@
     const std::optional<std::string>& name_string,
     const std::optional<std::string>& description_string,
     base::OnceClosure done_cb,
+    base::OnceClosure error_cb,
     std::optional<base::Value::Dict> manifest) {
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
+
+  // Extension is removed during loading. Skip adding in this case.
+  if (!IsPendingAdd(extension_id)) {
+    if (error_cb) {
+      std::move(error_cb).Run();
+    }
+    return;
+  }
+  pending_extension_ids_.erase(extension_id);
+
   if (!manifest) {
+    if (error_cb) {
+      std::move(error_cb).Run();
+    }
     return;  // Error already logged.
   }
 
@@ -682,26 +717,11 @@
                                           base::OnceClosure done_cb) {
   AddComponentFromDirWithManifestFilename(
       root_directory, extension_id, extensions::kManifestFilename,
-      extension_misc::kGuestManifestFilename, std::move(done_cb));
-}
-
-void ComponentLoader::AddWithNameAndDescriptionFromDir(
-    const base::FilePath& root_directory,
-    const ExtensionId& extension_id,
-    const std::string& name_string,
-    const std::string& description_string) {
-  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
-  GetExtensionFileTaskRunner()->PostTaskAndReplyWithResult(
-      FROM_HERE,
-      base::BindOnce(&LoadManifestOnFileThread, root_directory,
-                     extensions::kManifestFilename, false),
-      base::BindOnce(&ComponentLoader::FinishAddComponentFromDir,
-                     weak_factory_.GetWeakPtr(), root_directory, extension_id,
-                     name_string, description_string, base::OnceClosure()));
+      extension_misc::kGuestManifestFilename, std::move(done_cb), {});
 }
 
 void ComponentLoader::AddChromeOsSpeechSynthesisExtensions() {
-  if (!Exists(extension_misc::kGoogleSpeechSynthesisExtensionId)) {
+  if (!ExistsOrPendingAdd(extension_misc::kGoogleSpeechSynthesisExtensionId)) {
     AddComponentFromDir(
         ::features::IsAccessibilityManifestV3EnabledForGoogleTts()
             ? base::FilePath(
@@ -715,7 +735,7 @@
             extension_misc::kGoogleSpeechSynthesisExtensionId));
   }
 
-  if (!Exists(extension_misc::kEspeakSpeechSynthesisExtensionId)) {
+  if (!ExistsOrPendingAdd(extension_misc::kEspeakSpeechSynthesisExtensionId)) {
     AddComponentFromDir(
         base::FilePath(
             ::features::IsAccessibilityManifestV3EnabledForEspeakNGTts()
diff --git a/chrome/browser/extensions/component_loader.h b/chrome/browser/extensions/component_loader.h
index ab9d2dd..1722823 100644
--- a/chrome/browser/extensions/component_loader.h
+++ b/chrome/browser/extensions/component_loader.h
@@ -120,6 +120,13 @@
   std::vector<ExtensionId> GetRegisteredComponentExtensionsIds() const;
 
 #if BUILDFLAG(IS_CHROMEOS)
+  // Whether the given extension is being loaded in the file task runner.
+  bool IsPendingAdd(const ExtensionId& extension_id) const;
+
+  // Convenience wrapper of `Exists` and `IsPendingAdd` since most callers do
+  // not need to differentiate the two cases.
+  bool ExistsOrPendingAdd(const ExtensionId& extension_id) const;
+
   // Identical to AddComponentFromDir() except allows for the caller to supply
   // the name of the manifest file.
   void AddComponentFromDirWithManifestFilename(
@@ -127,7 +134,8 @@
       const ExtensionId& extension_id,
       const base::FilePath::CharType* manifest_file_name,
       const base::FilePath::CharType* guest_manifest_file_name,
-      base::OnceClosure done_cb);
+      base::OnceClosure done_cb,
+      base::OnceClosure error_cb);
 
   // Add a component extension from a specific directory. Assumes that the
   // extension uses a different manifest file when this is a guest session
@@ -137,15 +145,6 @@
                            const ExtensionId& extension_id,
                            base::OnceClosure done_cb);
 
-  // Add a component extension from a specific directory. Assumes that the
-  // extension's manifest file lives in `root_directory` and its name is
-  // 'manifest.json'. `name_string` and `description_string` are used to
-  // localize component extension's name and description text exclusively.
-  void AddWithNameAndDescriptionFromDir(const base::FilePath& root_directory,
-                                        const ExtensionId& extension_id,
-                                        const std::string& name_string,
-                                        const std::string& description_string);
-
   void AddChromeOsSpeechSynthesisExtensions();
 #endif  // BUILDFLAG(IS_CHROMEOS)
 
@@ -158,6 +157,7 @@
 
  private:
   friend class ComponentLoaderFactory;
+  friend class TtsApiTest;
   FRIEND_TEST_ALL_PREFIXES(ComponentLoaderTest, ParseManifest);
 
   // Information about a registered component extension.
@@ -233,6 +233,7 @@
       const std::optional<std::string>& name_string,
       const std::optional<std::string>& description_string,
       base::OnceClosure done_cb,
+      base::OnceClosure error_cb,
       std::optional<base::Value::Dict> manifest);
 
   // Finishes loading an extension tts engine.
@@ -250,14 +251,17 @@
   raw_ptr<ExtensionSystem, AcrossTasksDanglingUntriaged> extension_system_;
 
   // List of registered component extensions (see mojom::ManifestLocation).
-  typedef std::vector<ComponentExtensionInfo> RegisteredComponentExtensions;
+  using RegisteredComponentExtensions = std::vector<ComponentExtensionInfo>;
   RegisteredComponentExtensions component_extensions_;
 
   bool ignore_allowlist_for_testing_;
 
-  base::WeakPtrFactory<ComponentLoader> weak_factory_{this};
+#if BUILDFLAG(IS_CHROMEOS)
+  // Ids of extensions that are being loaded on file task runner.
+  ExtensionIdSet pending_extension_ids_;
+#endif  // BUILDFLAG(IS_CHROMEOS)
 
-  friend class TtsApiTest;
+  base::WeakPtrFactory<ComponentLoader> weak_factory_{this};
 };
 
 }  // namespace extensions
diff --git a/chrome/browser/extensions/component_loader_unittest.cc b/chrome/browser/extensions/component_loader_unittest.cc
index 60e4a72..2c0303a 100644
--- a/chrome/browser/extensions/component_loader_unittest.cc
+++ b/chrome/browser/extensions/component_loader_unittest.cc
@@ -17,6 +17,7 @@
 #include "base/memory/raw_ptr.h"
 #include "base/one_shot_event.h"
 #include "base/path_service.h"
+#include "base/run_loop.h"
 #include "base/scoped_observation.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/metrics/histogram_tester.h"
@@ -413,6 +414,55 @@
             base::MakeAbsoluteFilePath(shared_data));
 }
 
+// Tests that extensions added via file task runner are tracked during loading.
+TEST_F(ComponentLoaderTest, PendingAdd) {
+  const ExtensionId kExtensionId = "behllobkkfkfnphdnhnkndlbkcpglgmj";
+  base::RunLoop run_loop;
+
+  EXPECT_FALSE(component_loader_->ExistsOrPendingAdd(kExtensionId));
+
+  // Pass `kManifestFilename` for both regular and guest manifest name. This
+  // works around `IsNormalSession` that requires `UserManager` instance to be
+  // considered as regular session.
+  component_loader_->AddComponentFromDirWithManifestFilename(
+      extension_path_, kExtensionId, kManifestFilename, kManifestFilename,
+      /*done_cb=*/run_loop.QuitClosure(), /*error_cb=*/run_loop.QuitClosure());
+  EXPECT_FALSE(component_loader_->Exists(kExtensionId));
+  EXPECT_TRUE(component_loader_->IsPendingAdd(kExtensionId));
+  EXPECT_TRUE(component_loader_->ExistsOrPendingAdd(kExtensionId));
+
+  run_loop.Run();
+  EXPECT_TRUE(component_loader_->Exists(kExtensionId));
+  EXPECT_FALSE(component_loader_->IsPendingAdd(kExtensionId));
+  EXPECT_TRUE(component_loader_->ExistsOrPendingAdd(kExtensionId));
+}
+
+// Tests that removing a pending add extension cancels the loading.
+TEST_F(ComponentLoaderTest, RemovePendingAdd) {
+  const ExtensionId kExtensionId = "behllobkkfkfnphdnhnkndlbkcpglgmj";
+  base::RunLoop run_loop;
+
+  EXPECT_FALSE(component_loader_->ExistsOrPendingAdd(kExtensionId));
+
+  // Pass `kManifestFilename` for both regular and guest manifest name. This
+  // works around `IsNormalSession` that requires `UserManager` instance to be
+  // considered as regular session.
+  component_loader_->AddComponentFromDirWithManifestFilename(
+      extension_path_, kExtensionId, kManifestFilename, kManifestFilename,
+      /*done_cb=*/run_loop.QuitClosure(), /*error_cb=*/run_loop.QuitClosure());
+
+  // Remove when the extension is loading.
+  EXPECT_TRUE(component_loader_->IsPendingAdd(kExtensionId));
+  component_loader_->Remove(kExtensionId);
+
+  run_loop.Run();
+
+  // After loading, the extension is no longer pending and stays unloaded.
+  EXPECT_FALSE(component_loader_->Exists(kExtensionId));
+  EXPECT_FALSE(component_loader_->IsPendingAdd(kExtensionId));
+  EXPECT_FALSE(component_loader_->ExistsOrPendingAdd(kExtensionId));
+}
+
 #endif  // BUILDFLAG(IS_CHROMEOS)
 
 }  // namespace extensions