Implement MTRDevice handling in controller suspend/resume. (#35464)

Also blocks acquisition of CASE sessions on a suspended controller, which should
ensure that new requests for MTRDevices and MTRBaseDevices associated with the
controller do not hit the network.
diff --git a/src/darwin/Framework/CHIP/MTRDevice.mm b/src/darwin/Framework/CHIP/MTRDevice.mm
index ceca6d2..a668e02 100644
--- a/src/darwin/Framework/CHIP/MTRDevice.mm
+++ b/src/darwin/Framework/CHIP/MTRDevice.mm
@@ -648,8 +648,9 @@
 
 - (void)_delegateAdded
 {
-    // Nothing to do; this is a hook for subclasses.  If that ever changes for
-    // some reason, subclasses need to start calling this hook on their super.
+    os_unfair_lock_assert_owner(&self->_lock);
+
+    // Nothing to do for now. At the moment this is a hook for subclasses.
 }
 
 - (void)removeDelegate:(id<MTRDeviceDelegate>)delegate
@@ -1743,6 +1744,16 @@
     return result;
 }
 
+- (void)controllerSuspended
+{
+    // Nothing to do for now.
+}
+
+- (void)controllerResumed
+{
+    // Nothing to do for now.
+}
+
 @end
 
 /* BEGIN DRAGONS: Note methods here cannot be renamed, and are used by private callers, do not rename, remove or modify behavior here */
diff --git a/src/darwin/Framework/CHIP/MTRDeviceController.mm b/src/darwin/Framework/CHIP/MTRDeviceController.mm
index b743b9f..a55946c 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController.mm
@@ -120,7 +120,6 @@
     MTROperationalCredentialsDelegate * _operationalCredentialsDelegate;
     MTRDeviceAttestationDelegateBridge * _deviceAttestationDelegateBridge;
     MTRDeviceControllerFactory * _factory;
-    NSMapTable * _nodeIDToDeviceMap;
     os_unfair_lock _underlyingDeviceMapLock;
     MTRCommissionableBrowser * _commissionableBrowser;
     MTRAttestationTrustStoreBridge * _attestationTrustStoreBridge;
@@ -135,6 +134,7 @@
     MTRP256KeypairBridge _operationalKeypairBridge;
 
     BOOL _suspended;
+    os_unfair_lock _suspensionLock;
 
     // Counters to track assertion status and access controlled by the _assertionLock
     NSUInteger _keepRunningAssertionCounter;
@@ -160,6 +160,12 @@
     _assertionLock = OS_UNFAIR_LOCK_INIT;
 
     _suspended = startSuspended;
+    // All synchronous suspend/resume activity has to be protected by
+    // _suspensionLock, so that parts of suspend/resume can't interleave with
+    // each other.
+    _suspensionLock = OS_UNFAIR_LOCK_INIT;
+
+    _nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable];
 
     return self;
 }
@@ -204,6 +210,7 @@
         _assertionLock = OS_UNFAIR_LOCK_INIT;
 
         _suspended = startSuspended;
