Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionRace condition in the DOM: Content Processes component
ComponentToolkit
Bug ClassLogic Error
Tracker2049342
Fix commit4e06be5543c2 (firefox) +310/-33
CISA KEVNot listed
CreditedMozilla
Disclosed2026-09-15

Changed Functions

FunctionChangeNotes
UrlClassifierPrefixSetReporter
toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
modified
if
toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
modified
for
toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
modified
mFixedPrefixSet
toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
modified
while
toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
modified
nsUrlClassifierPrefixSet
toolkit/components/url-classifier/VariableLengthPrefixSet.h
modified
VariableLengthPrefixSet
toolkit/components/url-classifier/VariableLengthPrefixSet.h
modified
TestHandleReport
toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
modified
TestFinishReporting
toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
modified
for
toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
modified
NS_NewRunnableFunction
toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
modified

Files Changed

  • toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
  • toolkit/components/url-classifier/VariableLengthPrefixSet.h
  • toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
  • toolkit/components/url-classifier/tests/gtest/moz.build
diff --git a/toolkit/components/url-classifier/VariableLengthPrefixSet.cpp b/toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
index e3e039b7b3a..24ca5819438 100644
--- a/toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
+++ b/toolkit/components/url-classifier/VariableLengthPrefixSet.cpp
@@ -4,11 +4,14 @@
 
 #include "VariableLengthPrefixSet.h"
 #include "nsIInputStream.h"
+#include "nsIMemoryReporter.h"
 #include "nsUrlClassifierPrefixSet.h"
 #include "nsPrintfCString.h"
 #include "mozilla/ScopeExit.h"
 #include "mozilla/EndianUtils.h"
 #include "mozilla/Logging.h"
+#include "mozilla/StaticMutex.h"
+#include "mozilla/StaticPtr.h"
 #include "mozilla/UniquePtr.h"
 #include <algorithm>
 #include <bit>
@@ -37,28 +40,134 @@ void EnsureSorted(T* aArray) {
 }  // namespace
 #endif
 
-NS_IMPL_ISUPPORTS(VariableLengthPrefixSet, nsIMemoryReporter)
+struct TrackedPrefixSet {
+  ThreadSafeWeakPtr<VariableLengthPrefixSet> mWeakRef;
+  nsCString mPath;
+
+  bool operator==(const VariableLengthPrefixSet* aPrefixSet) const {
+    return mWeakRef == aPrefixSet;
+  }
+};
+
+class UrlClassifierPrefixSetReporter final : public nsIMemoryReporter {
+ public:
+  NS_DECL_THREADSAFE_ISUPPORTS
+  NS_DECL_NSIMEMORYREPORTER
+
+  static void Register(VariableLengthPrefixSet* aSet, const nsACString& aName);
+  static void PurgeStale(VariableLengthPrefixSet* aSet);
+
+ private:
+  ~UrlClassifierPrefixSetReporter() = default;
+
+  void Init() {
+    RefPtr reporter(this);
+    RegisterStrongMemoryReporter(reporter.forget());
+  }
+
+  static StaticMutex sMutex;
+  static StaticRefPtr<UrlClassifierPrefixSetReporter> sPrefixSetReporter;
+  nsTArray<TrackedPrefixSet> mSets MOZ_GUARDED_BY(sMutex);
+};
+
+StaticRefPtr<UrlClassifierPrefixSetReporter>
+    UrlClassifierPrefixSetReporter::sPrefixSetReporter;
+StaticMutex UrlClassifierPrefixSetReporter::sMutex;
+
+NS_IMPL_ISUPPORTS(UrlClassifierPrefixSetReporter, nsIMemoryReporter)
+
+/* static */
+void UrlClassifierPrefixSetReporter::Register(VariableLengthPrefixSet* aSet,
+                                              const nsACString& aName) {
+  StaticMutexAutoLock lock(sMutex);
+  if (!sPrefixSetReporter) {
+    sPrefixSetReporter = new UrlClassifierPrefixSetReporter();
+    sPrefixSetReporter->Init();
+  }
+  sPrefixSetReporter->mSets.RemoveElementsBy(
+      [](const TrackedPrefixSet& aEntry) { return aEntry.mWeakRef.IsDead(); });
+  nsCString path = nsPrintfCString(
+      "explicit/storage/prefix-set/%s",
+      (!aName.IsEmpty() ? PromiseFlatCString(aName).get() : "?!"));
+  sPrefixSetReporter->mSets.AppendElement(
+      TrackedPrefixSet{ThreadSafeWeakPtr<VariableLengthPrefixSet>(aSet), path});
+}
+
+/* static */
+void UrlClassifierPrefixSetReporter::PurgeStale(
+    VariableLengthPrefixSet* aPrefixSet) {
+  StaticMutexAutoLock lock(sMutex);
+  if (!sPrefixSetReporter) {
+    return;
+  }
+
+  sPrefixSetReporter->mSets.RemoveElement(aPrefixSet);
+
+  if (sPrefixSetReporter->mSets.IsEmpty()) {
+    // An async CollectReports dispatch may still hold a strong reference to
+    // sPrefixSetReporter after we null it here. That is safe because
+    // CollectReports releases sMutex before accessing the prefix sets.
+    UnregisterStrongMemoryReporter(sPrefixSetReporter);
+    sPrefixSetReporter = nullptr;
+  }
+}
+
+MOZ_DEFINE_MALLOC_SIZE_OF(UrlClassifierMallocSizeOf)
+
+NS_IMETHODIMP
+UrlClassifierPrefixSetReporter::CollectReports(
+    nsIHandleReportCallback* aHandleReport, nsISupports* aData,
+    bool aAnonymize) {
+  struct PrefixSetToReport {
+    RefPtr<VariableLengthPrefixSet> mPrefixSet;
+    nsCString mPath;
+  };
+
+  nsTArray<PrefixSetToReport> prefixSetsToReport;
+  {
+    StaticMutexAutoLock lock(sMutex);
+
+    mSets.RemoveElementsBy([&](const TrackedPrefixSet& aEntry) {
+      RefPtr<VariableLengthPrefixSet> set(aEntry.mWeakRef);
+      if (!set) {
+        return true;
+      }
+
+      prefixSetsToReport.AppendElement(
+          PrefixSetToReport{std::move(set), aEntry.mPath});
+
+      return false;
+    });
+  }
+
+  for (auto&& aEntry : prefixSetsToReport) {
+    size_t amount =
+        aEntry.mPrefixSet->SizeOfIncludingThis(UrlClassifierMallocSizeOf);
+    aHandleReport->Callback(
+        ""_ns, std::move(aEntry.mPath), nsIMemoryReporter::KIND_HEAP,
+        nsIMemoryReporter::UNITS_BYTES, amount,
+        nsLiteralCString("Memory used by the variable-length prefix set for a "
+                         "URL classifier."),
+        aData);
+  }
+
+  return NS_OK;
+}
 
