Skip to content

Commit 33bfd06

Browse files
authored
Merge pull request #68 from pusher/more-robust-reconnect
More robust reconnect
2 parents 2049d72 + 17275c2 commit 33bfd06

File tree

7 files changed

+174
-35
lines changed

7 files changed

+174
-35
lines changed

Example/Main.storyboard

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2-
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="7702" systemVersion="14D136" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="yEm-Wa-8Rj">
2+
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="yEm-Wa-8Rj">
33
<dependencies>
4-
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="7701"/>
4+
<deployment identifier="iOS"/>
5+
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
56
</dependencies>
67
<scenes>
78
<!--View Controller-->
@@ -13,14 +14,39 @@
1314
<viewControllerLayoutGuide type="bottom" id="r12-OR-bkP"/>
1415
</layoutGuides>
1516
<view key="view" contentMode="scaleToFill" id="y96-ws-RaM">
16-
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
17+
<rect key="frame" x="0.0" y="0.0" width="320" height="568"/>
1718
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
19+
<subviews>
20+
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="pak-HB-Rmg">
21+
<rect key="frame" x="122" y="132" width="78" height="30"/>
22+
<state key="normal" title="Disconnect"/>
23+
<connections>
24+
<action selector="disconnectButton:" destination="yEm-Wa-8Rj" eventType="touchUpInside" id="DIg-Tc-6e2"/>
25+
</connections>
26+
</button>
27+
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="zrh-9X-Fym">
28+
<rect key="frame" x="122" y="38" width="78" height="30"/>
29+
<state key="normal" title="Connect"/>
30+
<connections>
31+
<action selector="connectButton:" destination="yEm-Wa-8Rj" eventType="touchUpInside" id="Hla-Ru-KoR"/>
32+
</connections>
33+
</button>
34+
</subviews>
1835
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
36+
<constraints>
37+
<constraint firstItem="pak-HB-Rmg" firstAttribute="width" secondItem="zrh-9X-Fym" secondAttribute="width" id="4lR-eK-ZIZ"/>
38+
<constraint firstItem="pak-HB-Rmg" firstAttribute="centerX" secondItem="y96-ws-RaM" secondAttribute="centerX" constant="1" id="55L-vA-Tvc"/>
39+
<constraint firstItem="zrh-9X-Fym" firstAttribute="centerX" secondItem="y96-ws-RaM" secondAttribute="centerX" constant="1" id="BgE-CD-tkr"/>
40+
<constraint firstItem="pak-HB-Rmg" firstAttribute="height" secondItem="zrh-9X-Fym" secondAttribute="height" id="VhG-sC-yO0"/>
41+
<constraint firstItem="pak-HB-Rmg" firstAttribute="top" secondItem="fji-4t-sKP" secondAttribute="bottom" constant="112" id="aX3-md-844"/>
42+
<constraint firstItem="zrh-9X-Fym" firstAttribute="top" secondItem="fji-4t-sKP" secondAttribute="bottom" constant="18" id="goe-io-Zww"/>
43+
</constraints>
1944
</view>
45+
<simulatedScreenMetrics key="simulatedDestinationMetrics" type="retina4"/>
2046
</viewController>
2147
<placeholder placeholderIdentifier="IBFirstResponder" id="weS-V8-jg5" userLabel="First Responder" sceneMemberID="firstResponder"/>
2248
</objects>
23-
<point key="canvasLocation" x="3746" y="98"/>
49+
<point key="canvasLocation" x="3745.5" y="97.5"/>
2450
</scene>
2551
</scenes>
2652
</document>

Example/ViewController.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,23 @@ import UIKit
1010
import PusherSwift
1111

