ably 0.8.5 → 0.8.6

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -1
  3. data/CHANGELOG.md +42 -48
  4. data/SPEC.md +1099 -640
  5. data/ably.gemspec +10 -4
  6. data/lib/ably/auth.rb +155 -47
  7. data/lib/ably/exceptions.rb +2 -0
  8. data/lib/ably/models/channel_state_change.rb +2 -3
  9. data/lib/ably/models/connection_details.rb +54 -0
  10. data/lib/ably/models/protocol_message.rb +14 -4
  11. data/lib/ably/models/token_details.rb +13 -7
  12. data/lib/ably/models/token_request.rb +1 -2
  13. data/lib/ably/modules/ably.rb +3 -2
  14. data/lib/ably/modules/message_emitter.rb +1 -3
  15. data/lib/ably/modules/state_emitter.rb +2 -2
  16. data/lib/ably/realtime/auth.rb +6 -0
  17. data/lib/ably/realtime/channel/channel_manager.rb +2 -0
  18. data/lib/ably/realtime/channel.rb +15 -4
  19. data/lib/ably/realtime/client/incoming_message_dispatcher.rb +11 -1
  20. data/lib/ably/realtime/client.rb +10 -3
  21. data/lib/ably/realtime/connection/connection_manager.rb +58 -54
  22. data/lib/ably/realtime/connection.rb +62 -6
  23. data/lib/ably/realtime/presence.rb +18 -5
  24. data/lib/ably/rest/channel.rb +9 -1
  25. data/lib/ably/rest/client.rb +32 -14
  26. data/lib/ably/rest/presence.rb +1 -1
  27. data/lib/ably/version.rb +1 -1
  28. data/lib/ably.rb +2 -0
  29. data/spec/acceptance/realtime/auth_spec.rb +251 -11
  30. data/spec/acceptance/realtime/channel_history_spec.rb +12 -2
  31. data/spec/acceptance/realtime/channel_spec.rb +316 -24
  32. data/spec/acceptance/realtime/client_spec.rb +93 -1
  33. data/spec/acceptance/realtime/connection_failures_spec.rb +177 -86
  34. data/spec/acceptance/realtime/connection_spec.rb +284 -60
  35. data/spec/acceptance/realtime/message_spec.rb +45 -6
  36. data/spec/acceptance/realtime/presence_history_spec.rb +4 -0
  37. data/spec/acceptance/realtime/presence_spec.rb +181 -49
  38. data/spec/acceptance/realtime/time_spec.rb +13 -0
  39. data/spec/acceptance/rest/auth_spec.rb +222 -4
  40. data/spec/acceptance/rest/channel_spec.rb +132 -1
  41. data/spec/acceptance/rest/client_spec.rb +129 -28
  42. data/spec/acceptance/rest/presence_spec.rb +7 -7
  43. data/spec/acceptance/rest/time_spec.rb +10 -0
  44. data/spec/shared/client_initializer_behaviour.rb +41 -17
  45. data/spec/spec_helper.rb +1 -0
  46. data/spec/support/debug_failure_helper.rb +16 -0
  47. data/spec/unit/models/connection_details_spec.rb +60 -0
  48. data/spec/unit/models/protocol_message_spec.rb +45 -0
  49. data/spec/unit/modules/event_emitter_spec.rb +3 -1
  50. data/spec/unit/realtime/channel_spec.rb +6 -5
  51. data/spec/unit/realtime/client_spec.rb +5 -1
  52. data/spec/unit/realtime/connection_spec.rb +5 -1
  53. data/spec/unit/realtime/realtime_spec.rb +5 -1
  54. metadata +54 -7
@@ -73,7 +73,7 @@ module Ably::Realtime
73
73
  elsif connection.connected?
74
74
  logger.error "CONNECTED ProtocolMessage should not have been received when the connection is in the CONNECTED state"
75
75
  else
76
- connection.transition_state_machine :connected, reason: protocol_message.error, protocol_message: protocol_message
76
+ process_connected_message protocol_message
77
77
  end
78
78
 
79
79
  when ACTION.Disconnect, ACTION.Disconnected
@@ -141,6 +141,16 @@ module Ably::Realtime
141
141
  connection.manager.error_received_from_server protocol_message.error