-// This class will process prefix size between 4~32. But for 4 bytes prefixes,
-// they will be passed to nsUrlClassifierPrefixSet because of better
-// optimization.
 VariableLengthPrefixSet::VariableLengthPrefixSet()
     : mLock("VariableLengthPrefixSet.mLock"),
       mFixedPrefixSet(new nsUrlClassifierPrefixSet) {}
 
 nsresult VariableLengthPrefixSet::Init(const nsACString& aName) {
   mName = aName;
-  mMemoryReportPath = nsPrintfCString(
-      "explicit/storage/prefix-set/%s",
-      (!aName.IsEmpty() ? PromiseFlatCString(aName).get() : "?!"));
 
-  RegisterWeakMemoryReporter(this);
+  UrlClassifierPrefixSetReporter::Register(this, aName);
 
   return mFixedPrefixSet->Init(aName);
 }
 
 VariableLengthPrefixSet::~VariableLengthPrefixSet() {
-  UnregisterWeakMemoryReporter(this);
+  UrlClassifierPrefixSetReporter::PurgeStale(this);
 }
 
 nsresult VariableLengthPrefixSet::SetPrefixes(AddPrefixArray& aAddPrefixes,
@@ -445,7 +554,7 @@ bool VariableLengthPrefixSet::BinarySearch(const nsACString& aFullHash,
   int32_t begin = 0, end = aPrefixes.Length() / aPrefixSize;
 
   while (end > begin) {
-    int32_t mid = (begin + end) >> 1;
+    int32_t mid = begin + ((end - begin) >> 1);
     int cmp = memcmp(fullhash, prefixes + mid * aPrefixSize, aPrefixSize);
     if (cmp < 0) {
       end = mid;
@@ -458,22 +567,6 @@ bool VariableLengthPrefixSet::BinarySearch(const nsACString& aFullHash,
   return false;
 }
 
-MOZ_DEFINE_MALLOC_SIZE_OF(UrlClassifierMallocSizeOf)
-
-NS_IMETHODIMP
-VariableLengthPrefixSet::CollectReports(nsIHandleReportCallback* aHandleReport,
-                                        nsISupports* aData, bool aAnonymize) {
-  MOZ_ASSERT(NS_IsMainThread());
-
-  size_t amount = SizeOfIncludingThis(UrlClassifierMallocSizeOf);
-
-  return aHandleReport->Callback(
-      ""_ns, mMemoryReportPath, KIND_HEAP, UNITS_BYTES, amount,
-      nsLiteralCString("Memory used by the variable-length prefix set for a "
-                       "URL classifier."),
-      aData);
-}
-
 size_t VariableLengthPrefixSet::SizeOfIncludingThis(
     mozilla::MallocSizeOf aMallocSizeOf) const {
   MutexAutoLock lock(mLock);
diff --git a/toolkit/components/url-classifier/VariableLengthPrefixSet.h b/toolkit/components/url-classifier/VariableLengthPrefixSet.h
index 11b8096eedf..3db31415ce9 100644
--- a/toolkit/components/url-classifier/VariableLengthPrefixSet.h
+++ b/toolkit/components/url-classifier/VariableLengthPrefixSet.h
@@ -6,19 +6,24 @@
 #define VariableLengthPrefixSet_h
 
 #include "nsISupports.h"
-#include "nsIMemoryReporter.h"
 #include "Entries.h"
 #include "nsTArray.h"
 #include "mozilla/MemoryReporting.h"
 #include "mozilla/Mutex.h"
+#include "mozilla/ThreadSafeWeakPtr.h"
 
 class nsUrlClassifierPrefixSet;
 
 namespace mozilla {
 namespace safebrowsing {
 
-class VariableLengthPrefixSet final : public nsIMemoryReporter {
+class VariableLengthPrefixSet final
+    : public SupportsThreadSafeWeakPtr<VariableLengthPrefixSet> {
+  friend class SupportsThreadSafeWeakPtr<VariableLengthPrefixSet>;
+
  public:
+  MOZ_DECLARE_REFCOUNTED_TYPENAME(VariableLengthPrefixSet)
+
   VariableLengthPrefixSet();
 
   nsresult Init(const nsACString& aName);
@@ -41,11 +46,8 @@ class VariableLengthPrefixSet final : public nsIMemoryReporter {
 
   size_t SizeOfIncludingThis(mozilla::MallocSizeOf mallocSizeOf) const;
 
-  NS_DECL_THREADSAFE_ISUPPORTS
-  NS_DECL_NSIMEMORYREPORTER
-
  private:
-  virtual ~VariableLengthPrefixSet();
+  ~VariableLengthPrefixSet();
 
   static const uint32_t PREFIXSET_VERSION_MAGIC = 1;
 
@@ -62,7 +64,6 @@ class VariableLengthPrefixSet final : public nsIMemoryReporter {
   mozilla::safebrowsing::PrefixStringMap mVLPrefixSet;
 
   nsCString mName;
-  nsCString mMemoryReportPath;
 };
 
 }  // namespace safebrowsing
diff --git a/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp b/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
new file mode 100644
index 00000000000..cff2609a861
--- /dev/null
+++ b/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "Common.h"
+#include "VariableLengthPrefixSet.h"
+#include "mozilla/Atomics.h"
+#include "mozilla/gtest/MozAssertions.h"
+#include "mozilla/SpinEventLoopUntil.h"
+#include "nsIMemoryReporter.h"
+#include "nsNetCID.h"
+#include "nsPrintfCString.h"
+#include "nsServiceManagerUtils.h"
+
+using namespace mozilla;
+using namespace mozilla::safebrowsing;
+
+static RefPtr<VariableLengthPrefixSet> CreatePrefixSet(
+    const nsACString& aName) {
+  auto pset = MakeRefPtr<VariableLengthPrefixSet>();
+  nsresult rv = pset->Init(aName);
+  EXPECT_NS_SUCCEEDED(rv);
+  return pset;
+}
+
+class TestHandleReport final : public nsIHandleReportCallback {
+ public:
+  NS_DECL_ISUPPORTS
+
+  TestHandleReport(nsTArray<nsCString>& aPaths, const nsACString& aPrefix)
+      : mPaths(aPaths), mPrefix(aPrefix) {}
+
+  NS_IMETHOD Callback(const nsACString& aProcess, const nsACString& aPath,
+                      int32_t aKind, int32_t aUnits, int64_t aAmount,
+                      const nsACString& aDescription,
+                      nsISupports* aData) override {
+    if (StringBeginsWith(aPath, mPrefix)) {
+      mPaths.AppendElement(nsCString(aPath));
+    }
+    return NS_OK;
+  }
+
+ private:
+  ~TestHandleReport() = default;
+  nsTArray<nsCString>& mPaths;
+  const nsCString mPrefix;
+};
+
+NS_IMPL_ISUPPORTS(TestHandleReport, nsIHandleReportCallback)
+
+class TestFinishReporting final : public nsIFinishReportingCallback {
+ public:
+  NS_DECL_ISUPPORTS
+
+  explicit TestFinishReporting(Atomic<bool>& aDone) : mDone(aDone) {}
+
+  NS_IMETHOD Callback(nsISupports* aData) override {
+    mDone = true;
+    return NS_OK;
+  }
+
+ private:
+  ~TestFinishReporting() = default;
+  Atomic<bool>& mDone;
+};
+
+NS_IMPL_ISUPPORTS(TestFinishReporting, nsIFinishReportingCallback)
+
+static nsTArray<nsCString> CollectReportPaths(const nsACString& aPathPrefix) {
+  nsCOMPtr<nsIMemoryReporterManager> mgr =
+      do_GetService("@mozilla.org/memory-reporter-manager;1");
+  EXPECT_TRUE(mgr);
+
+  nsTArray<nsCString> paths;
+  Atomic<bool> done{false};
+
+  auto handleReport = MakeRefPtr<TestHandleReport>(paths, aPathPrefix);
+  auto finishReporting = MakeRefPtr<TestFinishReporting>(done);
+
+  mgr->GetReports(handleReport, nullptr, finishReporting, nullptr, false);
+
+  SpinEventLoopUntil("TestPrefixSetMemoryReporter"_ns,
+                     [&]() { return static_cast<bool>(done); });
+
+  return paths;
+}
+
+// Verify the singleton reporter registers and reports correctly.
+TEST(UrlClassifierPrefixSetReporter, BasicLifecycle)
+{
+  {
+    // Keep prefix sets alive while we verify they appear in memory reports.
+    RefPtr<VariableLengthPrefixSet> pset1 = CreatePrefixSet("test-table-a"_ns);
+    RefPtr<VariableLengthPrefixSet> pset2 = CreatePrefixSet("test-table-b"_ns);
+    (void)pset1;
+    (void)pset2;
+
+    nsTArray<nsCString> paths =
+        CollectReportPaths("explicit/storage/prefix-set/"_ns);
+    bool foundA = false, foundB = false;
+    for (const auto& path : paths) {
+      if (path.EqualsLiteral("explicit/storage/prefix-set/test-table-a")) {
+        foundA = true;
+      }
+      if (path.EqualsLiteral("explicit/storage/prefix-set/test-table-b")) {
+        foundB = true;
+      }
+    }
+    EXPECT_TRUE(foundA);
+    EXPECT_TRUE(foundB);
+  }
+
+  // After both prefix sets are destroyed, their reports should be gone.
+  nsTArray<nsCString> paths =
+      CollectReportPaths("explicit/storage/prefix-set/test-table-"_ns);
+  EXPECT_TRUE(paths.IsEmpty());
+}
+
+// Destroy VariableLengthPrefixSet instances on a background thread while
+// collecting memory reports on the main thread. This exercises the race
+// that bug 2049342 fixed.
+TEST(UrlClassifierPrefixSetReporter, ConcurrentDestroyAndReport)
+{
+  static const uint32_t kNumSets = 20;
+  static const uint32_t kReportRounds = 10;
+
+  nsTArray<RefPtr<VariableLengthPrefixSet>> sets;
+  for (uint32_t i = 0; i < kNumSets; i++) {
+    nsPrintfCString name("concurrent-test-%u", i);
+    sets.AppendElement(CreatePrefixSet(name));
+  }
+
+  // Verify all are reported before we start destroying.
+  nsTArray<nsCString> paths =
+      CollectReportPaths("explicit/storage/prefix-set/concurrent-test-"_ns);
+  EXPECT_EQ(paths.Length(), kNumSets);
+
+  // Move sets to a background thread that will release them one at a time,
+  // while the main thread repeatedly collects memory reports.
+  Atomic<uint32_t> destroyed{0};
+  nsTArray<RefPtr<VariableLengthPrefixSet>> bgSets = std::move(sets);
+
+  nsCOMPtr<nsIRunnable> destroyTask =
+      NS_NewRunnableFunction("DestroyPrefixSets", [&bgSets, &destroyed]() {
+        for (uint32_t i = 0; i < bgSets.Length(); i++) {
+          bgSets[i] = nullptr;
+          destroyed++;
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp b/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
new file mode 100644
index 00000000000..cff2609a861
--- /dev/null
+++ b/toolkit/components/url-classifier/tests/gtest/TestUrlClassifierPrefixSetReporter.cpp
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "Common.h"
+#include "VariableLengthPrefixSet.h"
+#include "mozilla/Atomics.h"
+#include "mozilla/gtest/MozAssertions.h"
+#include "mozilla/SpinEventLoopUntil.h"
+#include "nsIMemoryReporter.h"
+#include "nsNetCID.h"
+#include "nsPrintfCString.h"
+#include "nsServiceManagerUtils.h"
+
+using namespace mozilla;
+using namespace mozilla::safebrowsing;
+
+static RefPtr<VariableLengthPrefixSet> CreatePrefixSet(
+    const nsACString& aName) {
+  auto pset = MakeRefPtr<VariableLengthPrefixSet>();
+  nsresult rv = pset->Init(aName);
+  EXPECT_NS_SUCCEEDED(rv);
+  return pset;
+}
+
+class TestHandleReport final : public nsIHandleReportCallback {
+ public:
+  NS_DECL_ISUPPORTS
+
+  TestHandleReport(nsTArray<nsCString>& aPaths, const nsACString& aPrefix)
+      : mPaths(aPaths), mPrefix(aPrefix) {}
+
+  NS_IMETHOD Callback(const nsACString& aProcess, const nsACString& aPath,
+                      int32_t aKind, int32_t aUnits, int64_t aAmount,
+                      const nsACString& aDescription,
+                      nsISupports* aData) override {
+    if (StringBeginsWith(aPath, mPrefix)) {
+      mPaths.AppendElement(nsCString(aPath));
+    }
+    return NS_OK;
+  }
+
+ private:
+  ~TestHandleReport() = default;
+  nsTArray<nsCString>& mPaths;
+  const nsCString mPrefix;
+};
+
+NS_IMPL_ISUPPORTS(TestHandleReport, nsIHandleReportCallback)
+
+class TestFinishReporting final : public nsIFinishReportingCallback {
+ public:
+  NS_DECL_ISUPPORTS
+
+  explicit TestFinishReporting(Atomic<bool>& aDone) : mDone(aDone) {}
+
+  NS_IMETHOD Callback(nsISupports* aData) override {
+    mDone = true;
+    return NS_OK;
+  }
+
+ private:
+  ~TestFinishReporting() = default;
+  Atomic<bool>& mDone;
+};
+
+NS_IMPL_ISUPPORTS(TestFinishReporting, nsIFinishReportingCallback)
+
+static nsTArray<nsCString> CollectReportPaths(const nsACString& aPathPrefix) {
+  nsCOMPtr<nsIMemoryReporterManager> mgr =
+      do_GetService("@mozilla.org/memory-reporter-manager;1");
+  EXPECT_TRUE(mgr);
+
+  nsTArray<nsCString> paths;
+  Atomic<bool> done{false};
+
+  auto handleReport = MakeRefPtr<TestHandleReport>(paths, aPathPrefix);
+  auto finishReporting = MakeRefPtr<TestFinishReporting>(done);
+
+  mgr->GetReports(handleReport, nullptr, finishReporting, nullptr, false);
+
+  SpinEventLoopUntil("TestPrefixSetMemoryReporter"_ns,
+                     [&]() { return static_cast<bool>(done); });
+
+  return paths;
+}
+
+// Verify the singleton reporter registers and reports correctly.
+TEST(UrlClassifierPrefixSetReporter, BasicLifecycle)
+{
+  {
+    // Keep prefix sets alive while we verify they appear in memory reports.
+    RefPtr<VariableLengthPrefixSet> pset1 = CreatePrefixSet("test-table-a"_ns);
+    RefPtr<VariableLengthPrefixSet> pset2 = CreatePrefixSet("test-table-b"_ns);
+    (void)pset1;
+    (void)pset2;
+
+    nsTArray<nsCString> paths =
+        CollectReportPaths("explicit/storage/prefix-set/"_ns);
+    bool foundA = false, foundB = false;
+    for (const auto& path : paths) {
+      if (path.EqualsLiteral("explicit/storage/prefix-set/test-table-a")) {
+        foundA = true;
+      }
+      if (path.EqualsLiteral("explicit/storage/prefix-set/test-table-b")) {
+        foundB = true;
+      }
+    }
+    EXPECT_TRUE(foundA);
+    EXPECT_TRUE(foundB);
+  }
+
+  // After both prefix sets are destroyed, their reports should be gone.
+  nsTArray<nsCString> paths =
+      CollectReportPaths("explicit/storage/prefix-set/test-table-"_ns);
+  EXPECT_TRUE(paths.IsEmpty());
+}
+
+// Destroy VariableLengthPrefixSet instances on a background thread while
+// collecting memory reports on the main thread. This exercises the race
+// that bug 2049342 fixed.
+TEST(UrlClassifierPrefixSetReporter, ConcurrentDestroyAndReport)
+{
+  static const uint32_t kNumSets = 20;
+  static const uint32_t kReportRounds = 10;
+
+  nsTArray<RefPtr<VariableLengthPrefixSet>> sets;
+  for (uint32_t i = 0; i < kNumSets; i++) {
+    nsPrintfCString name("concurrent-test-%u", i);
+    sets.AppendElement(CreatePrefixSet(name));
+  }
+
+  // Verify all are reported before we start destroying.
+  nsTArray<nsCString> paths =
+      CollectReportPaths("explicit/storage/prefix-set/concurrent-test-"_ns);
+  EXPECT_EQ(paths.Length(), kNumSets);
+
+  // Move sets to a background thread that will release them one at a time,
+  // while the main thread repeatedly collects memory reports.
+  Atomic<uint32_t> destroyed{0};
+  nsTArray<RefPtr<VariableLengthPrefixSet>> bgSets = std::move(sets);
+
+  nsCOMPtr<nsIRunnable> destroyTask =
+      NS_NewRunnableFunction("DestroyPrefixSets", [&bgSets, &destroyed]() {
+        for (uint32_t i = 0; i < bgSets.Length(); i++) {
+          bgSets[i] = nullptr;
+          destroyed++;
+        }
+      });
+  nsCOMPtr<nsIEventTarget> pool =
+      do_GetService(NS_STREAMTRANSPORTSERVICE_CONTRACTID);
+  ASSERT_TRUE(pool);
+  nsresult rv = pool->Dispatch(destroyTask, NS_DISPATCH_NORMAL);
+  ASSERT_NS_SUCCEEDED(rv);
+
+  // Main thread collects reports repeatedly while destruction proceeds.
+  for (uint32_t round = 0; round < kReportRounds; round++) {
+    CollectReportPaths("explicit/storage/prefix-set/concurrent-test-"_ns);
+  }
+
+  SpinEventLoopUntil("WaitForDestroy"_ns,
+                     [&]() { return destroyed == kNumSets; });
+  EXPECT_EQ(static_cast<uint32_t>(destroyed), kNumSets);
+
+  // All should be gone now.
+  paths = CollectReportPaths("explicit/storage/prefix-set/concurrent-test-"_ns);
+  EXPECT_TRUE(paths.IsEmpty());
+}
+
+// Verify that creating and immediately destroying a VariableLengthPrefixSet
+// (the Init-then-fail path in Classifier::GetLookupCache) doesn't leave
+// stale entries in the reporter.
+TEST(UrlClassifierPrefixSetReporter, CreateAndImmediatelyDestroy)
+{
+  for (uint32_t i = 0; i < 50; i++) {
+    MOZ_ALWAYS_TRUE(CreatePrefixSet("ephemeral-test"_ns));
+  }
+
+  nsTArray<nsCString> paths =
+      CollectReportPaths("explicit/storage/prefix-set/ephemeral-test"_ns);
+  EXPECT_TRUE(paths.IsEmpty());
+}
diff --git a/toolkit/components/url-classifier/tests/gtest/moz.build b/toolkit/components/url-classifier/tests/gtest/moz.build
index 404583c2a0c..ca78f47cfff 100644
--- a/toolkit/components/url-classifier/tests/gtest/moz.build
+++ b/toolkit/components/url-classifier/tests/gtest/moz.build
@@ -25,6 +25,7 @@ UNIFIED_SOURCES += [
     "TestSafebrowsingHash.cpp",
     "TestSafeBrowsingProtobuf.cpp",
     "TestTable.cpp",
+    "TestUrlClassifierPrefixSetReporter.cpp",
     "TestUrlClassifierTableUpdateV4.cpp",
     "TestUrlClassifierUtils.cpp",
     "TestURLsAndHashing.cpp",
Loading diff…