ably 0.8.15 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +6 -4
  3. data/CHANGELOG.md +6 -2
  4. data/README.md +5 -1
  5. data/SPEC.md +1473 -852
  6. data/ably.gemspec +11 -8
  7. data/lib/ably/auth.rb +90 -53
  8. data/lib/ably/exceptions.rb +37 -8
  9. data/lib/ably/logger.rb +10 -1
  10. data/lib/ably/models/auth_details.rb +42 -0
  11. data/lib/ably/models/channel_state_change.rb +18 -4
  12. data/lib/ably/models/connection_details.rb +6 -3
  13. data/lib/ably/models/connection_state_change.rb +4 -3
  14. data/lib/ably/models/error_info.rb +1 -1
  15. data/lib/ably/models/message.rb +17 -1
  16. data/lib/ably/models/message_encoders/base.rb +103 -82
  17. data/lib/ably/models/message_encoders/base64.rb +1 -1
  18. data/lib/ably/models/presence_message.rb +16 -1
  19. data/lib/ably/models/protocol_message.rb +20 -3
  20. data/lib/ably/models/token_details.rb +11 -1
  21. data/lib/ably/models/token_request.rb +16 -6
  22. data/lib/ably/modules/async_wrapper.rb +7 -3
  23. data/lib/ably/modules/encodeable.rb +51 -12
  24. data/lib/ably/modules/enum.rb +17 -7
  25. data/lib/ably/modules/event_emitter.rb +29 -14
  26. data/lib/ably/modules/model_common.rb +13 -21
  27. data/lib/ably/modules/state_emitter.rb +7 -4
  28. data/lib/ably/modules/state_machine.rb +2 -4
  29. data/lib/ably/modules/uses_state_machine.rb +7 -3
  30. data/lib/ably/realtime.rb +2 -0
  31. data/lib/ably/realtime/auth.rb +102 -42
  32. data/lib/ably/realtime/channel.rb +68 -26
  33. data/lib/ably/realtime/channel/channel_manager.rb +154 -65
  34. data/lib/ably/realtime/channel/channel_state_machine.rb +14 -15
  35. data/lib/ably/realtime/client.rb +18 -3
  36. data/lib/ably/realtime/client/incoming_message_dispatcher.rb +38 -29
  37. data/lib/ably/realtime/client/outgoing_message_dispatcher.rb +6 -1
  38. data/lib/ably/realtime/connection.rb +108 -49
  39. data/lib/ably/realtime/connection/connection_manager.rb +167 -61
  40. data/lib/ably/realtime/connection/connection_state_machine.rb +22 -3
  41. data/lib/ably/realtime/connection/websocket_transport.rb +19 -10
  42. data/lib/ably/realtime/presence.rb +70 -45
  43. data/lib/ably/realtime/presence/members_map.rb +201 -36
  44. data/lib/ably/realtime/presence/presence_manager.rb +30 -6
  45. data/lib/ably/realtime/presence/presence_state_machine.rb +5 -12
  46. data/lib/ably/rest.rb +2 -2
  47. data/lib/ably/rest/channel.rb +5 -5
  48. data/lib/ably/rest/client.rb +31 -27
  49. data/lib/ably/rest/middleware/exceptions.rb +1 -3
  50. data/lib/ably/rest/middleware/logger.rb +2 -2
  51. data/lib/ably/rest/presence.rb +2 -2
  52. data/lib/ably/util/pub_sub.rb +1 -1
  53. data/lib/ably/util/safe_deferrable.rb +26 -0
  54. data/lib/ably/version.rb +2 -2
  55. data/spec/acceptance/realtime/auth_spec.rb +470 -111
  56. data/spec/acceptance/realtime/channel_history_spec.rb +5 -3
  57. data/spec/acceptance/realtime/channel_spec.rb +1017 -168
  58. data/spec/acceptance/realtime/client_spec.rb +6 -6
  59. data/spec/acceptance/realtime/connection_failures_spec.rb +458 -27
  60. data/spec/acceptance/realtime/connection_spec.rb +424 -105
  61. data/spec/acceptance/realtime/message_spec.rb +52 -23
  62. data/spec/acceptance/realtime/presence_history_spec.rb +5 -3
  63. data/spec/acceptance/realtime/presence_spec.rb +1110 -96
  64. data/spec/acceptance/rest/auth_spec.rb +222 -59
  65. data/spec/acceptance/rest/base_spec.rb +1 -1
  66. data/spec/acceptance/rest/channel_spec.rb +1 -2
  67. data/spec/acceptance/rest/client_spec.rb +104 -48
  68. data/spec/acceptance/rest/message_spec.rb +42 -15
  69. data/spec/acceptance/rest/presence_spec.rb +4 -11
  70. data/spec/rspec_config.rb +2 -1
  71. data/spec/shared/client_initializer_behaviour.rb +2 -2
  72. data/spec/shared/safe_deferrable_behaviour.rb +6 -2
  73. data/spec/spec_helper.rb +4 -2
  74. data/spec/support/debug_failure_helper.rb +20 -4
  75. data/spec/support/event_machine_helper.rb +32 -1
  76. data/spec/unit/auth_spec.rb +4 -11
  77. data/spec/unit/logger_spec.rb +28 -2
  78. data/spec/unit/models/auth_details_spec.rb +49 -0
  79. data/spec/unit/models/channel_state_change_spec.rb +23 -3
  80. data/spec/unit/models/connection_details_spec.rb +12 -1
  81. data/spec/unit/models/connection_state_change_spec.rb +15 -4
  82. data/spec/unit/models/message_encoders/base64_spec.rb +2 -1
  83. data/spec/unit/models/message_spec.rb +153 -0
  84. data/spec/unit/models/presence_message_spec.rb +192 -0
  85. data/spec/unit/models/protocol_message_spec.rb +64 -6
  86. data/spec/unit/models/token_details_spec.rb +75 -0
  87. data/spec/unit/models/token_request_spec.rb +74 -0
  88. data/spec/unit/modules/async_wrapper_spec.rb +2 -1
  89. data/spec/unit/modules/enum_spec.rb +69 -0
  90. data/spec/unit/modules/event_emitter_spec.rb +149 -22
  91. data/spec/unit/modules/state_emitter_spec.rb +9 -3
  92. data/spec/unit/realtime/client_spec.rb +1 -1
  93. data/spec/unit/realtime/connection_spec.rb +8 -5
  94. data/spec/unit/realtime/incoming_message_dispatcher_spec.rb +1 -1
  95. data/spec/unit/realtime/presence_spec.rb +4 -3
  96. data/spec/unit/rest/client_spec.rb +1 -1
  97. data/spec/unit/util/crypto_spec.rb +3 -3
  98. metadata +22 -19
