Bug 1772894 - Report noncompartmental aka nonzonal aka full GCs only when a full GC is requested, even if 100% of zones end up collecting r=jonco
authorSteve Fink <sfink@mozilla.com>
Mon, 13 Jun 2022 15:55:47 +0000
changeset 620751 28628b207b561dbb3b6b3dc53a2efa8322f0f4c1
parent 620750 2a715807444c3d6014f17513a17228913a56e561
child 620752 e59a131ffaee1ad7c2eae307a7cac47b645255f3
push id164463
push usersfink@mozilla.com
push dateMon, 13 Jun 2022 15:58:13 +0000
treeherderautoland@28628b207b56 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjonco
bugs1772894
milestone103.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1772894 - Report noncompartmental aka nonzonal aka full GCs only when a full GC is requested, even if 100% of zones end up collecting r=jonco Differential Revision: https://phabricator.services.mozilla.com/D148432
js/src/gc/GC.cpp
js/src/gc/GCAPI.cpp
js/src/gc/GCRuntime.h
js/src/gc/Statistics.cpp
js/src/gc/Statistics.h
js/src/jsapi-tests/testGCHooks.cpp
--- a/js/src/gc/GC.cpp
+++ b/js/src/gc/GC.cpp
@@ -386,16 +386,17 @@ GCRuntime::GCRuntime(JSRuntime* rt)
       systemZone(nullptr),
       mainThreadContext(rt),
       heapState_(JS::HeapState::Idle),
       stats_(this),
       marker(rt),
       barrierTracer(rt),
       sweepingTracer(rt),
       heapSize(nullptr),
+      fullGCRequested(false),
       helperThreadRatio(TuningDefaults::HelperThreadRatio),
       maxHelperThreads(TuningDefaults::MaxHelperThreads),
       helperThreadCount(1),
       createBudgetCallback(nullptr),
       rootsHash(256),
       nextCellUniqueId_(LargestTaggedNullCellPointer +
                         1),  // Ensure disjoint from null tagged pointers.
       numArenasFreeCommitted(0),
@@ -3648,30 +3649,36 @@ void GCRuntime::maybeCallGCCallback(JSGC
 
   if (gcCallbackDepth == 0) {
     // Save scheduled zone information in case the callback clears it.
     for (ZonesIter zone(this, WithAtoms); !zone.done(); zone.next()) {
       zone->gcScheduledSaved_ = zone->gcScheduled_;
     }
   }
 
-  // Save and clear GC options in case the callback reenters GC.
+  // Save and clear GC options and state in case the callback reenters GC.
   JS::GCOptions options = gcOptions();
   maybeGcOptions = Nothing();
+  bool savedFullGCRequested = fullGCRequested;
+  fullGCRequested = false;
 
   gcCallbackDepth++;
 
   callGCCallback(status, reason);
 
   MOZ_ASSERT(gcCallbackDepth != 0);
   gcCallbackDepth--;
 
   // Restore the original GC options.
   maybeGcOptions = Some(options);
 
+  // At the end of a GC, clear out the fullGCRequested state. At the start,
+  // restore the previous setting.
+  fullGCRequested = (status == JSGC_END) ? false : savedFullGCRequested;
+
   if (gcCallbackDepth == 0) {
     // Ensure any zone that was originally scheduled stays scheduled.
     for (ZonesIter zone(this, WithAtoms); !zone.done(); zone.next()) {
       zone->gcScheduled_ = zone->gcScheduled_ || zone->gcScheduledSaved_;
     }
   }
 }
 