142
142
  end
143
143
 
144
+ def process_connected_message(protocol_message)
145
+ if client.auth.token_client_id_allowed?(protocol_message.connection_details.client_id)
146
+ client.auth.configure_client_id protocol_message.connection_details.client_id
147
+ connection.transition_state_machine :connected, reason: protocol_message.error, protocol_message: protocol_message
148
+ else
149
+ reason = Ably::Exceptions::IncompatibleClientId.new("Client ID '#{protocol_message.connection_details.client_id}' specified by the server is incompatible with the library's configured client ID '#{client.client_id}'", 400, 40012)
150
+ connection.transition_state_machine :failed, reason: reason, protocol_message: protocol_message
151
+ end
152
+ end
153
+
144
154
  def update_connection_recovery_info(protocol_message)
145
155
  connection.update_connection_serial protocol_message.connection_serial if protocol_message.has_connection_serial?
146
156
  end
@@ -30,6 +30,10 @@ module Ably
30
30
  # (see Ably::Rest::Client#auth)
31
31
  attr_reader :auth
32
32
 
33
+ # The underlying connection for this client
34
+ # @return [Aby::Realtime::Connection]
35
+ attr_reader :connection
36
+
33
37
  # The {Ably::Rest::Client REST client} instantiated with the same credentials and configuration that is used for all REST operations such as authentication
34
38
  # @return [Ably::Rest::Client]
35
39
  attr_reader :rest_client
@@ -69,6 +73,9 @@ module Ably
69
73
  # @option options [String] :recover When a recover option is specified a connection inherits the state of a previous connection that may have existed under a different instance of the Realtime library, please refer to the API documentation for further information on connection state recovery
70
74
  # @option options [Boolean] :auto_connect By default as soon as the client library is instantiated it will connect to Ably. You can optionally set this to false and explicitly connect.
71
75
  #
76
+ # @option options [Integer] :disconnected_retry_timeout (15 seconds). 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
77
+ # @option options [Integer] :suspended_retry_timeout (30 seconds). 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
78
+ #
72
79
  # @return [Ably::Realtime::Client]
73
80
  #
74
81
  # @example
@@ -82,6 +89,7 @@ module Ably
82
89
  @rest_client = Ably::Rest::Client.new(options)
83
90
  @auth = Ably::Realtime::Auth.new(self)
84
91
  @channels = Ably::Realtime::Channels.new(self)
92
+ @connection = Ably::Realtime::Connection.new(self, options)
85
93
  @echo_messages = @rest_client.options.fetch(:echo_messages, true) == false ? false : true
86
94
  @queue_messages = @rest_client.options.fetch(:queue_messages, true) == false ? false : true
87
95
  @custom_realtime_host = @rest_client.options[:realtime_host] || @rest_client.options[:ws_host]
@@ -142,10 +150,9 @@ module Ably
142
150
  endpoint_for_host(custom_realtime_host || [environment, DOMAIN].compact.join('-'))
143
151
  end
144
152
 
145
- # @!attribute [r] connection
146
- # @return [Aby::Realtime::Connection] The underlying connection for this client
153
+
147
154
  def connection
148
- @connection ||= Connection.new(self)
155
+ @connection
149
156
  end
150
157
 
151
158
  # (see Ably::Rest::Client#register_encoder)
@@ -8,18 +8,6 @@ module Ably::Realtime
8
8
  #
9
9
  # @api private
10
10
  class ConnectionManager
11
- # Configuration for automatic recovery of failed connection attempts
12
- CONNECT_RETRY_CONFIG = {
13
- disconnected: { retry_every: 15, max_time_in_state: 120 },
14
- suspended: { retry_every: 120, max_time_in_state: Float::INFINITY }
15
- }.freeze
16
-
17
- # Time to wait following a connection state request before it's considered a failure
18
- TIMEOUTS = {
19
- open: 15,
20
- close: 10
21
- }
22
-
23
11
  # Error codes from the server that can potentially be resolved
