diff --git a/Source/ARTClientOptions.h b/Source/ARTClientOptions.h index bc920cfee..bf5a70379 100644 --- a/Source/ARTClientOptions.h +++ b/Source/ARTClientOptions.h @@ -50,6 +50,12 @@ ART_ASSUME_NONNULL_BEGIN */ @property (readwrite, assign, nonatomic) NSTimeInterval suspendedRetryTimeout; +/** + Represents the timeout (in seconds) to re-attach the channel automatically. + When a channel becomes SUSPENDED following a server initiated DETACHED, after this delay in milliseconds, if the channel is still SUSPENDED and the connection is CONNECTED, the client library will attempt to re-attach. + */ +@property (readwrite, assign, nonatomic) NSTimeInterval channelRetryTimeout; + /** Timeout for opening the connection, available in the client library if supported by the transport. */ diff --git a/Source/ARTClientOptions.m b/Source/ARTClientOptions.m index d7e6488b7..1ff1f0ace 100644 --- a/Source/ARTClientOptions.m +++ b/Source/ARTClientOptions.m @@ -37,6 +37,7 @@ - (instancetype)initDefaults { _logLevel = ARTLogLevelNone; _disconnectedRetryTimeout = 15.0; //Seconds _suspendedRetryTimeout = 30.0; //Seconds + _channelRetryTimeout = 15.0; //Seconds _httpOpenTimeout = 4.0; //Seconds _httpRequestTimeout = 15.0; //Seconds _httpMaxRetryDuration = 10.0; //Seconds @@ -109,6 +110,7 @@ - (id)copyWithZone:(NSZone *)zone { options.logHandler = self.logHandler; options.suspendedRetryTimeout = self.suspendedRetryTimeout; options.disconnectedRetryTimeout = self.disconnectedRetryTimeout; + options.channelRetryTimeout = self.channelRetryTimeout; options.httpMaxRetryCount = self.httpMaxRetryCount; options.httpMaxRetryDuration = self.httpMaxRetryDuration; options.httpOpenTimeout = self.httpOpenTimeout; diff --git a/Source/ARTRealtimeChannel.m b/Source/ARTRealtimeChannel.m index e81b46e7e..c7ea3f919 100644 --- a/Source/ARTRealtimeChannel.m +++ b/Source/ARTRealtimeChannel.m @@ -309,7 +309,10 @@ - (ARTEventListener *)timed:(ARTEventListener *)listener deadline:(NSTimeInterva - (void)transition:(ARTRealtimeChannelState)state status:(ARTStatus *)status { ARTChannelStateChange *stateChange = [[ARTChannelStateChange alloc] initWithCurrent:state previous:self.state reason:status.errorInfo]; self.state = state; - _errorReason = status.errorInfo; + + if (status.storeErrorInfo) { + _errorReason = status.errorInfo; + } if (state == ARTRealtimeChannelFailed) { [_attachedEventEmitter emit:[NSNull null] with:status.errorInfo]; @@ -410,28 +413,34 @@ - (void)setAttached:(ARTProtocolMessage *)message { [self sendQueuedMessages]; - if (message.error) { - _errorReason = message.error; - [self transition:ARTRealtimeChannelAttached status:[ARTStatus state:ARTStateError info:message.error]]; - } - else { - [self transition:ARTRealtimeChannelAttached status:[ARTStatus state:ARTStateOk]]; - } + ARTStatus *status = message.error ? [ARTStatus state:ARTStateError info:message.error] : [ARTStatus state:ARTStateOk]; + [self transition:ARTRealtimeChannelAttached status:status]; [_attachedEventEmitter emit:[NSNull null] with:nil]; } - (void)setDetached:(ARTProtocolMessage *)message { - if (self.state == ARTRealtimeChannelFailed) { - return; + switch (self.state) { + case ARTRealtimeChannelAttached: + case ARTRealtimeChannelSuspended: + [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p reattach initiated by DETACHED message", _realtime, self]; + [self reattach:nil withReason:message.error]; + return; + case ARTRealtimeChannelAttaching: { + [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p reattach initiated by DETACHED message but it is currently attaching", _realtime, self]; + ARTStatus *status = message.error ? [ARTStatus state:ARTStateError info:message.error] : [ARTStatus state:ARTStateOk]; + status.storeErrorInfo = false; + [self setSuspended:status retryIn:0]; + return; + } + case ARTRealtimeChannelFailed: + return; + default: + break; } + self.attachSerial = nil; - ARTErrorInfo *errorInfo; - if (message.error) { - errorInfo = message.error; - } else { - errorInfo = [ARTErrorInfo createWithCode:0 message:@"channel has detached"]; - } + ARTErrorInfo *errorInfo = message.error ? message.error : [ARTErrorInfo createWithCode:0 message:@"channel has detached"]; ARTStatus *reason = [ARTStatus state:ARTStateNotAttached info:errorInfo]; [self detachChannel:reason]; [_detachedEventEmitter emit:[NSNull null] with:nil]; @@ -447,9 +456,19 @@ - (void)setFailed:(ARTStatus *)error { [self transition:ARTRealtimeChannelFailed status:error]; } -- (void)setSuspended:(ARTStatus *)error { - [self failQueuedMessages:error]; - [self transition:ARTRealtimeChannelSuspended status:error]; +- (void)setSuspended:(ARTStatus *)status { + [self setSuspended:status retryIn:self.realtime.options.channelRetryTimeout]; +} + +- (void)setSuspended:(ARTStatus *)status retryIn:(NSTimeInterval)retryTimeout { + [self failQueuedMessages:status]; + [self transition:ARTRealtimeChannelSuspended status:status]; + __weak __typeof(self) weakSelf = self; + [self unlessStateChangesBefore:retryTimeout do:^{ + [weakSelf reattach:^(ARTErrorInfo *errorInfo) { + [weakSelf setSuspended:[ARTStatus state:ARTStateError info:errorInfo]]; + } withReason:nil]; + }]; } - (void)onMessage:(ARTProtocolMessage *)message { @@ -538,7 +557,7 @@ - (void)broadcastPresence:(ARTPresenceMessage *)pm { } - (void)onError:(ARTProtocolMessage *)msg { - [self transition:ARTRealtimeChannelFailed status:[ARTStatus state:ARTStateError info: msg.error]]; + [self transition:ARTRealtimeChannelFailed status:[ARTStatus state:ARTStateError info:msg.error]]; [self failQueuedMessages:[ARTStatus state:ARTStateError info: msg.error]]; } @@ -546,7 +565,7 @@ - (void)attach { [self attach:nil]; } -- (void)attach:(void (^)(ARTErrorInfo * _Nullable))callback { +- (void)attach:(void (^)(ARTErrorInfo *))callback { switch (self.state) { case ARTRealtimeChannelAttaching: [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p already attaching", _realtime, self]; @@ -556,19 +575,42 @@ - (void)attach:(void (^)(ARTErrorInfo * _Nullable))callback { [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p already attached", _realtime, self]; if (callback) callback(nil); return; + default: + break; + } + [self internalAttach:callback withReason:nil]; +} + +- (void)reattach:(void (^)(ARTErrorInfo *))callback withReason:(ARTErrorInfo *)reason { + switch (self.state) { + case ARTRealtimeChannelAttached: + case ARTRealtimeChannelSuspended: + [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p attached or suspended and will reattach", _realtime, self]; + break; + case ARTRealtimeChannelAttaching: + [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p already attaching", _realtime, self]; + if (callback) [_attachedEventEmitter once:callback]; + return; + default: + break; + } + [self internalAttach:callback withReason:reason]; +} + +- (void)internalAttach:(void (^)(ARTErrorInfo *))callback withReason:(ARTErrorInfo *)reason { + switch (self.state) { case ARTRealtimeChannelDetaching: { NSString *msg = @"can't attach when in DETACHING state"; [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p %@", _realtime, self, msg]; if (callback) callback([ARTErrorInfo createWithCode:90000 message:msg]); return; } - case ARTRealtimeChannelFailed: - _errorReason = nil; - break; default: break; } - + + _errorReason = nil; + if (![self.realtime isActive]) { [self.realtime.logger debug:__FILE__ line:__LINE__ message:@"R:%p C:%p can't attach when not in an active state", _realtime, self]; if (callback) callback([ARTErrorInfo createWithCode:90000 message:@"Can't attach when not in an active state"]); @@ -577,7 +619,9 @@ - (void)attach:(void (^)(ARTErrorInfo * _Nullable))callback { if (callback) [_attachedEventEmitter once:callback]; // Set state: Attaching - [self transition:ARTRealtimeChannelAttaching status:[ARTStatus state:ARTStateOk]]; + ARTStatus *status = reason ? [ARTStatus state:ARTStateError info:reason] : [ARTStatus state:ARTStateOk]; + status.storeErrorInfo = false; + [self transition:ARTRealtimeChannelAttaching status:status]; [self attachAfterChecks:callback]; } @@ -595,8 +639,7 @@ - (void)attachAfterChecks:(void (^)(ARTErrorInfo * _Nullable))callback { timeouted = true; ARTErrorInfo *errorInfo = [ARTErrorInfo createWithCode:ARTStateAttachTimedOut message:@"attach timed out"]; ARTStatus *status = [ARTStatus state:ARTStateAttachTimedOut info:errorInfo]; - _errorReason = errorInfo; - [self transition:ARTRealtimeChannelFailed status:status]; + [self setSuspended:status]; [_attachedEventEmitter emit:[NSNull null] with:errorInfo]; }]; @@ -659,7 +702,6 @@ - (void)detachAfterChecks:(void (^)(ARTErrorInfo * _Nullable))callback { timeouted = true; ARTErrorInfo *errorInfo = [ARTErrorInfo createWithCode:ARTStateDetachTimedOut message:@"detach timed out"]; ARTStatus *status = [ARTStatus state:ARTStateDetachTimedOut info:errorInfo]; - _errorReason = errorInfo; [self transition:ARTRealtimeChannelFailed status:status]; [_detachedEventEmitter emit:[NSNull null] with:errorInfo]; }]; diff --git a/Source/ARTStatus.h b/Source/ARTStatus.h index eff33287a..7e7c53c38 100644 --- a/Source/ARTStatus.h +++ b/Source/ARTStatus.h @@ -79,6 +79,7 @@ FOUNDATION_EXPORT NSString *const ARTAblyMessageNoMeansToRenewToken; @interface ARTStatus : NSObject @property (art_nullable, readonly, strong, nonatomic) ARTErrorInfo *errorInfo; +@property (nonatomic, assign) BOOL storeErrorInfo; @property (nonatomic, assign) ARTState state; + (ARTStatus *)state:(ARTState) state; diff --git a/Source/ARTStatus.m b/Source/ARTStatus.m index cc666d136..c852d5c1b 100644 --- a/Source/ARTStatus.m +++ b/Source/ARTStatus.m @@ -63,6 +63,7 @@ - (instancetype)init { if (self) { _state = ARTStateOk; _errorInfo = nil; + _storeErrorInfo = false; } return self; } @@ -76,6 +77,7 @@ + (ARTStatus *)state:(ARTState)state { + (ARTStatus *)state:(ARTState)state info:(ARTErrorInfo *)info { ARTStatus * s = [ARTStatus state:state]; s.errorInfo = info; + s.storeErrorInfo = true; return s; } diff --git a/Spec/RealtimeClientChannel.swift b/Spec/RealtimeClientChannel.swift index 6b88b0536..6050385a6 100644 --- a/Spec/RealtimeClientChannel.swift +++ b/Spec/RealtimeClientChannel.swift @@ -848,34 +848,47 @@ class RealtimeClientChannel: QuickSpec { } // RTL4f - it("should transition the channel state to FAILED if ATTACHED ProtocolMessage is not received") { - let previousRealtimeRequestTimeout = ARTDefault.realtimeRequestTimeout() - defer { ARTDefault.setRealtimeRequestTimeout(previousRealtimeRequestTimeout) } - ARTDefault.setRealtimeRequestTimeout(3.0) + it("should transition the channel state to SUSPENDED if ATTACHED ProtocolMessage is not received") { let options = AblyTests.commonAppSetup() - options.autoConnect = false - let client = ARTRealtime(options: options) - client.setTransportClass(TestProxyTransport.self) - client.connect() + options.channelRetryTimeout = 1.0 + let client = AblyTests.newRealtime(options) defer { client.dispose(); client.close() } expect(client.connection.state).toEventually(equal(ARTRealtimeConnectionState.Connected), timeout: testTimeout) - let transport = client.transport as! TestProxyTransport + + let previousRealtimeRequestTimeout = ARTDefault.realtimeRequestTimeout() + defer { ARTDefault.setRealtimeRequestTimeout(previousRealtimeRequestTimeout) } + ARTDefault.setRealtimeRequestTimeout(1.0) + + guard let transport = client.transport as? TestProxyTransport else { + fail("TestProxyTransport is not set"); return + } transport.actionsIgnored += [.Attached] - var callbackCalled = false let channel = client.channels.get("test") - channel.attach { errorInfo in - expect(errorInfo).toNot(beNil()) - expect(errorInfo).to(equal(channel.errorReason)) - callbackCalled = true + waitUntil(timeout: testTimeout) { done in + channel.attach { errorInfo in + expect(errorInfo).toNot(beNil()) + expect(errorInfo).to(equal(channel.errorReason)) + done() + } } - let start = NSDate() - expect(channel.state).toEventually(equal(ARTRealtimeChannelState.Failed), timeout: testTimeout) + expect(channel.state).toEventually(equal(ARTRealtimeChannelState.Suspended), timeout: testTimeout) expect(channel.errorReason).toNot(beNil()) - expect(callbackCalled).to(beTrue()) - let end = NSDate() - expect(start.dateByAddingTimeInterval(3.0)).to(beCloseTo(end, within: 0.5)) + + transport.actionsIgnored = [] + // Automatically re-attached + waitUntil(timeout: testTimeout) { done in + let partialDone = AblyTests.splitDone(2, done: done) + channel.once(.Attaching) { stateChange in + expect(stateChange?.reason).to(beNil()) + partialDone() + } + channel.once(.Attached) { stateChange in + expect(stateChange?.reason).to(beNil()) + partialDone() + } + } } it("if called with a callback should call it once attached") { @@ -2611,6 +2624,263 @@ class RealtimeClientChannel: QuickSpec { expect(channel.state).to(equal(ARTRealtimeChannelState.Attached)) } + // RTL13 + context("if the channel receives a server initiated DETACHED message when") { + + // RTL13a + it("the channel is in the ATTACHED states, an attempt to reattach the channel should be made immediately by sending a new ATTACH message and the channel should transition to the ATTACHING state with the error emitted in the ChannelStateChange event") { + let client = AblyTests.newRealtime(AblyTests.commonAppSetup()) + defer { client.dispose(); client.close() } + let channel = client.channels.get("foo") + + waitUntil(timeout: testTimeout) { done in + channel.attach() { error in + expect(error).to(beNil()) + done() + } + } + + expect(channel.state).to(equal(ARTRealtimeChannelState.Attached)) + + guard let transport = client.transport as? TestProxyTransport else { + fail("TestProxyTransport is not set"); return + } + + waitUntil(timeout: testTimeout) { done in + let detachedMessageWithError = AblyTests.newErrorProtocolMessage() + detachedMessageWithError.action = .Detached + detachedMessageWithError.channel = "foo" + + channel.once(.Attaching) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error).to(beIdenticalTo(detachedMessageWithError.error)) + expect(channel.errorReason).to(beNil()) + done() + } + + transport.receive(detachedMessageWithError) + } + + expect(transport.protocolMessagesSent.filter{ $0.action == .Attach }).to(haveCount(2)) + } + + // RTL13a + it("the channel is in the SUSPENDED state, an attempt to reattach the channel should be made immediately by sending a new ATTACH message and the channel should transition to the ATTACHING state with the error emitted in the ChannelStateChange event") { + let client = AblyTests.newRealtime(AblyTests.commonAppSetup()) + defer { client.dispose(); client.close() } + let channel = client.channels.get("foo") + + waitUntil(timeout: testTimeout) { done in + client.connection.once(.Connected) { stateChange in + expect(stateChange?.reason).to(beNil()) + done() + } + } + + guard let transport = client.transport as? TestProxyTransport else { + fail("TestProxyTransport is not set"); return + } + + let previousRealtimeRequestTimeout = ARTDefault.realtimeRequestTimeout() + defer { ARTDefault.setRealtimeRequestTimeout(previousRealtimeRequestTimeout) } + ARTDefault.setRealtimeRequestTimeout(1.0) + + // Timeout + transport.actionsIgnored += [.Attached] + + waitUntil(timeout: testTimeout) { done in + channel.once(.Suspended) { stateChange in + expect(stateChange?.reason?.message).to(contain("timed out")) + done() + } + channel.attach() + } + + transport.actionsIgnored = [] + + waitUntil(timeout: testTimeout) { done in + let detachedMessageWithError = AblyTests.newErrorProtocolMessage() + detachedMessageWithError.action = .Detached + detachedMessageWithError.channel = "foo" + + channel.once(.Attaching) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error).to(beIdenticalTo(detachedMessageWithError.error)) + expect(channel.errorReason).to(beNil()) + done() + } + + transport.receive(detachedMessageWithError) + } + + expect(transport.protocolMessagesSent.filter{ $0.action == .Attach }).to(haveCount(2)) + } + + // RTL13b + it("if the attempt to re-attach fails the channel will transition to the SUSPENDED state and the error will be emitted in the ChannelStateChange event") { + let options = AblyTests.commonAppSetup() + options.channelRetryTimeout = 1.0 + let client = AblyTests.newRealtime(options) + defer { client.dispose(); client.close() } + let channel = client.channels.get("foo") + + waitUntil(timeout: testTimeout) { done in + channel.attach { error in + expect(error).to(beNil()) + done() + } + } + + guard let transport = client.transport as? TestProxyTransport else { + fail("TestProxyTransport is not set"); return + } + + let previousRealtimeRequestTimeout = ARTDefault.realtimeRequestTimeout() + defer { ARTDefault.setRealtimeRequestTimeout(previousRealtimeRequestTimeout) } + ARTDefault.setRealtimeRequestTimeout(1.0) + transport.actionsIgnored = [.Attached] + + let detachedMessageWithError = AblyTests.newErrorProtocolMessage() + detachedMessageWithError.action = .Detached + detachedMessageWithError.channel = "foo" + + waitUntil(timeout: testTimeout) { done in + channel.once(.Attaching) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error).to(beIdenticalTo(detachedMessageWithError.error)) + expect(channel.errorReason).to(beNil()) + done() + } + + transport.receive(detachedMessageWithError) + } + + waitUntil(timeout: testTimeout) { done in + channel.once(.Suspended) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error.message).to(contain("timed out")) + expect(channel.errorReason).to(beIdenticalTo(error)) + done() + } + } + + let start = NSDate() + waitUntil(timeout: testTimeout) { done in + channel.once(.Attaching) { _ in + let end = NSDate() + expect(start.dateByAddingTimeInterval(options.channelRetryTimeout)).to(beCloseTo(end, within: 0.5)) + done() + } + } + } + + // RTL13b + it("if the channel was already in the ATTACHING state, the channel will transition to the SUSPENDED state and the error will be emitted in the ChannelStateChange event") { + let options = AblyTests.commonAppSetup() + options.channelRetryTimeout = 1.0 + let client = AblyTests.newRealtime(options) + defer { client.dispose(); client.close() } + let channel = client.channels.get("foo") + + let detachedMessageWithError = AblyTests.newErrorProtocolMessage() + detachedMessageWithError.action = .Detached + detachedMessageWithError.channel = "foo" + + waitUntil(timeout: testTimeout) { done in + let partialDone = AblyTests.splitDone(2, done: done) + channel.once(.Attaching) { stateChange in + expect(stateChange?.reason).to(beNil()) + client.transport?.receive(detachedMessageWithError) + partialDone() + } + channel.once(.Suspended) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); partialDone(); return + } + expect(error).to(beIdenticalTo(detachedMessageWithError.error)) + expect(channel.errorReason).to(beNil()) + + // Check retry + let start = NSDate() + channel.once(.Attaching) { stateChange in + let end = NSDate() + expect(start).to(beCloseTo(end, within: 0.5)) + expect(stateChange?.reason).to(beNil()) + partialDone() + } + } + channel.attach() + } + } + + // RTL13c + it("if the connection is no longer CONNECTED, then the automatic attempts to re-attach the channel must be cancelled") { + let options = AblyTests.commonAppSetup() + options.channelRetryTimeout = 1.0 + let client = AblyTests.newRealtime(options) + defer { client.dispose(); client.close() } + let channel = client.channels.get("foo") + waitUntil(timeout: testTimeout) { done in + channel.attach { error in + expect(error).to(beNil()) + done() + } + } + guard let transport = client.transport as? TestProxyTransport else { + fail("TestProxyTransport is not set"); return + } + + let previousRealtimeRequestTimeout = ARTDefault.realtimeRequestTimeout() + defer { ARTDefault.setRealtimeRequestTimeout(previousRealtimeRequestTimeout) } + ARTDefault.setRealtimeRequestTimeout(1.0) + + transport.actionsIgnored = [.Attached] + let detachedMessageWithError = AblyTests.newErrorProtocolMessage() + detachedMessageWithError.action = .Detached + detachedMessageWithError.channel = "foo" + waitUntil(timeout: testTimeout) { done in + channel.once(.Attaching) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error).to(beIdenticalTo(detachedMessageWithError.error)) + expect(channel.errorReason).to(beNil()) + done() + } + transport.receive(detachedMessageWithError) + } + waitUntil(timeout: testTimeout) { done in + channel.once(.Suspended) { stateChange in + guard let error = stateChange?.reason else { + fail("Reason error is nil"); done(); return + } + expect(error.message).to(contain("timed out")) + expect(channel.errorReason).to(beIdenticalTo(error)) + done() + } + } + + channel.once(.Attaching) { _ in + fail("Should cancel the re-attach") + } + + client.simulateSuspended(beforeSuspension: { done in + channel.once(.Suspended) { _ in + done() + } + }) + } + + } + // RTL14 it("If an ERROR ProtocolMessage is received for this channel then the channel should immediately transition to the FAILED state, the errorReason should be set and an error should be emitted on the channel") { let client = ARTRealtime(options: AblyTests.commonAppSetup()) @@ -2642,6 +2912,7 @@ class RealtimeClientChannel: QuickSpec { expect(channel.state).to(equal(ARTRealtimeChannelState.Failed)) } + } context("crypto") { @@ -2730,30 +3001,6 @@ class RealtimeClientChannel: QuickSpec { } } - // https://github.com/ably/ably-ios/issues/454 - it("should not move to FAILED if received DETACH with an error") { - let options = AblyTests.commonAppSetup() - let client = ARTRealtime(options: options) - defer { - client.dispose() - client.close() - } - let channel = client.channels.get("test") - channel.attach() - - expect(channel.state).toEventually(equal(ARTRealtimeChannelState.Attached), timeout: testTimeout) - - let protoMsg = ARTProtocolMessage() - protoMsg.action = .Detach - protoMsg.error = ARTErrorInfo.createWithCode(123, message: "test error") - protoMsg.channel = "test" - client.transport?.receive(protoMsg) - - expect(channel.state).to(equal(ARTRealtimeChannelState.Detached)) - expect(channel.errorReason).to(equal(protoMsg.error)) - expect(client.connection.state).to(equal(ARTRealtimeConnectionState.Connected)) - expect(client.connection.errorReason).to(beNil()) - } } } }