1212
class ViewController: UIViewController, ConnectionStateChangeDelegate {
13+
var pusher: Pusher! = nil
14+
15+
@IBAction func connectButton(sender: AnyObject) {
16+
pusher.connect()
17+
}
18+
19+
@IBAction func disconnectButton(sender: AnyObject) {
20+
pusher.disconnect()
21+
}
1322

1423
override func viewDidLoad() {
1524
super.viewDidLoad()
1625

1726
// Only use your secret here for testing or if you're sure that there's
1827
// no security risk
1928
let pusherClientOptions = PusherClientOptions(authMethod: .Internal(secret: "YOUR_APP_SECRET"))
20-
let pusher = Pusher(key: "YOUR_APP_KEY", options: pusherClientOptions)
29+
pusher = Pusher(key: "YOUR_APP_KEY", options: pusherClientOptions)
2130

2231
// remove the debugLogger from the client options if you want to remove the
2332
// debug logging, or just change the function below
@@ -44,7 +53,7 @@ class ViewController: UIViewController, ConnectionStateChangeDelegate {
4453

4554
chan.bind("test-event", callback: { (data: AnyObject?) -> Void in
4655
print(data)
47-
pusher.subscribe("presence-channel", onMemberAdded: onMemberAdded)
56+
self.pusher.subscribe("presence-channel", onMemberAdded: onMemberAdded)
4857

4958
if let data = data as? [String : AnyObject] {
5059
if let testVal = data["test"] as? String {

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ What else would you want? Head over to the example app [ViewController.swift](ht
1717
* [Installation](#installation)
1818
* [Configuration](#configuration)
1919
* [Connection](#connection)
20+
* [Connection state changes](#connection-state-changes)
21+
* [Reconnection](#reconnection)
2022
* [Subscribing to channels](#subscribing)
2123
* [Binding to events](#binding-to-events)
2224
* [Globally](#global-events)
@@ -204,6 +206,39 @@ class ViewController: UIViewController, ConnectionStateChangeDelegate {
204206
}
205207
```
206208

209+
The different states that the connection can be in are:
210+
211+
* `Connecting` - the connection is about to attempt to be made
212+
* `Connected` - the connection has been successfully made
213+
* `Disconnecting` - the connection has been instructed to disconnect and it is just about to do so
214+
* `Disconnected` - the connection has disconnected and no attempt will be made to reconnect automatically
215+
* `Reconnecting` - an attempt is going to be made to try and re-establish the connection
216+
* `ReconnectingWhenNetworkBecomesReachable` - when the network becomes reachable an attempt will be made to reconnect
217+
218+
219+
### Reconnection
220+
221+
There are three main ways in which a disconnection can occur:
222+
223+
* The client explicitly calls disconnect and a close frame is sent over the websocket connection
224+
* The client experiences some form of network degradation which leads to a heartbeat (ping/pong) message being missed and thus the client disconnects
225+
* The Pusher server closes the websocket connection; typically this will only occur during a restart of the Pusher socket servers and an almost immediate reconnection should occur
226+
227+
In the case of the first type of disconnection the library will (as you'd hope) not attempt a reconnection.
228+
229+
If there is network degradation that leads to a disconnection then the library has the [Reachability](https://github.com/ashleymills/Reachability.swift) library embedded and will be able to automatically determine when to attempt a reconnect based on the changing network conditions.
230+
231+
If the Pusher servers close the websocket then the library will attempt to reconnect (by default) a maximum of 6 times, with an exponential backoff. The value of `reconnectAttemptsMax` is a public property on the `PusherConnection` and so can be changed if you wish.
232+
233+
All of this is the case if you have the client option of `autoReconnect` set as `true`, which it is by default. If the reconnection strategies are not suitable for your use case then you can set `autoReconnect` to `false` and implement your own reconnection strategy based on the connection state changes.
234+
235+
There are a couple of properties on the connection (`PusherConnection`) that you can set that affect how the reconnection behaviour works. These are:
236+
237+
* `public var reconnectAttemptsMax: Int? = 6` - if you set this to `nil` then there is no maximum number of reconnect attempts and so attempts will continue to be made with an exponential backoff (based on number of attempts), otherwise only as many attempts as this property's value will be made before the connection's state moves to `.Disconnected`
238+
* `public var maxReconnectGapInSeconds: Double? = nil` - if you want to set a maximum length of time (in seconds) between reconnect attempts then set this property appropriately
239+
240+
Note that the number of reconnect attempts gets reset to 0 as soon as a successful connection is made.
241+
207242
## Subscribing
208243

209244
### Public channels

Source/PusherConnection.swift

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,21 @@ public class PusherConnection {
2222
public var userDataFetcher: (() -> PusherUserData)?
2323
public var debugLogger: ((String) -> ())?
2424
public weak var stateChangeDelegate: ConnectionStateChangeDelegate?
25-
internal var reconnectOperation: NSOperation?
25+
public var reconnectAttemptsMax: Int? = 6
26+
public var reconnectAttempts: Int = 0
27+
public var maxReconnectGapInSeconds: Double? = nil
28+
internal var reconnectTimer: NSTimer? = nil
2629

2730
public lazy var reachability: Reachability? = {
2831
let reachability = try? Reachability.reachabilityForInternetConnection()
2932
reachability?.whenReachable = { [unowned self] reachability in
30-
self.reconnectOperation?.cancel()
31-
if self.connectionState == .Disconnected {
32-
self.socket.connect()
33+
self.debugLogger?("[PUSHER DEBUG] Network reachable")
34+
if self.connectionState == .Disconnected || self.connectionState == .ReconnectingWhenNetworkBecomesReachable {
35+
self.attemptReconnect()
3336
}
3437
}
3538
reachability?.whenUnreachable = { [unowned self] reachability in
36-
self.reconnectOperation?.cancel()
37-
print("Network unreachable")
39+
self.debugLogger?("[PUSHER DEBUG] Network unreachable")
3840
}
3941
return reachability
4042
}()
@@ -178,13 +180,14 @@ public class PusherConnection {
178180
/**
179181
Establish a websocket connection
180182
*/
181-
public func connect() {
183+
@objc public func connect() {
182184
if self.connectionState == .Connected {
183185
return
184186
} else {
185187
updateConnectionState(.Connecting)
186188
self.socket.connect()
187189
if self.options.autoReconnect {
190+
// can call this multiple times and only one notifier will be started
188191
_ = try? reachability?.startNotifier()
189192
}
190193
}
@@ -278,6 +281,9 @@ public class PusherConnection {
278281
updateConnectionState(.Connected)
279282
self.socketId = socketId
280283

284+
self.reconnectAttempts = 0
285+
self.reconnectTimer?.invalidate()
286+
281287
for (_, channel) in self.channels.channels {
282288
if !channel.subscribed {
283289
if !self.authorize(channel) {
@@ -356,7 +362,6 @@ public class PusherConnection {
356362
if let jsonData = data, jsonObject = try NSJSONSerialization.JSONObjectWithData(jsonData, options: []) as? [String : AnyObject] {
357363
return jsonObject
358364
} else {
359-
// TODO: Move below
360365
print("Unable to parse string from WebSocket: \(string)")
361366
}
362367
} catch let error as NSError {
@@ -663,6 +668,8 @@ public enum ConnectionState {
663668
case Connected
664669
case Disconnecting
665670
case Disconnected
671+
case Reconnecting
672+
case ReconnectingWhenNetworkBecomesReachable
666673
}
667674

668675
public protocol ConnectionStateChangeDelegate: class {

Source/PusherWebsocketDelegate.swift

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
//
88

99
extension PusherConnection: WebSocketDelegate {
10-
// MARK: WebSocketDelegate Implementation
1110

1211
/**
1312
Delegate method called when a message is received over a websocket
@@ -31,36 +30,75 @@ extension PusherConnection: WebSocketDelegate {
3130
- parameter error: The error, if one exists, when disconnected
3231
*/
3332
public func websocketDidDisconnect(ws: WebSocket, error: NSError?) {
34-
35-
updateConnectionState(.Disconnected)
36-
for (_, channel) in self.channels.channels {
37-
channel.subscribed = false
33+
// Handles setting channel subscriptions to unsubscribed wheter disconnection
34+
// is intentional or not
35+
if connectionState == .Disconnecting || connectionState == .Connected {
36+
for (_, channel) in self.channels.channels {
37+
channel.subscribed = false
38+
}
3839
}
3940

4041
// Handle error (if any)
4142
guard let error = error where error.code != Int(WebSocket.CloseCode.Normal.rawValue) else {
42-
return
43+
self.debugLogger?("[PUSHER DEBUG] Deliberate disconnection - skipping reconnect attempts")
44+
return updateConnectionState(.Disconnected)
4345
}
4446

4547
print("Websocket is disconnected. Error: \(error.localizedDescription)")
48+
// Attempt reconnect if possible
4649

47-
// Reconnect if possible
48-
if self.options.autoReconnect {
49-
if let reachability = self.reachability where reachability.isReachable() {
50-
let operation = NSBlockOperation {
51-
self.socket.connect()
52-
}
50+
guard self.options.autoReconnect else {
51+
return updateConnectionState(.Disconnected)
52+
}
5353

54-
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(NSEC_PER_SEC)), dispatch_get_main_queue()) {
55-
NSOperationQueue.mainQueue().addOperation(operation)
56-
}
54+
guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else {
55+
self.debugLogger?("[PUSHER DEBUG] Max reconnect attempts reached")
56+
return updateConnectionState(.Disconnected)
57+
}
5758

58-
self.reconnectOperation?.cancel()
59-
self.reconnectOperation = operation
60-
}
59+
guard let reachability = self.reachability where reachability.isReachable() else {
60+
self.debugLogger?("[PUSHER DEBUG] Network unreachable so waiting to attempt reconnect")
61+
return updateConnectionState(.ReconnectingWhenNetworkBecomesReachable)
6162
}
63+
64+
if connectionState != .Reconnecting {
65+
updateConnectionState(.Reconnecting)
66+
}
67+
self.debugLogger?("[PUSHER DEBUG] Network reachable so will setup reconnect attempt")
68+
69+
attemptReconnect()
6270
}
6371

72+
/**
73+
Attempt to reconnect triggered by a disconnection
74+
*/
75+
internal func attemptReconnect() {
76+
guard connectionState != .Connected else {
77+
return
78+
}
79+
80+
guard reconnectAttemptsMax == nil || reconnectAttempts < reconnectAttemptsMax! else {
81+
return
82+
}
83+
84+
let reconnectInterval = Double(reconnectAttempts * reconnectAttempts)
85+
86+
let timeInterval = maxReconnectGapInSeconds != nil ? min(reconnectInterval, maxReconnectGapInSeconds!)
87+
: reconnectInterval
88+
89+
self.debugLogger?("[PUSHER DEBUG] Waiting \(timeInterval) seconds before attempting to reconnect (attempt \(reconnectAttempts + 1) of \(reconnectAttemptsMax!))")
90+
91+
reconnectTimer = NSTimer.scheduledTimerWithTimeInterval(
92+
timeInterval,
93+
target: self,
94+
selector: #selector(connect),
95+
userInfo: nil,
96+
repeats: false
97+
)
98+
reconnectAttempts += 1
99+
}
100+
101+
64102
public func websocketDidConnect(ws: WebSocket) {}
65103
public func websocketDidReceiveData(ws: WebSocket, data: NSData) {}
66104
}

Tests/PusherIncomingEventHandlingTests.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ class HandlingIncomingEventsSpec: QuickSpec {
104104
}
105105

106106
it("should pass incoming messages to the debugLogger if one is set") {
107-
let debugLogger = { (text: String) in socket.appendToCallbackCheckString(text) }
107+
let debugLogger = { (text: String) in
108+
if text.rangeOfString("websocketDidReceiveMessage") != nil {
109+
socket.appendToCallbackCheckString(text)
110+
}
111+
}
108112
pusher = Pusher(key: key)
109113
pusher.connection.debugLogger = debugLogger
110114
socket.delegate = pusher.connection

0 commit comments

Comments
 (0)