24
12
  RESOLVABLE_ERROR_CODES = {
25
13
  token_expired: 40140
@@ -69,9 +57,9 @@ module Ably::Realtime
69
57
  end
70
58
  end
71
59
 
72
- logger.debug "ConnectionManager: Setting up automatic connection timeout timer for #{TIMEOUTS.fetch(:open)}s"
73
- create_timeout_timer_whilst_in_state(:connect, TIMEOUTS.fetch(:open)) do
74
- connection_opening_failed Ably::Exceptions::ConnectionTimeout.new("Connection to Ably timed out after #{TIMEOUTS.fetch(:open)}s", nil, 80014)
60
+ logger.debug "ConnectionManager: Setting up automatic connection timeout timer for #{realtime_request_timeout}s"
61
+ create_timeout_timer_whilst_in_state(:connect, realtime_request_timeout) do
62
+ connection_opening_failed Ably::Exceptions::ConnectionTimeout.new("Connection to Ably timed out after #{realtime_request_timeout}s", nil, 80014)
75
63
  end
76
64
  end
77
65
 
@@ -79,6 +67,11 @@ module Ably::Realtime
79
67
  #
80
68
  # @api private
81
69
  def connection_opening_failed(error)
70
+ if error.kind_of?(Ably::Exceptions::IncompatibleClientId)
71
+ client.connection.transition_state_machine :failed, reason: error
72
+ return
73
+ end
74
+
82
75
  logger.warn "ConnectionManager: Connection to #{connection.current_host}:#{connection.port} failed; #{error.message}"
83
76
  next_state = get_next_retry_state_info
84
77
  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)
@@ -89,18 +82,17 @@ module Ably::Realtime
89
82
  # @api private
90
83
  def connected(protocol_message)
91
84
  if connection.key
92
- if connection_key_shared(protocol_message.connection_key) == connection_key_shared(connection.key)
85
+ if protocol_message.connection_id == connection.id
93
86
  logger.debug "ConnectionManager: Connection resumed successfully - ID #{connection.id} and key #{connection.key}"
94
87
  EventMachine.next_tick { connection.resumed }
95
88
  else
96
89
  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}"
97
90
  detach_attached_channels protocol_message.error
98
- connection.configure_new protocol_message.connection_id, protocol_message.connection_key, protocol_message.connection_serial
99
91
  end
100
92
  else
101
93
  logger.debug "ConnectionManager: New connection created with ID #{protocol_message.connection_id} and key #{protocol_message.connection_key}"
102
- connection.configure_new protocol_message.connection_id, protocol_message.connection_key, protocol_message.connection_serial
103
94
  end
95
+ connection.configure_new protocol_message.connection_id, protocol_message.connection_key, protocol_message.connection_serial
104
96
  end
105
97
 
106
98
  # Ensures the underlying transport has been disconnected and all event emitter callbacks removed
@@ -131,7 +123,7 @@ module Ably::Realtime
131
123
  def close_connection
132
124
  connection.send_protocol_message(action: Ably::Models::ProtocolMessage::ACTION.Close)
133
125
 
134
- create_timeout_timer_whilst_in_state(:close, TIMEOUTS.fetch(:close)) do
126
+ create_timeout_timer_whilst_in_state(:close, realtime_request_timeout) do
135
127
  force_close_connection if connection.closing?
136
128
  end
137
129
  end
@@ -158,19 +150,23 @@ module Ably::Realtime
158
150
  # @api private
159
151
  def respond_to_transport_disconnected_when_connecting(error)
160
152
  return unless connection.disconnected? || connection.suspended? # do nothing if state has changed through an explicit request
161
- return unless retry_connection? # do not always reattempt connection or change state as client may be re-authorising
153
+ return unless can_retry_connection? # do not always reattempt connection or change state as client may be re-authorising
162
154
 
163
155
  if error.kind_of?(Ably::Models::ErrorInfo)
164
- renew_token_and_reconnect error if error.code == RESOLVABLE_ERROR_CODES.fetch(:token_expired)
165
- return
156
+ if error.code == RESOLVABLE_ERROR_CODES.fetch(:token_expired)
157
+ next_state = get_next_retry_state_info
158
+ logger.debug "ConnectionManager: Transport disconnected because of token expiry, pausing #{next_state.fetch(:pause)}s before reattempting to connect"
159
+ EventMachine.add_timer(next_state.fetch(:pause)) { renew_token_and_reconnect error }
160
+ return
161
+ end
166
162
  end
167
163
 