@@ -74,7 +74,12 @@ module Ably::Realtime
74
74
 
75
75
  def setup_event_handlers
76
76
  connection.unsafe_on(:connected) do
77
- deliver_queued_protocol_messages
77
+ # Give connection manager enough time to prevent message delivery if necessary
78
+ # For example, if reconnecting and connection and channel state is lost,
79
+ # then the queued messages must be NACK'd
80
+ EventMachine.next_tick do
81
+ deliver_queued_protocol_messages
82
+ end
78
83
  end
79
84
  end
80
85
  end
@@ -1,3 +1,5 @@
1
+ require 'securerandom'
2
+
1
3
  module Ably
2
4
  module Realtime
3
5
  # The Connection class represents the connection associated with an Ably Realtime instance.
@@ -25,8 +27,6 @@ module Ably
25
27
  # Connection::STATE.Closed
26
28
  # Connection::STATE.Failed
27
29
  #
28
- # Connection emit errors - use `on(:error)` to subscribe to errors
29
- #
30
30
  # @example
31
31
  # client = Ably::Realtime::Client.new('key.id:secret')
32
32
  # client.connection.on(:connected) do
@@ -42,7 +42,8 @@ module Ably
42
42
  include Ably::Modules::SafeYield
43
43
  extend Ably::Modules::Enum
44
44
 
45
- # Valid Connection states
45
+ # ConnectionState
46
+ # The permited states for this connection
46
47
  STATE = ruby_enum('STATE',
47
48
  :initialized,
48
49
  :connecting,
@@ -53,6 +54,13 @@ module Ably
53
54
  :closed,
54
55
  :failed
55
56
  )
57
+
58
+ # ConnectionEvent
59
+ # The permitted connection events that are emitted for this connection
60
+ EVENT = ruby_enum('EVENT',
61
+ STATE.to_sym_arr + [:update]
62
+ )
63
+
56
64
  include Ably::Modules::StateEmitter
57
65
  include Ably::Modules::UsesStateMachine
58
66
  ensure_state_machine_emits 'Ably::Models::ConnectionStateChange'
@@ -62,11 +70,13 @@ module Ably
62
70
 
63
71
  # Defaults for automatic connection recovery and timeouts
64
72
  DEFAULTS = {
73
+ channel_retry_timeout: 15, # when a channel becomes SUSPENDED, after this delay in seconds, the channel will automatically attempt to reattach if the connection is CONNECTED
65
74
  disconnected_retry_timeout: 15, # when the connection enters the DISCONNECTED state, after this delay in milliseconds, if the state is still DISCONNECTED, the client library will attempt to reconnect automatically
66
75
  suspended_retry_timeout: 30, # when the connection enters the SUSPENDED state, after this delay in milliseconds, if the state is still SUSPENDED, the client library will attempt to reconnect automatically
67
76
  connection_state_ttl: 120, # the duration that Ably will persist the connection state when a Realtime client is abruptly disconnected
68
- max_connection_state_ttl: nil, # allow a max TTL to be passed in for CI test purposes thus overiding any connection_state_ttl sent from Ably
77
+ max_connection_state_ttl: nil, # allow a max TTL to be passed in, usually for CI test purposes thus overiding any connection_state_ttl sent from Ably
69
78
  realtime_request_timeout: 10, # default timeout when establishing a connection, or sending a HEARTBEAT, CONNECT, ATTACH, DETACH or CLOSE ProtocolMessage
79
+ websocket_heartbeats_disabled: false,
70
80
  }.freeze
71
81
 
72
82
  # A unique public identifier for this connection, used to identify this member in presence events and messages
@@ -122,9 +132,9 @@ module Ably
122
132
  # @api public
123
133
  def initialize(client, options)
124
134
  @client = client