+        _suspensionLock = OS_UNFAIR_LOCK_INIT;
 
         if (storageDelegate != nil) {
             if (storageDelegateQueue == nil) {
@@ -350,23 +357,46 @@
 
 - (void)suspend
 {
+    MTR_LOG("%@ suspending", self);
+
+    std::lock_guard lock(_suspensionLock);
+
     _suspended = YES;
 
-    // TODO: In the concrete class (which is unused so far!), iterate our
-    // MTRDevices, tell them to tear down subscriptions.  Possibly close all
-    // CASE sessions for our identity.  Possibly try to see whether we can
-    // change our fabric entry to not advertise and restart advertising.
+    NSEnumerator * devices;
+    {
+        std::lock_guard lock(*self.deviceMapLock);
+        devices = [self.nodeIDToDeviceMap objectEnumerator];
+    }
 
-    // TODO: What should happen with active commissioning sessions?  Presumably
-    // close them?
+    for (MTRDevice * device in devices) {
+        [device controllerSuspended];
+    }
+
+    // TODO: In the concrete class, consider what should happen with:
+    //
+    // * Active commissioning sessions (presumably close them?)
+    // * CASE sessions in general.
+    // * Possibly try to see whether we can change our fabric entry to not advertise and restart advertising.
 }
 
 - (void)resume
 {
+    MTR_LOG("%@ resuming", self);
+
+    std::lock_guard lock(_suspensionLock);
+
     _suspended = NO;
 
-    // TODO: In the concrete class (which is unused so far!), iterate our
-    // MTRDevices, tell them to restart subscriptions.
+    NSEnumerator * devices;
+    {
+        std::lock_guard lock(*self.deviceMapLock);
+        devices = [self.nodeIDToDeviceMap objectEnumerator];
+    }
+
+    for (MTRDevice * device in devices) {
+        [device controllerResumed];
+    }
 }
 
 - (BOOL)matchesPendingShutdownControllerWithOperationalCertificate:(nullable MTRCertificateDERBytes)operationalCertificate andRootCertificate:(nullable MTRCertificateDERBytes)rootCertificate
diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h b/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h
index 68d725f..171c91c 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerParameters.h
@@ -150,6 +150,11 @@
                 intermediateCertificate:(MTRCertificateDERBytes _Nullable)intermediateCertificate
                         rootCertificate:(MTRCertificateDERBytes)rootCertificate;
 
+/**
+ * The root certificate we were initialized with.
+ */
+@property (nonatomic, copy, readonly) MTRCertificateDERBytes rootCertificate MTR_NEWLY_AVAILABLE;
+
 @end
 
 MTR_NEWLY_AVAILABLE
diff --git a/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm b/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm
index fa2791a..6a64b6e 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceControllerStartupParams.mm
@@ -338,6 +338,9 @@
 @end
 
 @implementation MTRDeviceControllerExternalCertificateParameters
+
+@dynamic rootCertificate;
+
 - (instancetype)initWithStorageDelegate:(id<MTRDeviceControllerStorageDelegate>)storageDelegate
                    storageDelegateQueue:(dispatch_queue_t)storageDelegateQueue
                        uniqueIdentifier:(NSUUID *)uniqueIdentifier
diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm b/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm
index fba26d9..fbfc072 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_Concrete.mm
@@ -288,8 +288,6 @@
         _otaProviderDelegateQueue = otaProviderDelegateQueue;
         _chipWorkQueue = queue;
         _factory = factory;
-        // TODO: Shouldn't nodeIDToDeviceMap just be set up by initForSubclasses?
-        self.nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable];
         _serverEndpoints = [[NSMutableArray alloc] init];
         _commissionableBrowser = nil;
 
@@ -1416,6 +1414,15 @@
 
 - (void)getSessionForNode:(chip::NodeId)nodeID completion:(MTRInternalDeviceConnectionCallback)completion
 {
+    // TODO: Figure out whether the synchronization here makes sense.  What
+    // happens if this call happens mid-suspend or mid-resume?
+    if (self.suspended) {
+        MTR_LOG_ERROR("%@ suspended: can't get session for node %016llX-%016llx (%llu)", self, self.compressedFabricID.unsignedLongLongValue, nodeID, nodeID);
+        // TODO: Can we do a better error here?
+        completion(nullptr, chip::NullOptional, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INCORRECT_STATE], nil);
+        return;
+    }
+
     // Get the corresponding MTRDevice object to determine if the case/subscription pool is to be used
     MTRDevice * device = [self deviceForNodeID:@(nodeID)];
 
@@ -1440,6 +1447,15 @@
 
 - (void)directlyGetSessionForNode:(chip::NodeId)nodeID completion:(MTRInternalDeviceConnectionCallback)completion
 {
+    // TODO: Figure out whether the synchronization here makes sense.  What
+    // happens if this call happens mid-suspend or mid-resume?
+    if (self.suspended) {
+        MTR_LOG_ERROR("%@ suspended: can't get session for node %016llX-%016llx (%llu)", self, self.compressedFabricID.unsignedLongLongValue, nodeID, nodeID);
+        // TODO: Can we do a better error here?
+        completion(nullptr, chip::NullOptional, [MTRError errorForCHIPErrorCode:CHIP_ERROR_INCORRECT_STATE], nil);
+        return;
+    }
+
     [self
         asyncGetCommissionerOnMatterQueue:^(chip::Controller::DeviceCommissioner * commissioner) {
             auto connectionBridge = new MTRDeviceConnectionBridge(completion);
diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h b/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h
index 5d5acf8..643a232 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_Internal.h
@@ -66,7 +66,7 @@
 
 @interface MTRDeviceController ()
 
-@property (nonatomic, readwrite, nullable) NSMapTable * nodeIDToDeviceMap;
+@property (nonatomic, readonly) NSMapTable<NSNumber *, MTRDevice *> * nodeIDToDeviceMap;
 @property (readonly, assign) os_unfair_lock_t deviceMapLock;
 
 // queue used to serialize all work performed by the MTRDeviceController
diff --git a/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm b/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm
index 1bf0888..cb09fbd 100644
--- a/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm
+++ b/src/darwin/Framework/CHIP/MTRDeviceController_XPC.mm
@@ -113,7 +113,6 @@
         self.xpcConnection = connectionBlock();
         self.uniqueIdentifier = UUID;
         self.chipWorkQueue = dispatch_queue_create("MTRDeviceController_XPC_queue", DISPATCH_QUEUE_SERIAL_WITH_AUTORELEASE_POOL);
-        self.nodeIDToDeviceMap = [NSMapTable strongToWeakObjectsMapTable];
 
         MTR_LOG("Set up XPC Connection: %@", self.xpcConnection);
         if (self.xpcConnection) {
diff --git a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
index 52acb7c..b38f381 100644
--- a/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
+++ b/src/darwin/Framework/CHIP/MTRDevice_Concrete.mm
@@ -724,14 +724,23 @@
 {
     os_unfair_lock_assert_owner(&self->_lock);
 
-    // We should not allow a subscription for device controllers over XPC.
-    return ![_deviceController isKindOfClass:MTRDeviceControllerOverXPC.class];
+    // We should not allow a subscription for suspended controllers or device controllers over XPC.
+    return _deviceController.suspended == NO && ![_deviceController isKindOfClass:MTRDeviceControllerOverXPC.class];
 }
 
 - (void)_delegateAdded
 {
     os_unfair_lock_assert_owner(&self->_lock);
 
+    [super _delegateAdded];
+
+    [self _ensureSubscriptionForExistingDelegates:@"delegate is set"];
+}
+
+- (void)_ensureSubscriptionForExistingDelegates:(NSString *)reason
+{
+    os_unfair_lock_assert_owner(&self->_lock);
+
     __block BOOL shouldSetUpSubscription = [self _subscriptionsAllowed];
 
     // For unit testing only. If this ever changes to not being for unit testing purposes,
@@ -754,10 +763,10 @@
             MTR_LOG(" => %@ - device is a thread device, scheduling in pool", self);
             [self _scheduleSubscriptionPoolWork:^{
                 std::lock_guard lock(self->_lock);
-                [self _setupSubscriptionWithReason:@"delegate is set and scheduled subscription is happening"];
+                [self _setupSubscriptionWithReason:[NSString stringWithFormat:@"%@ and scheduled subscription is happening", reason]];
             } inNanoseconds:0 description:@"MTRDevice setDelegate first subscription"];
         } else {
-            [self _setupSubscriptionWithReason:@"delegate is set and subscription is needed"];
+            [self _setupSubscriptionWithReason:[NSString stringWithFormat:@"%@ and subscription is needed", reason]];
         }
     }
 }
@@ -1243,6 +1252,11 @@
 {
     os_unfair_lock_assert_owner(&_lock);
 
+    if (_deviceController.suspended) {
+        MTR_LOG("%@ ignoring expected subscription reset on controller suspend", self);
+        return;
+    }
+
     // If we are here, then either we failed to establish initial CASE, or we
     // failed to send the initial SubscribeRequest message, or our ReadClient
     // has given up completely.  Those all count as "we have tried and failed to
@@ -4002,6 +4016,34 @@
     return result;
 }
 
+- (void)controllerSuspended
+{
+    [super controllerSuspended];
+
+    std::lock_guard lock(self->_lock);
+    [self _resetSubscriptionWithReasonString:@"Controller suspended"];
+
+    // Ensure that any pre-existing resubscribe attempts we control don't try to
+    // do anything.
+    _reattemptingSubscription = NO;
+}
+
+- (void)controllerResumed
+{
+    [super controllerResumed];
+
+    std::lock_guard lock(self->_lock);
+
+    if (![self _delegateExists]) {
+        MTR_LOG("%@ ignoring controller resume: no delegates", self);
+        return;
+    }
+
+    // Use _ensureSubscriptionForExistingDelegates so that the subscriptions
+    // will go through the pool as needed, not necessarily happen immediately.
+    [self _ensureSubscriptionForExistingDelegates:@"Controller resumed"];
+}
+
 @end
 
 /* BEGIN DRAGONS: Note methods here cannot be renamed, and are used by private callers, do not rename, remove or modify behavior here */
diff --git a/src/darwin/Framework/CHIP/MTRDevice_Internal.h b/src/darwin/Framework/CHIP/MTRDevice_Internal.h
index 9627d4f..9905da7 100644
--- a/src/darwin/Framework/CHIP/MTRDevice_Internal.h
+++ b/src/darwin/Framework/CHIP/MTRDevice_Internal.h
@@ -196,6 +196,9 @@
 
 - (BOOL)_delegateExists;
 
+// Must be called by subclasses or MTRDevice implementation only.
+- (void)_delegateAdded;
+
 #ifdef DEBUG
 // Only used for unit test purposes - normal delegate should not expect or handle being called back synchronously
 // Returns YES if a delegate is called
@@ -205,6 +208,10 @@
 // Used to generate attribute report that contains all known attributes, taking into consideration expected values
 - (NSArray<NSDictionary<NSString *, id> *> *)getAllAttributesReport;
 
+// Hooks for controller suspend/resume.
+- (void)controllerSuspended;
+- (void)controllerResumed;
+
 @end
 
 #pragma mark - MTRDevice internal state monitoring
diff --git a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
index 35df347..ccce347 100644
--- a/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
+++ b/src/darwin/Framework/CHIPTests/MTRPerControllerStorageTests.m
@@ -262,11 +262,8 @@
                                                        nodeID:(NSNumber *)nodeID
                                                       storage:(MTRTestPerControllerStorage *)storage
                                         caseAuthenticatedTags:(NSSet * _Nullable)caseAuthenticatedTags
+                                               paramsModifier:(void (^_Nullable)(MTRDeviceControllerExternalCertificateParameters *))paramsModifier
                                                         error:(NSError * __autoreleasing *)error
-                                            certificateIssuer:
-                                                (MTRPerControllerStorageTestsCertificateIssuer * __autoreleasing *)certificateIssuer
-                               concurrentSubscriptionPoolSize:(NSUInteger)concurrentSubscriptionPoolSize
-                                 storageBehaviorConfiguration:(MTRDeviceStorageBehaviorConfiguration * _Nullable)storageBehaviorConfiguration
 {
     XCTAssertTrue(error != NULL);
 
@@ -295,28 +292,9 @@
                                                                                      intermediateCertificate:nil
                                                                                              rootCertificate:root];
     XCTAssertNotNil(params);
-    // TODO: This is only used by testControllerServer.  If that moves
-    // elsewhere, take this back out again.
-    params.shouldAdvertiseOperational = YES;
 
-    __auto_type * ourCertificateIssuer = [[MTRPerControllerStorageTestsCertificateIssuer alloc] initWithRootCertificate:root
-                                                                                                intermediateCertificate:nil
-                                                                                                             signingKey:rootKeys
-                                                                                                               fabricID:fabricID];
-    XCTAssertNotNil(ourCertificateIssuer);
-
-    if (certificateIssuer) {
-        *certificateIssuer = ourCertificateIssuer;
-    }
-
-    [params setOperationalCertificateIssuer:ourCertificateIssuer queue:dispatch_get_main_queue()];
-
-    if (concurrentSubscriptionPoolSize > 0) {
-        params.concurrentSubscriptionEstablishmentsAllowedOnThread = concurrentSubscriptionPoolSize;
-    }
-
-    if (storageBehaviorConfiguration) {
-        params.storageBehaviorConfiguration = storageBehaviorConfiguration;
+    if (paramsModifier) {
+        paramsModifier(params);
     }
 
     return [[MTRDeviceController alloc] initWithParameters:params error:error];
@@ -327,6 +305,52 @@
                                                      fabricID:(NSNumber *)fabricID
                                                        nodeID:(NSNumber *)nodeID
                                                       storage:(MTRTestPerControllerStorage *)storage
+                                        caseAuthenticatedTags:(NSSet * _Nullable)caseAuthenticatedTags
+                                                        error:(NSError * __autoreleasing *)error
+                                            certificateIssuer:
+                                                (MTRPerControllerStorageTestsCertificateIssuer * __autoreleasing *)certificateIssuer
+                               concurrentSubscriptionPoolSize:(NSUInteger)concurrentSubscriptionPoolSize
+                                 storageBehaviorConfiguration:(MTRDeviceStorageBehaviorConfiguration * _Nullable)storageBehaviorConfiguration
+{
+    return [self startControllerWithRootKeys:rootKeys
+                             operationalKeys:operationalKeys
+                                    fabricID:fabricID
+                                      nodeID:nodeID
+                                     storage:storage
+                       caseAuthenticatedTags:caseAuthenticatedTags
+                              paramsModifier:^(MTRDeviceControllerExternalCertificateParameters * params) {
+                                  // TODO: This is only used by testControllerServer.  If that moves
+                                  // elsewhere, take this back out again.
+                                  params.shouldAdvertiseOperational = YES;
+
+                                  __auto_type * ourCertificateIssuer = [[MTRPerControllerStorageTestsCertificateIssuer alloc] initWithRootCertificate:params.rootCertificate
+                                                                                                                              intermediateCertificate:nil
+                                                                                                                                           signingKey:rootKeys
+                                                                                                                                             fabricID:fabricID];
+                                  XCTAssertNotNil(ourCertificateIssuer);
+
+                                  if (certificateIssuer) {
+                                      *certificateIssuer = ourCertificateIssuer;
+                                  }
+
+                                  [params setOperationalCertificateIssuer:ourCertificateIssuer queue:dispatch_get_main_queue()];
+
+                                  if (concurrentSubscriptionPoolSize > 0) {
+                                      params.concurrentSubscriptionEstablishmentsAllowedOnThread = concurrentSubscriptionPoolSize;
+                                  }
+
+                                  if (storageBehaviorConfiguration) {
+                                      params.storageBehaviorConfiguration = storageBehaviorConfiguration;
+                                  }
+                              }
+                                       error:error];
+}
+
+- (nullable MTRDeviceController *)startControllerWithRootKeys:(MTRTestKeys *)rootKeys
+                                              operationalKeys:(MTRTestKeys *)operationalKeys
+                                                     fabricID:(NSNumber *)fabricID
+                                                       nodeID:(NSNumber *)nodeID
+                                                      storage:(MTRTestPerControllerStorage *)storage
                                         caseAuthenticatedTags:(nullable NSSet *)caseAuthenticatedTags
                                                         error:(NSError * __autoreleasing *)error
                                             certificateIssuer:
@@ -462,6 +486,7 @@
     XCTAssertNil(error);
     XCTAssertNotNil(controller);
     XCTAssertTrue([controller isRunning]);
+    XCTAssertFalse(controller.suspended);
 
     XCTAssertEqualObjects(controller.controllerNodeID, nodeID);
 
@@ -1611,6 +1636,128 @@
     [self doDataStoreMTRDeviceTestWithStorageDelegate:[[MTRTestPerControllerStorage alloc] initWithControllerID:[NSUUID UUID]] disableStorageBehaviorOptimization:YES];
 }
 
+// TODO: Factor out startControllerWithRootKeys into a test helper, move these
+// suspension tests to a different file.
+- (void)test012_startSuspended
+{
+    NSError * error;
+    __auto_type * storageDelegate = [[MTRTestPerControllerStorage alloc] initWithControllerID:[NSUUID UUID]];
+    __auto_type * controller = [self startControllerWithRootKeys:[[MTRTestKeys alloc] init]
+                                                 operationalKeys:[[MTRTestKeys alloc] init]
+                                                        fabricID:@555
+                                                          nodeID:@888
+                                                         storage:storageDelegate
+                                           caseAuthenticatedTags:nil
+                                                  paramsModifier:^(MTRDeviceControllerExternalCertificateParameters * params) {
+                                                      params.startSuspended = YES;
+                                                  }
+                                                           error:&error];
+
+    XCTAssertNil(error);
+    XCTAssertNotNil(controller);
+    XCTAssertTrue(controller.running);
+    XCTAssertTrue(controller.suspended);
+    [controller shutdown];
+}
+
+- (void)test013_suspendDevices
+{
+    NSNumber * deviceID = @(17);
+    __auto_type * device = [self getMTRDevice:deviceID];
+    __auto_type * controller = device.deviceController;
+
+    XCTAssertFalse(controller.suspended);
+
+    __auto_type queue = dispatch_get_main_queue();
+    __auto_type * delegate = [[MTRDeviceTestDelegate alloc] init];
+
+    XCTestExpectation * initialSubscriptionExpectation = [self expectationWithDescription:@"Subscription has been set up"];
+    XCTestExpectation * initialReachableExpectation = [self expectationWithDescription:@"Device initially became reachable"];
+    XCTestExpectation * initialUnreachableExpectation = [self expectationWithDescription:@"Device initially became unreachable"];
+    initialUnreachableExpectation.inverted = YES;
+
+    delegate.onReachable = ^{
+        [initialReachableExpectation fulfill];
+    };
+
+    delegate.onNotReachable = ^{
+        // We do not expect to land here.
+        [initialUnreachableExpectation fulfill];
+    };
+
+    delegate.onReportEnd = ^{
+        [initialSubscriptionExpectation fulfill];
+    };
+
+    [device setDelegate:delegate queue:queue];
+    [self waitForExpectations:@[ initialReachableExpectation, initialSubscriptionExpectation ] timeout:60];
+    // Separately wait for the unreachable bit, so we don't end up waiting 60
+    // seconds for it.
+    [self waitForExpectations:@[ initialUnreachableExpectation ] timeout:0];
+
+    // Test that sending a command works.  Clear the delegate's onReportEnd
+    // first, so reports from the command don't trigger it.
+    delegate.onReportEnd = nil;
+    XCTestExpectation * toggle1Expectation = [self expectationWithDescription:@"toggle 1"];
+    __auto_type * cluster = [[MTRClusterOnOff alloc] initWithDevice:device endpointID:@(1) queue:queue];
+    [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) {
+        XCTAssertNil(error);
+        [toggle1Expectation fulfill];
+    }];
+
+    [self waitForExpectations:@[ toggle1Expectation ] timeout:kTimeoutInSeconds];
+
+    XCTestExpectation * becameUnreachableExpectation = [self expectationWithDescription:@"Device became unreachable"];
+    delegate.onNotReachable = ^{
+        [becameUnreachableExpectation fulfill];
+    };
+
+    [controller suspend];
+    XCTAssertTrue(controller.suspended);
+
+    // Test that sending a command no longer works.
+    XCTestExpectation * toggle2Expectation = [self expectationWithDescription:@"toggle 2"];
+    [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) {
+        XCTAssertNotNil(error);
+        [toggle2Expectation fulfill];
+    }];
+
+    [self waitForExpectations:@[ becameUnreachableExpectation, toggle2Expectation ] timeout:kTimeoutInSeconds];
+
+    XCTestExpectation * newSubscriptionExpectation = [self expectationWithDescription:@"Subscription has been set up again"];
+    XCTestExpectation * newReachableExpectation = [self expectationWithDescription:@"Device became reachable again"];
+    delegate.onReachable = ^{
+        [newReachableExpectation fulfill];
+    };
+
+    delegate.onReportEnd = ^{
+        [newSubscriptionExpectation fulfill];
+    };
+
+    [controller resume];
+    XCTAssertFalse(controller.suspended);
+
+    [self waitForExpectations:@[ newSubscriptionExpectation, newReachableExpectation ] timeout:kTimeoutInSeconds];
+
+    // Test that sending a command works again.  Clear the delegate's onReportEnd
+    // first, so reports from the command don't trigger it.
+    delegate.onReportEnd = nil;
+    XCTestExpectation * toggle3Expectation = [self expectationWithDescription:@"toggle 3"];
+    [cluster toggleWithExpectedValues:nil expectedValueInterval:nil completion:^(NSError * _Nullable error) {
+        XCTAssertNil(error);
+        [toggle3Expectation fulfill];
+    }];
+
+    [self waitForExpectations:@[ toggle3Expectation ] timeout:kTimeoutInSeconds];
+
+    [controller removeDevice:device];
+    // Reset our commissionee.
+    __auto_type * baseDevice = [MTRBaseDevice deviceWithNodeID:deviceID controller:controller];
+    ResetCommissionee(baseDevice, queue, self, kTimeoutInSeconds);
+
+    [controller shutdown];
+}
+
 // TODO: This might want to go in a separate test file, with some shared setup
 // across multiple tests, maybe.  Would need to factor out
 // startControllerWithRootKeys into a test helper.