168
- unless connection_retry_from_suspended_state?
164
+ if connection.state == :suspended
165
+ return if connection_retry_for(:suspended)
166
+ elsif connection.state == :disconnected
169
167
  return if connection_retry_for(:disconnected)
170
168
  end
171
169
 
172
- return if connection_retry_for(:suspended)
173
-
174
170
  # Fallback if no other criteria met
175
171
  connection.transition_state_machine :failed, reason: error
176
172
  end
@@ -179,7 +175,11 @@ module Ably::Realtime
179
175
  #
180
176
  # @api private
181
177
  def respond_to_transport_disconnected_whilst_connected(error)
182
- logger.warn "ConnectionManager: Connection #{"to #{connection.transport.url}" if connection.transport} was disconnected unexpectedly"
178
+ unless connection.disconnected? || connection.suspended?
179
+ logger.warn "ConnectionManager: Connection #{"to #{connection.transport.url}" if connection.transport} was disconnected unexpectedly"
180
+ else
181
+ logger.debug "ConnectionManager: Transport disconnected whilst connection in #{connection.state} state"
182
+ end
183
183
 
184
184
  if error.kind_of?(Ably::Models::ErrorInfo) && error.code != RESOLVABLE_ERROR_CODES.fetch(:token_expired)
185
185
  connection.emit :error, error
@@ -197,10 +197,8 @@ module Ably::Realtime
197
197
  def error_received_from_server(error)
198
198
  case error.code
199
199
  when RESOLVABLE_ERROR_CODES.fetch(:token_expired)
200
- connection.transition_state_machine :disconnected, retry_in: 0
201
- connection.unsafe_once_or_if(:disconnected) do
202
- renew_token_and_reconnect error
203
- end
200
+ next_state = get_next_retry_state_info
201
+ connection.transition_state_machine next_state.fetch(:state), retry_in: next_state.fetch(:pause), reason: error
204
202
  else
205
203
  logger.error "ConnectionManager: Error #{error.class.name} code #{error.code} received from server '#{error.message}', transitioning to failed state"
206
204
  connection.transition_state_machine :failed, reason: error
@@ -233,10 +231,16 @@ module Ably::Realtime
233
231
  client.channels
234
232
  end
235
233
 
236
- # Connection key left part is consistent between connection resumes
237
- # i.e. wVIsgTHAB1UvXh7z-1991d8586 becomes wVIsgTHAB1UvXh7z-1990d8586 after a resume
238
- def connection_key_shared(connection_key)
239
- (connection_key || '')[/^\w{5,}-/, 0]
234
+ def realtime_request_timeout
235
+ connection.defaults.fetch(:realtime_request_timeout)
236
+ end
237
+
238
+ def retry_timeout_for(state)
239
+ connection.defaults.fetch("#{state}_retry_timeout".to_sym) { raise ArgumentError.new("#{state} does not have a configured retry timeout") }
240
+ end
241
+
242
+ def state_has_retry_timeout?(state)
243
+ connection.defaults.has_key?("#{state}_retry_timeout".to_sym)
240
244
  end
241
245
 
242
246
  # Create a timer that will execute in timeout_in seconds.
@@ -267,12 +271,12 @@ module Ably::Realtime
267
271
  end
268
272
 
269
273
  def next_retry_pause(retry_state)
270
- return nil unless CONNECT_RETRY_CONFIG.fetch(retry_state)
274
+ return nil unless state_has_retry_timeout?(retry_state)
271
275
 
272
276
  if retries_for_state(retry_state, ignore_states: [:connecting]).empty?
273
277
  0
274
278
  else
275
- CONNECT_RETRY_CONFIG.fetch(retry_state).fetch(:retry_every)
279
+ retry_timeout_for(retry_state)
276
280
  end
277
281
  end
278
282
 
@@ -284,20 +288,18 @@ module Ably::Realtime
284
288
  time_spent_attempting_state(:disconnected, ignore_states: [:connecting])
285
289
  end
286
290
 
287
- # Reattempt a connection with a delay based on the CONNECT_RETRY_CONFIG for `from_state`
291
+ # Reattempt a connection with a delay based on the configured retry timeout for +from_state+
288
292
  #
289
293
  # @return [Boolean] True if a connection attempt has been set up, false if no further connection attempts can be made for this state