125
- @client_serial = -1
126
135
  @__outgoing_message_queue__ = []
127
136
  @__pending_message_ack_queue__ = []
137
+ reset_client_serial
128
138
 
129
139
  @defaults = DEFAULTS.dup
130
140
  options.each do |key, val|
@@ -150,7 +160,9 @@ module Ably
150
160
  #
151
161
  def close(&success_block)
152
162
  unless closing? || closed?
153
- raise exception_for_state_change_to(:closing) unless can_transition_to?(:closing)
163
+ unless can_transition_to?(:closing)
164
+ return Ably::Util::SafeDeferrable.new_and_fail_immediately(logger, exception_for_state_change_to(:closing))
165
+ end
154
166
  transition_state_machine :closing
155
167
  end
156
168
  deferrable_for_state_change_to(STATE.Closed, &success_block)
@@ -170,8 +182,11 @@ module Ably
170
182
  #
171
183
  def connect(&success_block)
172
184
  unless connecting? || connected?
173
- raise exception_for_state_change_to(:connecting) unless can_transition_to?(:connecting)
174
- transition_state_machine :connecting
185
+ unless can_transition_to?(:connecting)
186
+ return Ably::Util::SafeDeferrable.new_and_fail_immediately(logger, exception_for_state_change_to(:connecting))
187
+ end
188
+ # If connect called in a suspended block, we want to ensure the other callbacks have finished their work first
189
+ EventMachine.next_tick { transition_state_machine :connecting if can_transition_to?(:connecting) }
175
190
  end
176
191
 
177
192
  Ably::Util::SafeDeferrable.new(logger).tap do |deferrable|
@@ -181,12 +196,12 @@ module Ably
181
196
  succeed_callback = deferrable.method(:succeed)
182
197
  fail_callback = deferrable.method(:fail)
183
198
 
184
- once(:connected) do
199
+ unsafe_once(:connected) do
185
200
  deferrable.succeed
186
201
  off(&fail_callback)
187
202
  end
188
203
 
189
- once(:failed, :closed, :closing) do
204
+ unsafe_once(:failed, :closed, :closing) do
190
205
  deferrable.fail
191
206
  off(&succeed_callback)
192
207
  end
@@ -206,38 +221,53 @@ module Ably
206
221
  # puts "Ping took #{elapsed_s}s"
207
222
  # end
208
223
  #
209
- # @return [void]
224
+ # @return [Ably::Util::SafeDeferrable]
210
225
  #
211
226
  def ping(&block)
212
- raise RuntimeError, 'Cannot send a ping when connection is not open' if initialized?
213
- raise RuntimeError, 'Cannot send a ping when connection is in a closed or failed state' if closed? || failed?
227
+ if initialized? || suspended? || closing? || closed? || failed?
228
+ error = Ably::Models::ErrorInfo.new(message: "Cannot send a ping when the connection is #{state}", code: 80003)
229
+ return Ably::Util::SafeDeferrable.new_and_fail_immediately(logger, error)
230
+ end
214
231
 
215
- started = nil
216
- finished = false
232
+ Ably::Util::SafeDeferrable.new(logger).tap do |deferrable|
233
+ started = nil
234
+ finished = false
235
+ ping_id = SecureRandom.hex(16)
236
+ heartbeat_action = Ably::Models::ProtocolMessage::ACTION.Heartbeat
237
+
238
+ wait_for_ping = Proc.new do |protocol_message|
239
+ next if finished
240
+ if protocol_message.action == heartbeat_action && protocol_message.id == ping_id
241
+ finished = true
242
+ __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
243
+ time_passed = Time.now.to_f - started.to_f
244
+ deferrable.succeed time_passed
245
+ safe_yield block, time_passed if block_given?
246
+ end
247
+ end
217
248
 
218
- wait_for_ping = Proc.new do |protocol_message|
219
- next if finished
220
- if protocol_message.action == Ably::Models::ProtocolMessage::ACTION.Heartbeat
221
- finished = true
222
- __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
223
- time_passed = Time.now.to_f - started.to_f
224
- safe_yield block, time_passed if block_given?
249
+ once_or_if(STATE.Connected) do
250
+ next if finished
251
+ started = Time.now
252
+ send_protocol_message action: heartbeat_action.to_i, id: ping_id
253
+ __incoming_protocol_msgbus__.subscribe :protocol_message, &wait_for_ping
225
254
  end
226
- end
227
255
 
228
- once_or_if(STATE.Connected) do
229
- next if finished
230
- started = Time.now
231
- send_protocol_message action: Ably::Models::ProtocolMessage::ACTION.Heartbeat.to_i
232
- __incoming_protocol_msgbus__.subscribe :protocol_message, &wait_for_ping
233
- end
256
+ once_or_if([:suspended, :closing, :closed, :failed]) do
257
+ next if finished
258
+ finished = true
259
+ deferrable.fail Ably::Models::ErrorInfo.new(message: "Ping failed as connection has changed state to #{state}", code: 80003)
260
+ end
234
261
 
