ably 0.8.15 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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