290
294
  #
291
295
  def connection_retry_for(from_state)
292
- retry_params = CONNECT_RETRY_CONFIG.fetch(from_state)
293
-
294
296
  if can_reattempt_connect_for_state?(from_state)
295
- if retries_for_state(from_state, ignore_states: [:connecting]).empty?
296
- logger.debug "ConnectionManager: Will attempt reconnect immediately as no previous reconnect attempts made in this state"
297
+ if connection.state == :disconnected && retries_for_state(from_state, ignore_states: [:connecting]).empty?
298
+ logger.debug "ConnectionManager: Will attempt reconnect immediately as no previous reconnect attempts made in state #{from_state}"
297
299
  EventMachine.next_tick { connection.connect }
298
300
  else
299
- logger.debug "ConnectionManager: Pausing for #{retry_params.fetch(:retry_every)}s before attempting to reconnect"
300
- create_timeout_timer_whilst_in_state(:reconnect, retry_params.fetch(:retry_every)) do
301
+ logger.debug "ConnectionManager: Pausing for #{retry_timeout_for(from_state)}s before attempting to reconnect"
302
+ create_timeout_timer_whilst_in_state(from_state, retry_timeout_for(from_state)) do
301
303
  connection.connect if connection.state == from_state
302
304
  end
303
305
  end
@@ -309,8 +311,14 @@ module Ably::Realtime
309
311
  # For example, if the state is disconnected, and has been in a cycle of disconnected > connect > disconnected
310
312
  # so long as the time in this cycle of states is less than max_time_in_state, this will return true
311
313
  def can_reattempt_connect_for_state?(state)
312
- retry_params = CONNECT_RETRY_CONFIG.fetch(state)
313
- time_spent_attempting_state(state, ignore_states: [:connecting]) < retry_params.fetch(:max_time_in_state)
314
+ case state
315
+ when :disconnected
316
+ time_spent_attempting_state(:disconnected, ignore_states: [:connecting]) < connection.defaults.fetch(:connection_state_ttl)
317
+ when :suspended
318
+ true # suspended state remains indefinitely
319
+ else
320
+ raise ArgumentError, "Connections in state '#{state}' cannot be reattempted"
321
+ end
314
322
  end
315
323
 
316
324
  # Returns a float representing the amount of time passed since the first consecutive attempt of this state
@@ -380,23 +388,18 @@ module Ably::Realtime
380
388
  def renew_token_and_reconnect(error)
381
389
  if client.auth.token_renewable?
382
390
  if @renewing_token
383
- connection.transition_state_machine :failed, reason: error
391
+ logger.error 'ConnectionManager: Attempting to renew token whilst another token renewal is underway. Aborting current renew token request'
384
392
  return
385
393
  end
386
394
 
387
395
  @renewing_token = true
388
396
  logger.info "ConnectionManager: Token has expired and is renewable, renewing token now"
389
397
 
390
- client.auth.authorise.tap do |authorise_deferrable|
398
+ client.auth.authorise({}, force: true).tap do |authorise_deferrable|
391
399
  authorise_deferrable.callback do |token_details|
392
400
  logger.info 'ConnectionManager: Token renewed succesfully following expiration'
393
401
 
394
- state_changed_callback = proc do
395
- @renewing_token = false
396
- connection.off &state_changed_callback
397
- end
398
-
399
- connection.unsafe_once :connected, :closed, :failed, &state_changed_callback
402
+ connection.once_state_changed { @renewing_token = false }
400
403
 
401
404
  if token_details && !token_details.expired?
402
405
  connection.connect
@@ -406,6 +409,7 @@ module Ably::Realtime
406
409
  end
407
410
 
408
411
  authorise_deferrable.errback do |auth_error|
412
+ @renewing_token = false
409
413
  logger.error "ConnectionManager: Error authorising following token expiry: #{auth_error}"
410
414
  connection.transition_state_machine :failed, reason: auth_error
411
415
  end
@@ -428,7 +432,7 @@ module Ably::Realtime
428
432
  end
429
433
  end
430
434
 
431
- def retry_connection?
435
+ def can_retry_connection?
432
436
  !@renewing_token
433
437
  end
434
438
 