235
- EventMachine.add_timer(defaults.fetch(:realtime_request_timeout)) do
236
- next if finished
237
- finished = true
238
- __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
239
- logger.warn "Ping timed out after #{defaults.fetch(:realtime_request_timeout)}s"
240
- safe_yield block, nil if block_given?
262
+ EventMachine.add_timer(defaults.fetch(:realtime_request_timeout)) do
263
+ next if finished
264
+ finished = true
265
+ __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
266
+ error_msg = "Ping timed out after #{defaults.fetch(:realtime_request_timeout)}s"
267
+ logger.warn { error_msg }
268
+ deferrable.fail Ably::Models::ErrorInfo.new(message: error_msg, code: 50003)
269
+ safe_yield block, nil if block_given?
270
+ end
241
271
  end
242
272
  end
243
273
 
@@ -364,7 +394,7 @@ module Ably
364
394
  Ably::Models::ProtocolMessage.new(protocol_message, logger: logger).tap do |message|
365
395
  add_message_to_outgoing_queue message
366
396
  notify_message_dispatcher_of_new_message message
367
- logger.debug("Connection: Prot msg queued =>: #{message.action} #{message}")
397
+ logger.debug { "Connection: Prot msg queued =>: #{message.action} #{message}" }
368
398
  end
369
399
  end
370
400
  end
@@ -387,21 +417,24 @@ module Ably
387
417
  client.auth.auth_params.tap do |auth_deferrable|
388
418
  auth_deferrable.callback do |auth_params|
389
419
  url_params = auth_params.merge(
390
- format: client.protocol,
391
- echo: client.echo_messages,
392
- v: Ably::PROTOCOL_VERSION,
393
- lib: client.rest_client.lib_version_id,
420
+ format: client.protocol,
421
+ echo: client.echo_messages,
422
+ v: Ably::PROTOCOL_VERSION,
423
+ lib: client.rest_client.lib_version_id,
394
424
  )
395
425
 
426
+ # Use native websocket heartbeats if possible
427
+ url_params['heartbeats'] = 'false' unless defaults.fetch(:websocket_heartbeats_disabled)
428
+
396
429
  url_params['clientId'] = client.auth.client_id if client.auth.has_client_id?
397
430
 
398
431
  if connection_resumable?
399
432
  url_params.merge! resume: key, connection_serial: serial
400
- logger.debug "Resuming connection key #{key} with serial #{serial}"
433
+ logger.debug { "Resuming connection key #{key} with serial #{serial}" }
401
434
  elsif connection_recoverable?
402
- url_params.merge! recover: connection_recover_parts[:recover], connection_serial: connection_recover_parts[:connection_serial]
403
- logger.debug "Recovering connection with key #{client.recover}"
404
- once(:connected, :closed, :failed) do
435
+ url_params.merge! recover: connection_recover_parts[:recover], connectionSerial: connection_recover_parts[:connection_serial]
436
+ logger.debug { "Recovering connection with key #{client.recover}" }
437
+ unsafe_once(:connected, :closed, :failed) do
405
438
  client.disable_automatic_connection_recovery
406
439
  end
407
440
  end
@@ -412,7 +445,7 @@ module Ably
412
445
 
413
446
  determine_host do |host|
414
447
  begin
415
- logger.debug "Connection: Opening socket connection to #{host}:#{port}/#{url.path}?#{url.query}"
448
+ logger.debug { "Connection: Opening socket connection to #{host}:#{port}/#{url.path}?#{url.query}" }
416
449
  @transport = create_transport(host, port, url) do |websocket_transport|
417
450
  websocket_deferrable.succeed websocket_transport
418
451
  end
@@ -451,7 +484,7 @@ module Ably
451
484
 
452
485
  # Executes registered callbacks for a successful connection resume event
453
486
  # @api private
454
- def resumed
487
+ def trigger_resumed
455
488
  resume_callbacks.each(&:call)
456
489
  end
457
490
 
@@ -490,6 +523,33 @@ module Ably
490
523
  @connection_state_ttl = val
491
524
  end
492
525
 
526
+ # @api private
527
+ def heartbeat_interval
528
+ # See RTN23a
529
+ (details && details.max_idle_interval).to_i +
530
+ defaults.fetch(:realtime_request_timeout)
531
+ end
532
+
533
+ # Resets the client serial (msgSerial) sent to Ably for each new {Ably::Models::ProtocolMessage}
534
+ # (see #client_serial)
535
+ # @api private
536
+ def reset_client_serial
537
+ @client_serial = -1
538
+ end
539
+
540
+ # When a hearbeat or any other message from Ably is received
541
+ # we know it's alive, see #RTN23
542
+ # @api private
543
+ def set_connection_confirmed_alive
544
+ @last_liveness_event = Time.now
545
+ manager.reset_liveness_timer
546
+ end
547
+
548
+ # @api private
549
+ def time_since_connection_confirmed_alive?
550
+ Time.now.to_i - @last_liveness_event.to_i
551
+ end
552
+
493
553
  # As we are using a state machine, do not allow change_state to be used
494
554
  # #transition_state_machine must be used instead
495
555
  private :change_state
@@ -500,9 +560,8 @@ module Ably
500
560
  # Note that this is different to the connection serial that contains the last known serial number
501
561
  # received from the server.
502
562
  #