@@ -3900,17 +3907,17 @@ void GCRuntime::collect(bool nonincremen
   // Checks run for each request, even if we do not actually GC.
   checkCanCallAPI();
 
   // Check if we are allowed to GC at this time before proceeding.
   if (!checkIfGCAllowedInCurrentState(reason)) {
     return;
   }
 
-  stats().log("GC starting in state %s", StateName(incrementalState));
+  stats().log("GC slice starting in state %s", StateName(incrementalState));
 
   AutoTraceLog logGC(TraceLoggerForCurrentThread(), TraceLogger_GC);
   AutoStopVerifyingBarriers av(rt, isShutdownGC());
   AutoMaybeLeaveAtomsZone leaveAtomsZone(rt->mainContextFromOwnThread());
   AutoSetZoneSliceThresholds sliceThresholds(this);
 
   schedulingState.updateHighFrequencyModeForReason(reason);
 
@@ -3957,17 +3964,17 @@ void GCRuntime::collect(bool nonincremen
   if (hasZealMode(ZealMode::CheckHeapAfterGC)) {
     gcstats::AutoPhase ap(stats(), gcstats::PhaseKind::TRACE_HEAP);
     CheckHeapAfterGC(rt);
   }
   if (hasZealMode(ZealMode::CheckGrayMarking) && !isIncrementalGCInProgress()) {
     MOZ_RELEASE_ASSERT(CheckGrayMarkingState(rt));
   }
 #endif
-  stats().log("GC ending in state %s", StateName(incrementalState));
+  stats().log("GC slice ending in state %s", StateName(incrementalState));
 
   UnscheduleZones(this);
 }
 
 SliceBudget GCRuntime::defaultBudget(JS::GCReason reason, int64_t millis) {
   // millis == 0 means use internal GC scheduling logic to come up with
   // a duration for the slice budget. This may end up still being zero
   // based on preferences.
--- a/js/src/gc/GCAPI.cpp
+++ b/js/src/gc/GCAPI.cpp
@@ -226,16 +226,17 @@ JS_PUBLIC_API void JS::PrepareZoneForGC(
 
   zone->scheduleGC();
 }
 
 JS_PUBLIC_API void JS::PrepareForFullGC(JSContext* cx) {
   AssertHeapIsIdle();
   CHECK_THREAD(cx);
 
+  cx->runtime()->gc.fullGCRequested = true;
   for (ZonesIter zone(cx->runtime(), WithAtoms); !zone.done(); zone.next()) {
     zone->scheduleGC();
   }
 }
 
 JS_PUBLIC_API void JS::PrepareForIncrementalGC(JSContext* cx) {
   AssertHeapIsIdle();
   CHECK_THREAD(cx);
@@ -264,16 +265,17 @@ JS_PUBLIC_API bool JS::IsGCScheduled(JSC
   return false;
 }
 
 JS_PUBLIC_API void JS::SkipZoneForGC(JSContext* cx, Zone* zone) {
   AssertHeapIsIdle();
   CHECK_THREAD(cx);
   MOZ_ASSERT(cx->runtime()->gc.hasZone(zone));
 
+  cx->runtime()->gc.fullGCRequested = false;
   zone->unscheduleGC();
 }
 
 static inline void CheckGCOptions(JS::GCOptions options) {
   MOZ_ASSERT(options == JS::GCOptions::Normal ||
              options == JS::GCOptions::Shrink ||
              options == JS::GCOptions::Shutdown);
 }
--- a/js/src/gc/GCRuntime.h
+++ b/js/src/gc/GCRuntime.h
@@ -948,16 +948,17 @@ class GCRuntime {
   Vector<JS::GCCellPtr, 0, SystemAllocPolicy> unmarkGrayStack;
 
   /* Track total GC heap size for this runtime. */
   HeapSize heapSize;
 
   /* GC scheduling state and parameters. */
   GCSchedulingTunables tunables;
   GCSchedulingState schedulingState;
+  MainThreadData<bool> fullGCRequested;
 
   // Helper thread configuration.
   MainThreadData<double> helperThreadRatio;
   MainThreadData<size_t> maxHelperThreads;
   MainThreadData<size_t> helperThreadCount;
 
   // State used for managing atom mark bitmaps in each zone.
   AtomMarkingRuntime atomMarking;
--- a/js/src/gc/Statistics.cpp
+++ b/js/src/gc/Statistics.cpp
@@ -1004,17 +1004,17 @@ void Statistics::endGC() {
   postTotalHeapBytes = gc->heapSize.bytes();
 
   sendGCTelemetry();
 }
 
 void Statistics::sendGCTelemetry() {
   JSRuntime* runtime = gc->rt;
   runtime->addTelemetry(JS_TELEMETRY_GC_IS_ZONE_GC,
-                        !zoneStats.isFullCollection());
+                        !runtime->gc.fullGCRequested);
   TimeDuration prepareTotal = SumPhase(PhaseKind::PREPARE, phaseTimes);
   TimeDuration markTotal = SumPhase(PhaseKind::MARK, phaseTimes);
   TimeDuration markRootsTotal = SumPhase(PhaseKind::MARK_ROOTS, phaseTimes);
   TimeDuration markWeakTotal = phaseTimes[Phase::SWEEP_MARK_WEAK] +
                                phaseTimes[Phase::SWEEP_MARK_GRAY_WEAK];
   TimeDuration markGrayTotal = phaseTimes[Phase::SWEEP_MARK_GRAY] +
                                phaseTimes[Phase::SWEEP_MARK_GRAY_WEAK];
   size_t markCount = gc->marker.getMarkCount();
@@ -1159,20 +1159,19 @@ void Statistics::beginSlice(const ZoneGC
     return;
   }
 
   runtime->addTelemetry(JS_TELEMETRY_GC_REASON, uint32_t(reason));
   runtime->addTelemetry(JS_TELEMETRY_GC_BUDGET_WAS_INCREASED,
                         budgetWasIncreased);
 
   // Slice callbacks should only fire for the outermost level.
-  bool wasFullGC = zoneStats.isFullCollection();
   if (sliceCallback) {
     JSContext* cx = context();
-    JS::GCDescription desc(!wasFullGC, false, options, reason);
+    JS::GCDescription desc(!gc->fullGCRequested, false, options, reason);
     if (first) {
       (*sliceCallback)(cx, JS::GC_CYCLE_BEGIN, desc);
     }
     (*sliceCallback)(cx, JS::GC_SLICE_BEGIN, desc);
   }
 
   log("begin slice");
 }
@@ -1208,20 +1207,19 @@ void Statistics::endSlice() {
   if (!aborted &&
       ShouldPrintProfile(gc->rt, enableProfiling_, profileWorkers_,
                          profileThreshold_, slices_.back().duration())) {
     printSliceProfile();
   }
 
   // Slice callbacks should only fire for the outermost level.
   if (!aborted) {
-    bool wasFullGC = zoneStats.isFullCollection();
     if (sliceCallback) {
       JSContext* cx = context();
-      JS::GCDescription desc(!wasFullGC, last, gcOptions,
+      JS::GCDescription desc(!gc->fullGCRequested, last, gcOptions,
                              slices_.back().reason);
       (*sliceCallback)(cx, JS::GC_SLICE_END, desc);
       if (last) {
         (*sliceCallback)(cx, JS::GC_CYCLE_END, desc);
       }
     }
   }
 
@@ -1584,17 +1582,17 @@ void Statistics::printSliceProfile() {
 
   maybePrintProfileHeaders();
 
   TimeDuration ts = slice.end - creationTime();
 
   bool shrinking = gcOptions == JS::GCOptions::Shrink;
   bool reset = slice.resetReason != GCAbortReason::None;
   bool nonIncremental = nonincrementalReason_ != GCAbortReason::None;
-  bool full = zoneStats.isFullCollection();
+  bool full = gc->fullGCRequested;
   size_t sizeKB = gc->heapSize.bytes() / 1024;
 
   FILE* file = profileFile();
   fprintf(file,
           "MajorGC: %7zu %14p %10.6f %-20.20s %1d -> %1d %1s%1s%1s%1s   %6zu",
           size_t(getpid()), gc->rt, ts.ToSeconds(),
           ExplainGCReason(slice.reason), int(slice.initialState),
           int(slice.finalState), full ? "F" : "", shrinking ? "S" : "",
--- a/js/src/gc/Statistics.h
+++ b/js/src/gc/Statistics.h
@@ -86,18 +86,16 @@ struct ZoneGCStats {
   int collectedCompartmentCount = 0;
 
   /* Total number of compartments in the Runtime at the start of this GC. */
   int compartmentCount = 0;
 
   /* Total number of compartments swept by this GC. */
   int sweptCompartmentCount = 0;
 
-  bool isFullCollection() const { return collectedZoneCount == zoneCount; }
-
   ZoneGCStats() = default;
 };
 
 struct Trigger {
   size_t amount = 0;
   size_t threshold = 0;
 };
 
--- a/js/src/jsapi-tests/testGCHooks.cpp
+++ b/js/src/jsapi-tests/testGCHooks.cpp
@@ -1,19 +1,22 @@
 /* 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 "mozilla/ScopeExit.h"
 #include "mozilla/UniquePtr.h"
 
 #include <iterator>
 
 #include "jsapi-tests/tests.h"
 
 static unsigned gSliceCallbackCount = 0;
+static bool gSawAllSliceCallbacks;
+static bool gSawAllGCCallbacks;
 
 static void NonIncrementalGCSliceCallback(JSContext* cx,
                                           JS::GCProgress progress,
                                           const JS::GCDescription& desc) {
   using namespace JS;
   static GCProgress expect[] = {GC_CYCLE_BEGIN, GC_SLICE_BEGIN, GC_SLICE_END,
                                 GC_CYCLE_END};
 
@@ -37,58 +40,240 @@ BEGIN_TEST(testGCSliceCallback) {
   return true;
 }
 END_TEST(testGCSliceCallback)
 
 static void RootsRemovedGCSliceCallback(JSContext* cx, JS::GCProgress progress,
                                         const JS::GCDescription& desc) {
   using namespace JS;
 
-  static GCProgress expectProgress[] = {
-      GC_CYCLE_BEGIN, GC_SLICE_BEGIN, GC_SLICE_END,   GC_SLICE_BEGIN,
-      GC_SLICE_END,   GC_CYCLE_END,   GC_CYCLE_BEGIN, GC_SLICE_BEGIN,
-      GC_SLICE_END,   GC_CYCLE_END};
+  static constexpr struct {
+    GCProgress progress;
+    GCReason reason;
+  } expect[] = {
+      // Explicitly requested a full GC.
+      {GC_CYCLE_BEGIN, GCReason::DEBUG_GC},
+      {GC_SLICE_BEGIN, GCReason::DEBUG_GC},
+      {GC_SLICE_END, GCReason::DEBUG_GC},
+      {GC_SLICE_BEGIN, GCReason::DEBUG_GC},
+      {GC_SLICE_END, GCReason::DEBUG_GC},
+      {GC_CYCLE_END, GCReason::DEBUG_GC},
+      // Shutdown GC with ROOTS_REMOVED.
+      {GC_CYCLE_BEGIN, GCReason::ROOTS_REMOVED},
+      {GC_SLICE_BEGIN, GCReason::ROOTS_REMOVED},
+      {GC_SLICE_END, GCReason::ROOTS_REMOVED},
+      {GC_CYCLE_END, GCReason::ROOTS_REMOVED}
+      // All done.
+  };
 
-  static GCReason expectReasons[] = {
-      GCReason::DEBUG_GC,      GCReason::DEBUG_GC,      GCReason::DEBUG_GC,
-      GCReason::DEBUG_GC,      GCReason::DEBUG_GC,      GCReason::DEBUG_GC,
-      GCReason::ROOTS_REMOVED, GCReason::ROOTS_REMOVED, GCReason::ROOTS_REMOVED,
-      GCReason::ROOTS_REMOVED};
-
-  static_assert(
-      std::size(expectProgress) == std::size(expectReasons),
-      "expectProgress and expectReasons arrays should be the same length");
-
-  MOZ_RELEASE_ASSERT(gSliceCallbackCount < std::size(expectProgress));
-  MOZ_RELEASE_ASSERT(progress == expectProgress[gSliceCallbackCount]);
+  MOZ_RELEASE_ASSERT(gSliceCallbackCount < std::size(expect));
+  MOZ_RELEASE_ASSERT(progress == expect[gSliceCallbackCount].progress);
   MOZ_RELEASE_ASSERT(desc.isZone_ == false);
   MOZ_RELEASE_ASSERT(desc.options_ == JS::GCOptions::Shrink);
-  MOZ_RELEASE_ASSERT(desc.reason_ == expectReasons[gSliceCallbackCount]);
+  MOZ_RELEASE_ASSERT(desc.reason_ == expect[gSliceCallbackCount].reason);
   gSliceCallbackCount++;
 }
 
 BEGIN_TEST(testGCRootsRemoved) {
   AutoLeaveZeal nozeal(cx);
 
   AutoGCParameter param1(cx, JSGC_INCREMENTAL_GC_ENABLED, true);
 
   gSliceCallbackCount = 0;
   JS::SetGCSliceCallback(cx, RootsRemovedGCSliceCallback);
+  auto byebye =
+      mozilla::MakeScopeExit([=] { JS::SetGCSliceCallback(cx, nullptr); });
 
   JS::RootedObject obj(cx, JS_NewPlainObject(cx));
   CHECK(obj);
 
   JS::PrepareForFullGC(cx);
   js::SliceBudget budget(js::WorkBudget(1));
   cx->runtime()->gc.startDebugGC(JS::GCOptions::Shrink, budget);
   CHECK(JS::IsIncrementalGCInProgress(cx));
 
   // Trigger another GC after the current one in shrinking / shutdown GCs.
   cx->runtime()->gc.notifyRootsRemoved();
 
   JS::FinishIncrementalGC(cx, JS::GCReason::DEBUG_GC);
   CHECK(!JS::IsIncrementalGCInProgress(cx));
 
-  JS::SetGCSliceCallback(cx, nullptr);
+  return true;
+}
+END_TEST(testGCRootsRemoved)
+
+#define ASSERT_MSG(cond, ...)       \
+  do {                              \
+    if (!(cond)) {                  \
+      fprintf(stderr, __VA_ARGS__); \
+      MOZ_RELEASE_ASSERT(cond);     \
+    }                               \
+  } while (false)
+
+// Trigger some nested GCs to ensure that they get their own reasons and
+// fullGCRequested state.
+//
+// The callbacks will form the following tree:
+//
+//   Begin(DEBUG_GC)
+//     Begin(API)
+//     End(API)
+//   End(DEBUG_GC)
+//     Begin(MEM_PRESSURE)
+//     End(MEM_PRESSURE)
+//       Begin(DOM_WINDOW_UTILS)
+//       End(DOM_WINDOW_UTILS)
+//
+// JSGC_BEGIN and JSGC_END callbacks will be observed as a preorder traversal
+// of the above tree.
+//
+// Note that the ordering of the *slice* callbacks don't match up simply to the
+// ordering above. If a JSGC_BEGIN triggers another GC, we won't see the outer
+// GC's GC_CYCLE_BEGIN until the inner one is done. The slice callbacks are
+// reporting the actual order that the GCs are happening in.
+//
+// JSGC_END, on the other hand, won't be emitted until the GC is complete and
+// the GC_CYCLE_BEGIN callback has fired. So its ordering is straightforward.
+//
+static void GCTreeCallback(JSContext* cx, JSGCStatus status,
+                           JS::GCReason reason, void* data) {
+  using namespace JS;
+
+  static constexpr struct {
+    JSGCStatus expectedStatus;
+    JS::GCReason expectedReason;
+    bool fireGC;
+    JS::GCReason reason;
+    bool requestFullGC;
+  } invocations[] = {
+      {JSGC_BEGIN, GCReason::DEBUG_GC, true, GCReason::API, false},
+      {JSGC_BEGIN, GCReason::API, false},
+      {JSGC_END, GCReason::API, false},
+      {JSGC_END, GCReason::DEBUG_GC, true, GCReason::MEM_PRESSURE, true},
+      {JSGC_BEGIN, GCReason::MEM_PRESSURE, false},
+      {JSGC_END, GCReason::MEM_PRESSURE, true, GCReason::DOM_WINDOW_UTILS,
+       false},
+      {JSGC_BEGIN, GCReason::DOM_WINDOW_UTILS, false},
+      {JSGC_END, GCReason::DOM_WINDOW_UTILS, false}};
+
+  static size_t i = 0;
+  MOZ_RELEASE_ASSERT(i < std::size(invocations));
+  auto& invocation = invocations[i++];
+  if (i == std::size(invocations)) {
+    gSawAllGCCallbacks = true;
+  }
+  ASSERT_MSG(status == invocation.expectedStatus,
+             "GC callback #%zu: got status %d expected %d\n", i, status,
+             invocation.expectedStatus);
+  ASSERT_MSG(reason == invocation.expectedReason,
+             "GC callback #%zu: got reason %s expected %s\n", i,
+             ExplainGCReason(reason),
+             ExplainGCReason(invocation.expectedReason));
+  if (invocation.fireGC) {
+    if (invocation.requestFullGC) {
+      JS::PrepareForFullGC(cx);
+    }
+    js::SliceBudget budget = js::SliceBudget(js::WorkBudget(1));
+    cx->runtime()->gc.startGC(GCOptions::Normal, invocation.reason, budget);
+    MOZ_RELEASE_ASSERT(JS::IsIncrementalGCInProgress(cx));
+
+    JS::FinishIncrementalGC(cx, invocation.reason);
+    MOZ_RELEASE_ASSERT(!JS::IsIncrementalGCInProgress(cx));
+  }
+}
+
+static void GCTreeSliceCallback(JSContext* cx, JS::GCProgress progress,
+                                const JS::GCDescription& desc) {
+  using namespace JS;
+
+  static constexpr struct {
+    GCProgress progress;
+    GCReason reason;
+    bool isZonal;
+  } expectations[] = {
+      // JSGC_BEGIN triggers a new GC before we get any slice callbacks from the
+      // original outer GC. So the very first reason observed is API, not
+      // DEBUG_GC.
+      {GC_CYCLE_BEGIN, GCReason::API, true},
+      {GC_SLICE_BEGIN, GCReason::API, true},
+      {GC_SLICE_END, GCReason::API, true},
+      {GC_SLICE_BEGIN, GCReason::API, true},
+      {GC_SLICE_END, GCReason::API, true},
+      {GC_CYCLE_END, GCReason::API, true},
+      // Now the "outer" GC runs. It requested a full GC.
+      {GC_CYCLE_BEGIN, GCReason::DEBUG_GC, false},
+      {GC_SLICE_BEGIN, GCReason::DEBUG_GC, false},
+      {GC_SLICE_END, GCReason::DEBUG_GC, false},
+      {GC_SLICE_BEGIN, GCReason::DEBUG_GC, false},
+      {GC_SLICE_END, GCReason::DEBUG_GC, false},
+      {GC_CYCLE_END, GCReason::DEBUG_GC, false},
+      // The outer JSGC_DEBUG GC's end callback triggers a full MEM_PRESSURE
+      // GC, which runs next. (Its JSGC_BEGIN does not run a GC.)
+      {GC_CYCLE_BEGIN, GCReason::MEM_PRESSURE, false},
+      {GC_SLICE_BEGIN, GCReason::MEM_PRESSURE, false},
+      {GC_SLICE_END, GCReason::MEM_PRESSURE, false},
+      {GC_SLICE_BEGIN, GCReason::MEM_PRESSURE, false},
+      {GC_SLICE_END, GCReason::MEM_PRESSURE, false},
+      {GC_CYCLE_END, GCReason::MEM_PRESSURE, false},
+      // The MEM_PRESSURE's GC's end callback now triggers a (zonal)
+      // DOM_WINDOW_UTILS GC.
+      {GC_CYCLE_BEGIN, GCReason::DOM_WINDOW_UTILS, true},
+      {GC_SLICE_BEGIN, GCReason::DOM_WINDOW_UTILS, true},
+      {GC_SLICE_END, GCReason::DOM_WINDOW_UTILS, true},
+      {GC_SLICE_BEGIN, GCReason::DOM_WINDOW_UTILS, true},
+      {GC_SLICE_END, GCReason::DOM_WINDOW_UTILS, true},
+      {GC_CYCLE_END, GCReason::DOM_WINDOW_UTILS, true},
+      // All done.
+  };
+
+  MOZ_RELEASE_ASSERT(gSliceCallbackCount < std::size(expectations));
+  auto& expect = expectations[gSliceCallbackCount];
+  ASSERT_MSG(progress == expect.progress, "iteration %d: wrong progress\n",
+             gSliceCallbackCount);
+  ASSERT_MSG(desc.reason_ == expect.reason,
+             "iteration %d: expected %s got %s\n", gSliceCallbackCount,
+             JS::ExplainGCReason(expect.reason),
+             JS::ExplainGCReason(desc.reason_));
+  ASSERT_MSG(desc.isZone_ == expect.isZonal, "iteration %d: wrong zonal\n",
+             gSliceCallbackCount);
+  MOZ_RELEASE_ASSERT(desc.options_ == JS::GCOptions::Normal);
+  gSliceCallbackCount++;
+  if (gSliceCallbackCount == std::size(expectations)) {
+    gSawAllSliceCallbacks = true;
+  }
+}
+
+BEGIN_TEST(testGCTree) {
+  AutoLeaveZeal nozeal(cx);
+
+  AutoGCParameter param1(cx, JSGC_INCREMENTAL_GC_ENABLED, true);
+
+  gSliceCallbackCount = 0;
+  gSawAllSliceCallbacks = false;
+  gSawAllGCCallbacks = false;
+  JS::SetGCSliceCallback(cx, GCTreeSliceCallback);
+  JS_SetGCCallback(cx, GCTreeCallback, nullptr);
+
+  // Automate the callback clearing. Otherwise if a CHECK fails, it will get
+  // cluttered with additional failures from the callback unexpectedly firing
+  // during the final shutdown GC.
+  auto byebye = mozilla::MakeScopeExit([=] {
+    JS::SetGCSliceCallback(cx, nullptr);
+    JS_SetGCCallback(cx, nullptr, nullptr);
+  });
+
+  JS::RootedObject obj(cx, JS_NewPlainObject(cx));
+  CHECK(obj);
+
+  // Outer GC is a full GC.
+  JS::PrepareForFullGC(cx);
+  js::SliceBudget budget(js::WorkBudget(1));
+  cx->runtime()->gc.startDebugGC(JS::GCOptions::Normal, budget);
+  CHECK(JS::IsIncrementalGCInProgress(cx));
+
+  JS::FinishIncrementalGC(cx, JS::GCReason::DEBUG_GC);
+  CHECK(!JS::IsIncrementalGCInProgress(cx));
+  CHECK(gSawAllSliceCallbacks);
+  CHECK(gSawAllGCCallbacks);
 
   return true;
 }
-END_TEST(testGCRootsRemoved)
+END_TEST(testGCTree)