@@ -60,6 +60,14 @@ module Ably
60
60
  # Expected format for a connection recover key
61
61
  RECOVER_REGEX = /^(?<recover>[\w-]+):(?<connection_serial>\-?\w+)$/
62
62
 
63
+ # Defaults for automatic connection recovery and timeouts
64
+ DEFAULTS = {
65
+ 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
+ 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
+ connection_state_ttl: 60, # the duration that Ably will persist the connection state when a Realtime client is abruptly disconnected
68
+ realtime_request_timeout: 10 # default timeout when establishing a connection, or sending a HEARTBEAT, CONNECT, ATTACH, DETACH or CLOSE ProtocolMessage
69
+ }.freeze
70
+
63
71
  # A unique public identifier for this connection, used to identify this member in presence events and messages
64
72
  # @return [String]
65
73
  attr_reader :id
@@ -90,23 +98,35 @@ module Ably
90
98
  # @api private
91
99
  attr_reader :manager
92
100
 
93
- # An internal queue used to manage unsent outgoing messages. You should never interface with this array directly
101
+ # An internal queue used to manage unsent outgoing messages. You should never interface with this array directly
94
102
  # @return [Array]
95
103
  # @api private
96
104
  attr_reader :__outgoing_message_queue__
97
105
 
98
- # An internal queue used to manage sent messages. You should never interface with this array directly
106
+ # An internal queue used to manage sent messages. You should never interface with this array directly
99
107
  # @return [Array]
100
108
  # @api private
101
109
  attr_reader :__pending_message_ack_queue__
102
110
 
111
+ # Configured recovery and timeout defaults for this {Connection}.
112
+ # See the configurable options in {Ably::Realtime::Client#initialize}.
113
+ # The defaults are immutable
114
+ # @return [Hash]
115
+ attr_reader :defaults
116
+
103
117
  # @api public
104
- def initialize(client)
118
+ def initialize(client, options)
105
119
  @client = client
106
120
  @client_serial = -1
107
121
  @__outgoing_message_queue__ = []
108
122
  @__pending_message_ack_queue__ = []
109
123
 
124
+ @defaults = DEFAULTS.dup
125
+ options.each do |key, val|
126
+ @defaults[key] = val if DEFAULTS.has_key?(key)
127
+ end if options.kind_of?(Hash)
128
+ @defaults.freeze
129
+
110
130
  Client::IncomingMessageDispatcher.new client, self
111
131
  Client::OutgoingMessageDispatcher.new client, self
112
132
 
@@ -133,6 +153,11 @@ module Ably
133
153
 
134
154
  # Causes the library to attempt connection. If it was previously explicitly
135
155
  # closed by the user, or was closed as a result of an unrecoverable error, a new connection will be opened.
156
+ # Succeeds when connection is established i.e. state is @Connected@
157
+ # Fails when state becomes either @Closing@, @Closed@ or @Failed@
158
+ #
159
+ # Note that if the connection remains in the disconnected ans suspended states indefinitely,
160
+ # the Deferrable or block provided may never be called
136
161
  #
137
162
  # @yield block is called as soon as this connection is in the Connected state
138
163
  #
@@ -143,14 +168,32 @@ module Ably
143
168
  raise exception_for_state_change_to(:connecting) unless can_transition_to?(:connecting)
144
169
  transition_state_machine :connecting
145
170
  end
146
- deferrable_for_state_change_to(STATE.Connected, &success_block)
171
+
172
+ Ably::Util::SafeDeferrable.new(logger).tap do |deferrable|
173
+ deferrable.callback do
174
+ yield if block_given?
175
+ end
176
+ succeed_callback = deferrable.method(:succeed)
177
+ fail_callback = deferrable.method(:fail)
178
+
179
+ once(:connected) do
180
+ deferrable.succeed
181
+ off &fail_callback
182
+ end
183
+
184
+ once(:failed, :closed, :closing) do
185
+ deferrable.fail
186
+ off &succeed_callback
187
+ end
188
+ end
147
189
  end
148
190
 
149
191
  # Sends a ping to Ably and yields the provided block when a heartbeat ping request is echoed from the server.
150
192
  # This can be useful for measuring true roundtrip client to Ably server latency for a simple message, or checking that an underlying transport is responding currently.