503
- # A client serial number therefore does not guarantee a message has been received, only sent.
504
- # A connection serial guarantees the server has received the message and is thus used for connection
505
- # recovery and resumes.
563
+ # A message serial number does not guarantee a message has been received, only sent.
564
+ # A connection serial guarantees the server has received the message and is thus used for connection recovery and resumes.
506
565
  # @return [Integer] starting at -1 indicating no messages sent, 0 when the first message is sent
507
566
  def client_serial
508
567
  @client_serial
@@ -12,13 +12,12 @@ module Ably::Realtime
12
12
  class ConnectionManager
13
13
  # Error codes from the server that can potentially be resolved
14
14
  RESOLVABLE_ERROR_CODES = {
15
- token_expired: Ably::Rest::Middleware::Exceptions::TOKEN_EXPIRED_CODE
15
+ token_expired: Ably::Exceptions::TOKEN_EXPIRED_CODE
16
16
  }
17
17
 
18
18
  def initialize(connection)
19
19
  @connection = connection
20
20
  @timers = Hash.new { |hash, key| hash[key] = [] }
21
- @renewing_token = false
22
21
 
23
22
  connection.unsafe_on(:closed) do
24
23
  connection.reset_resume_info
@@ -48,7 +47,7 @@ module Ably::Realtime
48
47
  return
49
48
  end
50
49
 
51
- logger.debug 'ConnectionManager: Opening a websocket transport connection'
50
+ logger.debug { 'ConnectionManager: Opening a websocket transport connection' }
52
51
 
53
52
  connection.create_websocket_transport.tap do |socket_deferrable|
54
53
  socket_deferrable.callback do |websocket_transport|
@@ -60,7 +59,7 @@ module Ably::Realtime
60
59
  end
61
60
  end
62
61
 
63
- logger.debug "ConnectionManager: Setting up automatic connection timeout timer for #{realtime_request_timeout}s"
62
+ logger.debug { "ConnectionManager: Setting up automatic connection timeout timer for #{realtime_request_timeout}s" }
64
63
  create_timeout_timer_whilst_in_state(:connecting, realtime_request_timeout) do
65
64
  connection_opening_failed Ably::Exceptions::ConnectionTimeout.new("Connection to Ably timed out after #{realtime_request_timeout}s", nil, 80014)
66
65
  end
@@ -70,34 +69,72 @@ module Ably::Realtime
70
69
  #
71
70
  # @api private
72
71
  def connection_opening_failed(error)
73
- if error.kind_of?(Ably::Exceptions::IncompatibleClientId)
74
- client.connection.transition_state_machine :failed, reason: error
75
- return
72
+ if error.kind_of?(Ably::Exceptions::BaseAblyException)
73
+ # Authentication errors that indicate the authentication failure is terminal should move to the failed state
74
+ if ([401, 403].include?(error.status) && !RESOLVABLE_ERROR_CODES.fetch(:token_expired).include?(error.code)) ||
75
+ (error.code == Ably::Exceptions::INVALID_CLIENT_ID)
76
+ connection.transition_state_machine :failed, reason: error
77
+ return
78
+ end
76
79
  end
77
80
 
78
- logger.warn "ConnectionManager: Connection to #{connection.current_host}:#{connection.port} failed; #{error.message}"
81
+ logger.warn { "ConnectionManager: Connection to #{connection.current_host}:#{connection.port} failed; #{error.message}" }
79
82
  next_state = get_next_retry_state_info
80
- connection.transition_state_machine next_state.fetch(:state), retry_in: next_state.fetch(:pause), reason: Ably::Exceptions::ConnectionError.new("Connection failed: #{error.message}", nil, 80000)
83
+ connection.transition_state_machine next_state.fetch(:state), retry_in: next_state.fetch(:pause), reason: Ably::Exceptions::ConnectionError.new("Connection failed: #{error.message}", nil, 80000, error)
81
84
  end
82
85
 
83
86
  # Called whenever a new connection is made
84
87
  #
85
88
  # @api private
86
89
  def connected(protocol_message)
90
+ # ClientID validity is already checked as part of the incoming message processing
91
+ client.auth.configure_client_id protocol_message.connection_details.client_id
92
+
93
+ # Update the connection details and any associated defaults
94
+ connection.set_connection_details protocol_message.connection_details
95
+
87
96
  if connection.key
88
97
  if protocol_message.connection_id == connection.id
89
- logger.debug "ConnectionManager: Connection resumed successfully - ID #{connection.id} and key #{connection.key}"
90
- EventMachine.next_tick { connection.resumed }
98
+ logger.debug { "ConnectionManager: Connection resumed successfully - ID #{connection.id} and key #{connection.key}" }
99
+ EventMachine.next_tick { connection.trigger_resumed }
100
+ resend_pending_message_ack_queue
91
101
  else
92
- logger.debug "ConnectionManager: Connection was not resumed, old connection ID #{connection.id} has been updated with new connect ID #{protocol_message.connection_id} and key #{protocol_message.connection_key}"
93
- detach_attached_channels protocol_message.error
102
+ logger.debug { "ConnectionManager: Connection was not resumed, old connection ID #{connection.id} has been updated with new connection ID #{protocol_message.connection_id} and key #{protocol_message.connection_key}" }
103
+ connection.reset_client_serial
104
+ nack_messages_on_all_channels protocol_message.error
105
+ force_reattach_on_channels protocol_message.error
94
106
  end
95
107
  else