151
193
  # The elapsed milliseconds is passed as an argument to the block and represents the time taken to echo a ping heartbeat once the connection is in the `:connected` state.
152
194
  #
153
- # @yield [Integer] if a block is passed to this method, then this block will be called once the ping heartbeat is received with the time elapsed in milliseconds
195
+ # @yield [Integer] if a block is passed to this method, then this block will be called once the ping heartbeat is received with the time elapsed in milliseconds.
196
+ # If the ping is not received within an acceptable timeframe, the block will be called with +nil+ as he first argument
154
197
  #
155
198
  # @example
156
199
  # client = Ably::Rest::Client.new(key: 'key.id:secret')
@@ -165,9 +208,12 @@ module Ably
165
208
  raise RuntimeError, 'Cannot send a ping when connection is in a closed or failed state' if closed? || failed?
166
209
 
167
210
  started = nil
211
+ finished = false
168
212
 
169
213
  wait_for_ping = Proc.new do |protocol_message|
214
+ next if finished
170
215
  if protocol_message.action == Ably::Models::ProtocolMessage::ACTION.Heartbeat
216
+ finished = true
171
217
  __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
172
218
  time_passed = (Time.now.to_f * 1000 - started.to_f * 1000).to_i
173
219
  safe_yield block, time_passed if block_given?
@@ -175,10 +221,19 @@ module Ably
175
221
  end
176
222
 
177
223
  once_or_if(STATE.Connected) do
224
+ next if finished
178
225
  started = Time.now
179
226
  send_protocol_message action: Ably::Models::ProtocolMessage::ACTION.Heartbeat.to_i
180
227
  __incoming_protocol_msgbus__.subscribe :protocol_message, &wait_for_ping
181
228
  end
229
+
230
+ EventMachine.add_timer(defaults.fetch(:realtime_request_timeout)) do
231
+ next if finished
232
+ finished = true
233
+ __incoming_protocol_msgbus__.unsubscribe(:protocol_message, &wait_for_ping)
234
+ logger.warn "Ping timed out after #{defaults.fetch(:realtime_request_timeout)}s"
235
+ safe_yield block, nil if block_given?
236
+ end
182
237
  end
183
238
 
184
239
  # @yield [Boolean] True if an internet connection check appears to be up following an HTTP request to a reliable CDN
@@ -327,11 +382,12 @@ module Ably
327
382
  client.auth.auth_params.tap do |auth_deferrable|
328
383
  auth_deferrable.callback do |auth_params|
329
384
  url_params = auth_params.merge(
330
- timestamp: as_since_epoch(Time.now),
331
385
  format: client.protocol,
332
386
  echo: client.echo_messages
333
387
  )
334
388
 
389
+ url_params['clientId'] = client.auth.client_id if client.auth.has_client_id?
390
+
335
391
  if connection_resumable?
336
392
  url_params.merge! resume: key, connection_serial: serial
337
393
  logger.debug "Resuming connection key #{key} with serial #{serial}"
@@ -74,8 +74,8 @@ module Ably::Realtime
74
74
  data = options.fetch(:data, nil)
75
75
  deferrable = create_deferrable
76
76
 
77
+ ensure_supported_client_id client_id
77
78
  ensure_supported_payload data unless data.nil?
78
- raise Ably::Exceptions::Standard.new('Unable to enter presence channel without a client_id', 400, 91000) unless client_id
79
79
 
80
80
  @data = data
81
81
  @client_id = client_id
@@ -120,8 +120,8 @@ module Ably::Realtime
120
120
  #
121
121
  def enter_client(client_id, options = {}, &success_block)
122
122
  raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash)
123
+ ensure_supported_client_id client_id
123
124
  ensure_supported_payload options[:data] if options.has_key?(:data)
124
- raise Ably::Exceptions::Standard.new('Unable to enter presence channel without a client_id', 400, 91000) unless client_id
125
125
 
126
126
  send_presence_action_for_client(Ably::Models::PresenceMessage::ACTION.Enter, client_id, options, &success_block)
127
127
  end
@@ -139,6 +139,7 @@ module Ably::Realtime
139
139
  data = options.fetch(:data, self.data) # nil value defaults leave data to existing value
140
140
  deferrable = create_deferrable