96
- logger.debug "ConnectionManager: New connection created with ID #{protocol_message.connection_id} and key #{protocol_message.connection_key}"
108
+ logger.debug { "ConnectionManager: New connection created with ID #{protocol_message.connection_id} and key #{protocol_message.connection_key}" }
109
+ connection.reset_client_serial
97
110
  end
111
+
112
+ reattach_suspended_channels protocol_message.error
113
+
98
114
  connection.configure_new protocol_message.connection_id, protocol_message.connection_key, protocol_message.connection_serial
99
115
  end
100
116
 
117
+ # When connection is CONNECTED and receives an update
118
+ # Update the Connection details and emit an UPDATE event #RTN4h
119
+ def connected_update(protocol_message)
120
+ # ClientID validity is already checked as part of the incoming message processing
121
+ client.auth.configure_client_id protocol_message.connection_details.client_id
122
+
123
+ # Update the connection details and any associated defaults
124
+ connection.set_connection_details protocol_message.connection_details
125
+
126
+ connection.configure_new protocol_message.connection_id, protocol_message.connection_key, protocol_message.connection_serial
127
+
128
+ state_change = Ably::Models::ConnectionStateChange.new(
129
+ current: connection.state,
130
+ previous: connection.state,
131
+ event: Ably::Realtime::Connection::EVENT(:update),
132
+ reason: protocol_message.error,
133
+ protocol_message: protocol_message
134
+ )
135
+ connection.emit :update, state_change
136
+ end
137
+
101
138
  # Ensures the underlying transport has been disconnected and all event emitter callbacks removed
102
139
  #
103
140
  # @api private
@@ -109,6 +146,12 @@ module Ably::Realtime
109
146
  end
110
147
  end
111
148
 
149
+ # @api private
150
+ def release_and_establish_new_transport
151
+ destroy_transport
152
+ setup_transport
153
+ end
154
+
112
155
  # Reconnect the {Ably::Realtime::Connection::WebsocketTransport} if possible, otherwise set up a new transport
113
156
  #
114
157
  # @api private
@@ -143,9 +186,12 @@ module Ably::Realtime
143
186
  #
144
187
  # @api private
145
188
  def fail(error)
146
- connection.logger.fatal "ConnectionManager: Connection failed - #{error}"
147
- connection.manager.destroy_transport
148
- connection.unsafe_once(:failed) { connection.emit :error, error }
189
+ connection.logger.fatal { "ConnectionManager: Connection failed - #{error}" }
190
+ destroy_transport
191
+ channels.each do |channel|
192
+ next if channel.detached? || channel.initialized?
193
+ channel.transition_state_machine :failed, reason: error if channel.can_transition_to?(:failed)
194
+ end
149
195
  end
150
196
 
151
197
  # When a connection is disconnected whilst connecting, attempt reconnect and/or set state to :suspended or :failed
@@ -153,12 +199,12 @@ module Ably::Realtime
153
199
  # @api private
154
200
  def respond_to_transport_disconnected_when_connecting(error)
155
201
  return unless connection.disconnected? || connection.suspended? # do nothing if state has changed through an explicit request
156
- return unless can_retry_connection? # do not always reattempt connection or change state as client may be re-authorising
202
+ return if currently_renewing_token? # do not always reattempt connection or change state as client may be re-authorising
157
203
 
158
204
  if error.kind_of?(Ably::Models::ErrorInfo)
159
205
  if RESOLVABLE_ERROR_CODES.fetch(:token_expired).include?(error.code)
160
- next_state = get_next_retry_state_info
161
- logger.debug "ConnectionManager: Transport disconnected because of token expiry, pausing #{next_state.fetch(:pause)}s before reattempting to connect"
206
+ next_state = get_next_retry_state_info(1)
207
+ logger.debug { "ConnectionManager: Transport disconnected because of token expiry, pausing #{next_state.fetch(:pause)}s before reattempting to connect" }
162
208
  EventMachine.add_timer(next_state.fetch(:pause)) { renew_token_and_reconnect error }
163
209
  return
164
210
  end
@@ -179,14 +225,13 @@ module Ably::Realtime
179
225
  # @api private
180
226
  def respond_to_transport_disconnected_whilst_connected(error)
181
227
  unless connection.disconnected? || connection.suspended?
182
- logger.warn "ConnectionManager: Connection #{"to #{connection.transport.url}" if connection.transport} was disconnected unexpectedly"
228
+ logger.warn { "ConnectionManager: Connection #{"to #{connection.transport.url}" if connection.transport} was disconnected unexpectedly" }
183
229
  else
184
- logger.debug "ConnectionManager: Transport disconnected whilst connection in #{connection.state} state"
230
+ logger.debug { "ConnectionManager: Transport disconnected whilst connection in #{connection.state} state" }
185
231
  end
186
232
 
187
233
  if error.kind_of?(Ably::Models::ErrorInfo) && !RESOLVABLE_ERROR_CODES.fetch(:token_expired).include?(error.code)
188
- connection.emit :error, error
189
- logger.error "ConnectionManager: Error in Disconnected ProtocolMessage received from the server - #{error}"
234
+ logger.error { "ConnectionManager: Error in Disconnected ProtocolMessage received from the server - #{error}" }
190
235
  end
191
236
 
192
237
  destroy_transport
@@ -200,10 +245,10 @@ module Ably::Realtime
200
245
  def error_received_from_server(error)
201
246
  case error.code
202
247
  when RESOLVABLE_ERROR_CODES.fetch(:token_expired)
203
- next_state = get_next_retry_state_info
248
+ next_state = get_next_retry_state_info(1)
204
249
  connection.transition_state_machine next_state.fetch(:state), retry_in: next_state.fetch(:pause), reason: error
205
250
  else
206
- logger.error "ConnectionManager: Error #{error.class.name} code #{error.code} received from server '#{error.message}', transitioning to failed state"
251
+ logger.error { "ConnectionManager: Error #{error.class.name} code #{error.code} received from server '#{error.message}', transitioning to failed state" }
207
252
  connection.transition_state_machine :failed, reason: error
208
253
  end
209
254
  end
@@ -215,6 +260,71 @@ module Ably::Realtime
215
260
  retries_for_state(state, ignore_states: [:connecting]).count
216
261
  end
217
262
 
263
+ # Any message sent before an ACK/NACK was received on the previous transport
264
+ # need to be resent to the Ably service so that a subsequent ACK/NACK is received.
265
+ # It is up to Ably to ensure that duplicate messages are not retransmitted on the channel
266
+ # base on the serial numbers
267
+ #
268
+ # @api private
269
+ def resend_pending_message_ack_queue
270
+ connection.__pending_message_ack_queue__.delete_if do |protocol_message|
271
+ if protocol_message.ack_required?
272
+ connection.__outgoing_message_queue__ << protocol_message
273
+ connection.__outgoing_protocol_msgbus__.publish :protocol_message
274
+ true
275
+ end
276
+ end
277
+ end
278
+
279
+ # @api private
280
+ def suspend_active_channels(error)
281
+ channels.select do |channel|
282
+ channel.attached? || channel.attaching? || channel.detaching?
283
+ end.each do |channel|
284
+ channel.transition_state_machine! :suspended, reason: error
285
+ end
286
+ end
287
+
288
+ # @api private
289
+ def detach_active_channels
290
+ channels.select do |channel|
291
+ channel.attached? || channel.attaching? || channel.detaching?
292
+ end.each do |channel|
293
+ channel.transition_state_machine! :detaching # will always move to detached immediately if connection is closed
294
+ end
295
+ end
296
+
297
+ # @api private
298
+ def fail_active_channels(error)
299
+ channels.select do |channel|
300
+ channel.attached? || channel.attaching? || channel.detaching? || channel.suspended?
301
+ end.each do |channel|
302
+ channel.transition_state_machine! :failed, reason: error
303
+ end
304
+ end
305
+
306
+ # When continuity on a connection is lost all messages
307
+ # whether queued or awaiting an ACK must be NACK'd as we now have a new connection
308
+ def nack_messages_on_all_channels(error)
309
+ channels.each do |channel|
310
+ channel.manager.fail_messages_awaiting_ack error, immediately: true
311
+ channel.manager.fail_queued_messages error
312
+ end
313
+ end
314
+
315
+ # Liveness timer ensures a connection that has not heard from Ably in heartbeat_interval
316
+ # is moved to the disconnected state automatically
317
+ def reset_liveness_timer
318
+ @liveness_timer.cancel if @liveness_timer
319
+ @liveness_timer = EventMachine::Timer.new(connection.heartbeat_interval + 0.1) do
320
+ if connection.connected? && (connection.time_since_connection_confirmed_alive? >= connection.heartbeat_interval)
321
+ msg = "No activity seen from realtime in #{connection.heartbeat_interval}; assuming connection has dropped";
322
+ error = Ably::Exceptions::ConnectionTimeout.new(msg, 80003, 408)
323
+ connection.transition_state_machine! :disconnected, reason: error
324
+ end
325
+ end
326
+ end
327
+
218
328
  private
219
329
  def connection
220
330
  @connection
@@ -265,7 +375,7 @@ module Ably::Realtime
265
375
  timers.fetch(key, []).each(&:cancel)
266
376
  end
267
377
 
268
- def get_next_retry_state_info
378
+ def get_next_retry_state_info(allow_extra_immediate_retries = 0)
269
379
  retry_state = if connection_retry_from_suspended_state? || !can_reattempt_connect_for_state?(:disconnected)
270
380
  :suspended
271
381
  else
@@ -273,14 +383,14 @@ module Ably::Realtime
273
383
  end
274
384
  {
275
385
  state: retry_state,
276
- pause: next_retry_pause(retry_state)
386
+ pause: next_retry_pause(retry_state, allow_extra_immediate_retries)
277
387
  }
278
388
  end
279
389
 
280
- def next_retry_pause(retry_state)
390
+ def next_retry_pause(retry_state, allow_extra_immediate_retries = 0)
281
391
  return nil unless state_has_retry_timeout?(retry_state)
282
392
 
283
- if retries_for_state(retry_state, ignore_states: [:connecting]).empty?
393
+ if retries_for_state(retry_state, ignore_states: [:connecting]).count <= allow_extra_immediate_retries
284
394
  0
285
395
  else
286
396
  retry_timeout_for(retry_state)
@@ -302,10 +412,10 @@ module Ably::Realtime
302
412
  def connection_retry_for(from_state)
303
413
  if can_reattempt_connect_for_state?(from_state)
304
414
  if connection.state == :disconnected && retries_for_state(from_state, ignore_states: [:connecting]).empty?
305
- logger.debug "ConnectionManager: Will attempt reconnect immediately as no previous reconnect attempts made in state #{from_state}"
415
+ logger.debug { "ConnectionManager: Will attempt reconnect immediately as no previous reconnect attempts made in state #{from_state}" }
306
416
  EventMachine.next_tick { connection.connect }
307
417
  else
308
- logger.debug "ConnectionManager: Pausing for #{retry_timeout_for(from_state)}s before attempting to reconnect"
418
+ logger.debug { "ConnectionManager: Pausing for #{retry_timeout_for(from_state)}s before attempting to reconnect" }
309
419
  create_timeout_timer_whilst_in_state(from_state, retry_timeout_for(from_state)) do
310
420
  connection.connect if connection.state == from_state
311
421
  end
@@ -380,7 +490,7 @@ module Ably::Realtime
380
490
  transport.unsafe_on(:disconnected) do |reason|
381
491
  if connection.closing?
382
492
  connection.transition_state_machine :closed
383
- elsif !connection.closed? && !connection.disconnected?
493
+ elsif !connection.closed? && !connection.disconnected? && !connection.failed? && !connection.suspended?
384
494
  exception = if reason
385
495
  Ably::Exceptions::TransportClosed.new(reason, nil, 80003)
386
496
  else
@@ -394,35 +504,21 @@ module Ably::Realtime
394
504
 
395
505
  def renew_token_and_reconnect(error)
396
506
  if client.auth.token_renewable?
397
- if @renewing_token
398
- logger.error 'ConnectionManager: Attempting to renew token whilst another token renewal is underway. Aborting current renew token request'
507
+ if currently_renewing_token?
508
+ logger.error { 'ConnectionManager: Attempting to renew token whilst another token renewal is underway. Aborting current renew token request' }
399
509
  return
400
510
  end
401
511
 
402
- @renewing_token = true
403
- logger.info "ConnectionManager: Token has expired and is renewable, renewing token now"
404
-
405
- client.auth.authorise(nil, force: true).tap do |authorise_deferrable|
406
- authorise_deferrable.callback do |token_details|
407
- logger.info 'ConnectionManager: Token renewed succesfully following expiration'
408
-
409
- connection.once_state_changed { @renewing_token = false }
512
+ logger.info { "ConnectionManager: Token has expired and is renewable, renewing token now" }
410
513
 
411
- if token_details && !token_details.expired?
412
- connection.connect
413
- else
414
- connection.transition_state_machine :failed, reason: error unless connection.failed?
415
- end
416
- end
417
-
418
- authorise_deferrable.errback do |auth_error|
419
- @renewing_token = false
420
- logger.error "ConnectionManager: Error authorising following token expiry: #{auth_error}"
421
- connection.transition_state_machine :failed, reason: auth_error
514
+ # Authorize implicitly reconnects, see #RTC8
515
+ client.auth.authorize.tap do |authorize_deferrable|
516
+ authorize_deferrable.callback do |token_details|
517
+ logger.info { 'ConnectionManager: Token renewed succesfully following expiration' }
422
518
  end
423
519
  end
424
520
  else
425
- logger.error "ConnectionManager: Token has expired and is not renewable - #{error}"
521
+ logger.error { "ConnectionManager: Token has expired and is not renewable - #{error}" }
426
522
  connection.transition_state_machine :failed, reason: error
427
523
  end
428
524
  end
@@ -430,7 +526,7 @@ module Ably::Realtime
430
526
  def unsubscribe_from_transport_events(transport)
431
527
  transport.__incoming_protocol_msgbus__.unsubscribe
432
528
  transport.off
433
- logger.debug "ConnectionManager: Unsubscribed from all events from current transport"
529
+ logger.debug { "ConnectionManager: Unsubscribed from all events from current transport" }
434
530
  end
435
531
 
436
532
  def close_connection_when_reactor_is_stopped
@@ -439,16 +535,26 @@ module Ably::Realtime
439
535
  end
440
536
  end
441
537
 
442
- def can_retry_connection?
443
- !@renewing_token
538
+ def currently_renewing_token?
539
+ client.auth.authorization_in_flight?
540
+ end
541
+
542
+ def reattach_suspended_channels(error)
543
+ channels.select do |channel|
544
+ channel.suspended?
545
+ end.each do |channel|
546
+ channel.transition_state_machine :attaching
547
+ end
444
548
  end
445
549
 
446
- def detach_attached_channels(error)
550
+ # When continuity on a connection is lost all messages
551
+ # Channels in the ATTACHED or ATTACHING state should explicitly be re-attached
552
+ # by sending a new ATTACH to Ably
553
+ def force_reattach_on_channels(error)
447
554
  channels.select do |channel|
448
555
  channel.attached? || channel.attaching?
449
556
  end.each do |channel|
450
- logger.warn "Force detaching channel '#{channel.name}': #{error}"
451
- channel.manager.suspend error
557
+ channel.manager.request_reattach reason: error
452
558
  end
453
559
  end
454
560