141
141
 
142
+ ensure_supported_client_id client_id
142
143
  ensure_supported_payload data unless data.nil?
143
144
  raise Ably::Exceptions::Standard.new('Unable to leave presence channel that is not entered', 400, 91002) unless able_to_leave?
144
145
 
@@ -178,8 +179,8 @@ module Ably::Realtime
178
179
  #
179
180
  def leave_client(client_id, options = {}, &success_block)
180
181
  raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash)
182
+ ensure_supported_client_id client_id
181
183
  ensure_supported_payload options[:data] if options.has_key?(:data)
182
- raise Ably::Exceptions::Standard.new('Unable to leave presence channel without a client_id', 400, 91000) unless client_id
183
184
 
184
185
  send_presence_action_for_client(Ably::Models::PresenceMessage::ACTION.Leave, client_id, options, &success_block)
185
186
  end
@@ -198,8 +199,8 @@ module Ably::Realtime
198
199
  data = options.fetch(:data, nil)
199
200
  deferrable = create_deferrable
200
201
 
202
+ ensure_supported_client_id client_id
201
203
  ensure_supported_payload data unless data.nil?
202
- raise Ably::Exceptions::Standard.new('Unable to update presence channel without a client_id', 400, 91000) unless client_id
203
204
 
204
205
  @data = data
205
206
 
@@ -230,8 +231,8 @@ module Ably::Realtime
230
231
  #
231
232
  def update_client(client_id, options = {}, &success_block)
232
233
  raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash)
234
+ ensure_supported_client_id client_id
233
235
  ensure_supported_payload options[:data] if options.has_key?(:data)
234
- raise Ably::Exceptions::Standard.new('Unable to enter presence channel without a client_id', 400, 91000) unless client_id
235
236
 
236
237
  send_presence_action_for_client(Ably::Models::PresenceMessage::ACTION.Update, client_id, options, &success_block)
237
238
  end
@@ -387,6 +388,18 @@ module Ably::Realtime
387
388
  deferrable
388
389
  end
389
390
 
391
+ def ensure_supported_client_id(check_client_id)
392
+ unless check_client_id
393
+ raise Ably::Exceptions::IncompatibleClientId.new('Unable to enter/update/leave presence channel without a client_id', 400, 40012)
394
+ end
395
+ if check_client_id == '*'
396
+ raise Ably::Exceptions::IncompatibleClientId.new('Unable to enter/update/leave presence channel with the reserved wildcard client_id', 400, 40012)
397
+ end
398
+ unless client.auth.can_assume_client_id?(check_client_id)
399
+ raise Ably::Exceptions::IncompatibleClientId.new("Cannot enter with provided client_id '#{check_client_id}' as it is incompatible with the current configured client_id '#{client.client_id}'", 400, 40012)
400
+ end
401
+ end
402
+
390
403
  def send_protocol_message_and_transition_state_to(action, options = {}, &success_block)
391
404
  deferrable = options.fetch(:deferrable) { raise ArgumentError, 'option :deferrable is required' }
392
405
  client_id = options.fetch(:client_id) { raise ArgumentError, 'option :client_id is required' }
@@ -67,6 +67,14 @@ module Ably
67
67
  payload = messages.map do |message|
68
68
  Ably::Models::Message(message.dup).tap do |message|
69
69
  message.encode self
70
+
71
+ next if message.client_id.nil?
72
+ if message.client_id == '*'
73
+ raise Ably::Exceptions::IncompatibleClientId.new('Wildcard client_id is reserved and cannot be used when publishing messages', 400, 40012)
74
+ end
75
+ unless client.auth.can_assume_client_id?(message.client_id)
76
+ raise Ably::Exceptions::IncompatibleClientId.new("Cannot publish with client_id '#{message.client_id}' as it is incompatible with the current configured client_id '#{client.client_id}'", 400, 40012)
77
+ end
70
78
  end.as_json
71
79
  end
72
80
 
@@ -123,7 +131,7 @@ module Ably
123
131
 
124
132
  private
125
133
  def base_path
126
- "/channels/#{CGI.escape(name)}"
134
+ "/channels/#{Addressable::URI.encode(name)}"
127
135
  end
128
136
 
129
137
  def decode_message(message)