lowdown 0.2.0 → 0.3.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "celluloid/current"
4
+
5
+ module Lowdown
6
+ class Client
7
+ # Implements the {Connection::DelegateProtocol} to provide a way to group requests and hal the caller thread while
8
+ # waiting for the responses to come in. In addition to the regular delegate message based callbacks, it also allows
9
+ # for a more traditional blocks-based callback mechanism.
10
+ #
11
+ # @note These callbacks are executed on a separate thread, so be aware about this when accessing shared resources
12
+ # from a block callback.
13
+ #
14
+ class RequestGroup
15
+ attr_reader :callbacks
16
+
17
+ def initialize(client, condition)
18
+ @client = client
19
+ @callbacks = Callbacks.new(condition)
20
+ end
21
+
22
+ def send_notification(notification, delegate: nil, context: nil, &block)
23
+ return unless @callbacks.alive?
24
+ if (block.nil? && delegate.nil?) || (block && delegate)
25
+ raise ArgumentError, "Either a delegate object or a block should be provided."
26
+ end
27
+ @callbacks.add(notification.formatted_id, block || delegate)
28
+ @client.send_notification(notification, delegate: @callbacks.async, context: context)
29
+ end
30
+
31
+ def empty?
32
+ @callbacks.empty?
33
+ end
34
+
35
+ def terminate
36
+ @callbacks.terminate if @callbacks.alive?
37
+ end
38
+
39
+ class Callbacks
40
+ include Celluloid
41
+
42
+ def initialize(condition)
43
+ @callbacks = {}
44
+ @condition = condition
45
+ end
46
+
47
+ def empty?
48
+ @callbacks.empty?
49
+ end
50
+
51
+ def add(notification_id, callback)
52
+ raise ArgumentError, "A notification ID is required." unless notification_id
53
+ @callbacks[notification_id] = callback
54
+ end
55
+
56
+ def handle_apns_response(response, context:)
57
+ callback = @callbacks.delete(response.id)
58
+ if callback.is_a?(Proc)
59
+ callback.call(response, context)
60
+ else
61
+ callback.send(:handle_apns_response, response, context: context)
62
+ end
63
+ ensure
64
+ @condition.signal if @callbacks.empty?
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+
@@ -1,44 +1,49 @@
1
- require "lowdown/threading"
2
- require "lowdown/response"
1
+ # This file can’t specify that it uses frozen string literals yet, because the strings’ encodings are modified when
2
+ # passed to the http-2 gem.
3
3
 
4
- require "http/2"
4
+ require "lowdown/response"
5
5
 
6
- require "openssl"
7
- require "socket"
8
- require "timeout"
9
6
  require "uri"
10
7
 
8
+ require "celluloid/current"
9
+ require "celluloid/io"
10
+ require "http/2"
11
+
11
12
  if HTTP2::VERSION == "0.8.0"
12
13
  # @!visibility private
13
14
  #
14
- # This monkey-patch ensures that we send the HTTP/2 connection preface before anything else.
15
- #
16
- # @see https://github.com/igrigorik/http-2/pull/44
17
- #
18
- class HTTP2::Client
19
- def connection_management(frame)
20
- if @state == :waiting_connection_preface
21
- send_connection_preface
22
- connection_settings(frame)
23
- else
24
- super(frame)
15
+ module HTTP2
16
+ # @!visibility private
17
+ #
18
+ # This monkey-patch ensures that we send the HTTP/2 connection preface before anything else.
19
+ #
20
+ # @see https://github.com/igrigorik/http-2/pull/44
21
+ #
22
+ class Client
23
+ def connection_management(frame)
24
+ if @state == :waiting_connection_preface
25
+ send_connection_preface
26
+ connection_settings(frame)
27
+ else
28
+ super(frame)
29
+ end
25
30
  end
26
31
  end
27
- end
28
32
 
29
- # @!visibility private
30
- #
31
- # These monkey-patches ensure that data added to a buffer has a binary encoding, as to not lead to encoding clashes.
32
- #
33
- # @see https://github.com/igrigorik/http-2/pull/46
34
- #
35
- class HTTP2::Buffer
36
- def <<(x)
37
- super(x.force_encoding(Encoding::BINARY))
38
- end
33
+ # @!visibility private
34
+ #
35
+ # These monkey-patches ensure that data added to a buffer has a binary encoding, as to not lead to encoding clashes.
36
+ #
37
+ # @see https://github.com/igrigorik/http-2/pull/46
38
+ #
39
+ class Buffer
40
+ def <<(x)
41
+ super(x.force_encoding(Encoding::BINARY))
42
+ end
39
43
 
40
- def prepend(x)
41
- super(x.force_encoding(Encoding::BINARY))
44
+ def prepend(x)
45
+ super(x.force_encoding(Encoding::BINARY))
46
+ end
42
47
  end
43
48
  end
44
49
  end
@@ -49,14 +54,36 @@ module Lowdown
49
54
  # It manages both the SSL connection and processing of the HTTP/2 data sent back and forth over that connection.
50
55
  #
51
56
  class Connection
57
+ class TimedOut < StandardError; end
58
+
59
+ CONNECT_RETRIES = 5
60
+ CONNECT_RETRY_BACKOFF = 5
61
+ CONNECT_TIMEOUT = 10
62
+ HEARTBEAT_INTERVAL = 10
63
+ HEARTBEAT_TIMEOUT = CONNECT_TIMEOUT
64
+
65
+ include Celluloid::IO
66
+ include Celluloid::Internals::Logger
67
+ finalizer :disconnect
68
+
52
69
  # @param [URI, String] uri
53
70
  # the details to connect to the APN service.
54
71
  #
55
72
  # @param [OpenSSL::SSL::SSLContext] ssl_context
56
73
  # a SSL context, configured with the certificate/key pair, which is used to connect to the APN service.
57
74
  #
58
- def initialize(uri, ssl_context)
75
+ # @param [Boolean] connect
76
+ # whether or not to immediately connect on initialization.
77
+ #
78
+ def initialize(uri, ssl_context, connect = true)
59
79
  @uri, @ssl_context = URI(uri), ssl_context
80
+ reset_state!
81
+
82
+ if connect
83
+ # This ensures that calls to the public #connect method are ignored while already connecting.
84
+ @connecting = true
85
+ async.connect!
86
+ end
60
87
  end
61
88
 
62
89
  # @return [URI]
@@ -69,63 +96,188 @@ module Lowdown
69
96
  #
70
97
  attr_reader :ssl_context
71
98
 
72
- # Creates a new SSL connection to the service, a HTTP/2 client, and starts off a worker thread.
99
+ # Creates a new SSL connection to the service, a HTTP/2 client, and starts off the main runloop.
73
100
  #
74
101
  # @return [void]
75
102
  #
76
- def open
77
- raise "Connection already open." if @worker
78
- @requests = Threading::Counter.new
79
- @worker = Worker.new(@uri, @ssl_context)
103
+ def connect
104
+ connect! unless @connecting
80
105
  end
81
106
 
82
- # Flushes the connection, terminates the worker thread, and closes the socket. Finally it peforms one more check for
83
- # pending jobs dispatched onto the main thread.
107
+ # Closes the connection and resets the internal state
84
108
  #
85
109
  # @return [void]
86
110
  #
87
- def close
88
- return unless @worker
89
- flush
90
- @worker.stop
91
- @worker = @requests = nil
111
+ def disconnect
112
+ @connection.close if @connection
113
+ @heartbeat.cancel if @heartbeat
114
+ reset_state!
92
115
  end
93
116
 
94
- # This performs a HTTP/2 PING to determine if the connection is actually alive.
117
+ # @return [Boolean]
118
+ # whether or not the Connection is open.
95
119
  #
96
- # @param [Numeric] timeout
97
- # the maximum amount of time to wait for the service to reply to the PING.
120
+ def connected?
121
+ !@connection.nil? && !@connection.closed?
122
+ end
123
+
124
+ # This performs a HTTP/2 PING to determine if the connection is actually alive. Be sure to not call this on a
125
+ # sleeping connection, or it will be guaranteed to fail.
126
+ #
127
+ # @note This halts the caller thread until a reply is received. You should call this on a future and possibly set
128
+ # a timeout.
98
129
  #
99
130
  # @return [Boolean]
100
- # whether or not the Connection is open.
131
+ # whether or not a reply was received.
101
132
  #
102
- def open?(timeout = 5)
103
- return false unless @worker
104
- Timeout.timeout(timeout) do
105
- caller_thread = Thread.current
106
- @worker.enqueue do |http|
107
- http.ping('whatever') { caller_thread.run }
133
+ def ping
134
+ if connected?
135
+ condition = Celluloid::Condition.new
136
+ @http.ping("whatever") { condition.signal(true) }
137
+ condition.wait
138
+ else
139
+ false
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def connect!(tries = 0)
146
+ return if @connection
147
+ @connecting = true
148
+
149
+ info "Opening APNS connection."
150
+
151
+ # Celluloid::IO::DNSResolver bug. In case there is no connection at all:
152
+ # 1. This results in `nil`:
153
+ # https://github.com/celluloid/celluloid-io/blob/85cee9da22ef5e94ba0abfd46454a2d56572aff4/lib/celluloid/io/dns_resolver.rb#L32
154
+ # 2. This tries to `NilClass#send` the hostname:
155
+ # https://github.com/celluloid/celluloid-io/blob/85cee9da22ef5e94ba0abfd46454a2d56572aff4/lib/celluloid/io/dns_resolver.rb#L44
156
+ begin
157
+ socket = TCPSocket.new(@uri.host, @uri.port)
158
+ rescue NoMethodError
159
+ raise SocketError, "(Probably) getaddrinfo: nodename nor servname provided, or not known"
160
+ end
161
+
162
+ @connection = SSLSocket.new(socket, @ssl_context)
163
+ begin
164
+ timeout(CONNECT_TIMEOUT) { @connection.connect }
165
+ rescue Celluloid::TimedOut
166
+ raise TimedOut, "Initiating SSL socket timed-out."
167
+ end
168
+
169
+ @http = HTTP2::Client.new
170
+ @http.on(:frame) do |bytes|
171
+ @connection.print(bytes)
172
+ @connection.flush
173
+ end
174
+
175
+ async.runloop
176
+
177
+ rescue Celluloid::TaskTerminated, Celluloid::DeadActorError
178
+ # These are legit, let them bubble up.
179
+ raise
180
+ rescue Exception => e
181
+ # The main reason to do connect retries ourselves, instead of letting it up a supervisor/pool, is because a pool
182
+ # goes into a bad state if a connection crashes on initialization.
183
+ @connection.close if @connection && !@connection.closed?
184
+ @connection = @http = nil
185
+ if tries < CONNECT_RETRIES
186
+ tries += 1
187
+ delay = tries * CONNECT_RETRY_BACKOFF
188
+ error("#{e.class}: #{e.message} - retrying in #{delay} seconds (#{tries}/#{CONNECT_RETRIES})")
189
+ after(delay) { async.connect!(tries) }
190
+ return
191
+ else
192
+ raise
193
+ end
194
+ end
195
+
196
+ def reset_state!
197
+ @connecting = false
198
+ @connected = false
199
+ @request_queue = []
200
+ @connection = @http = @heartbeat = nil
201
+ end
202
+
203
+ # The main IO runloop that feeds data from the remote service into the HTTP/2 client.
204
+ #
205
+ # It should only ever exit gracefully if the connection has been closed with {#close} or the actor has been
206
+ # terminated. Otherwise this method may raise any connection or HTTP/2 parsing related exception, which will kill
207
+ # the actor and, if supervised, start a new connection.
208
+ #
209
+ # @return [void]
210
+ #
211
+ def runloop
212
+ loop do
213
+ begin
214
+ @http << @connection.readpartial(1024)
215
+ change_to_connected_state if !@connected && @http.state == :connected
216
+ rescue IOError => e
217
+ if @connection
218
+ raise
219
+ else
220
+ # Connection was closed by us and set to nil, so exit gracefully
221
+ break
222
+ end
108
223
  end
109
- Thread.stop
110
224
  end
111
- # If the thread was woken-up before the timeout was reached, that means we got a PONG.
112
- true
113
- rescue Timeout::Error
114
- false
115
225
  end
116
226
 
117
- # Halts the calling thread until all dispatched requests have been performed.
227
+ # Called when the HTTP client changes its state to `:connected`.
118
228
  #
119
229
  # @return [void]
120
230
  #
121
- def flush
122
- return unless @worker
123
- sleep 0.1 until !@worker.working? && @requests.zero?
231
+ def change_to_connected_state
232
+ @max_stream_count = @http.remote_settings[:settings_max_concurrent_streams]
233
+ @connected = true
234
+
235
+ debug "APNS connection established. Maximum number of concurrent streams: #{@max_stream_count}. " \
236
+ "Flushing #{@request_queue.size} enqueued requests."
237
+
238
+ @request_queue.size.times do
239
+ async.try_to_perform_request!
240
+ end
241
+
242
+ @heartbeat = every(HEARTBEAT_INTERVAL) do
243
+ debug "Sending heartbeat ping"
244
+ begin
245
+ future.ping.call(HEARTBEAT_TIMEOUT)
246
+ rescue Celluloid::TimedOut
247
+ raise TimedOut, "Heartbeat ping timed-out."
248
+ end
249
+ end
250
+ end
251
+
252
+ public
253
+
254
+ # This module describes the interface that your delegate object should conform to, but it is not required to include
255
+ # this module in your class, it mainly serves a documentation purpose.
256
+ #
257
+ module DelegateProtocol
258
+ # Called when a request is finished and a response is available.
259
+ #
260
+ # @note (see Connection#post)
261
+ #
262
+ # @param [Response] response
263
+ # the Response that holds the status data that came back from the service.
264
+ #
265
+ # @param [Object, nil] context
266
+ # the context passed in when making the request, which can be any type of object or an array of objects.
267
+ #
268
+ # @return [void]
269
+ #
270
+ def handle_apns_response(response, context:)
271
+ raise NotImplementedError
272
+ end
124
273
  end
125
274
 
126
275
  # Sends the provided data as a `POST` request to the service.
127
276
  #
128
- # @note The callback is performed on a different thread, dedicated to perfoming these callbacks.
277
+ # @note It is strongly advised that the delegate object is a Celluloid actor and that you pass in an async proxy
278
+ # of that object, but that is not required. If you do not pass in an actor, then be advised that the
279
+ # callback will run on this connection’s private thread and thus you should not perform long blocking
280
+ # operations.
129
281
  #
130
282
  # @param [String] path
131
283
  # the request path, which should be `/3/device/<device-token>`.
@@ -136,152 +288,75 @@ module Lowdown
136
288
  # @param [String] body
137
289
  # the (JSON) encoded payload data to send to the service.
138
290
  #
139
- # @yield [response]
140
- # called when the request is finished and a response is available.
291
+ # @param [DelegateProtocol] delegate
292
+ # an object that implements the delegate protocol.
141
293
  #
142
- # @yieldparam [Response] response
143
- # the Response that holds the status data that came back from the service.
294
+ # @param [Object, nil] context
295
+ # any object that you want to be passed to the delegate once the response is back.
144
296
  #
145
297
  # @return [void]
146
298
  #
147
- def post(path, headers, body, &callback)
148
- request('POST', path, headers, body, &callback)
299
+ def post(path:, headers:, body:, delegate:, context: nil)
300
+ request("POST", path, headers, body, delegate, context)
149
301
  end
150
302
 
151
303
  private
152
304
 
153
- def request(method, path, custom_headers, body, &callback)
154
- @requests.increment!
155
- @worker.enqueue do |http, callbacks|
156
- headers = { ":method" => method.to_s, ":path" => path.to_s, "content-length" => body.bytesize.to_s }
157
- custom_headers.each { |k, v| headers[k] = v.to_s }
158
-
159
- stream = http.new_stream
160
- response = Response.new
161
-
162
- stream.on(:headers) do |response_headers|
163
- response.headers = Hash[*response_headers.flatten]
164
- end
305
+ Request = Struct.new(:headers, :body, :delegate, :context)
165
306
 
166
- stream.on(:data) do |response_data|
167
- response.raw_body ||= ''
168
- response.raw_body << response_data
169
- end
307
+ def request(method, path, custom_headers, body, delegate, context)
308
+ headers = { ":method" => method.to_s, ":path" => path.to_s, "content-length" => body.bytesize.to_s }
309
+ custom_headers.each { |k, v| headers[k] = v.to_s }
170
310
 
171
- stream.on(:close) do
172
- callbacks.enqueue do
173
- callback.call(response)
174
- @requests.decrement!
175
- end
176
- end
311
+ request = Request.new(headers, body, delegate, context)
312
+ @request_queue << request
177
313
 
178
- stream.headers(headers, end_stream: false)
179
- stream.data(body, end_stream: true)
180
- end
314
+ try_to_perform_request!
181
315
  end
182
316
 
183
- # @!visibility private
184
- #
185
- # Creates a new worker thread which maintains all its own state:
186
- # * SSL connection
187
- # * HTTP2 client
188
- # * Another thread from where request callbacks are ran
189
- #
190
- class Worker < Threading::Consumer
191
- def initialize(uri, ssl_context)
192
- @uri, @ssl_context = uri, ssl_context
193
-
194
- # Start the worker thread.
195
- #
196
- # Because a max size of 0 is not allowed, create with an initial max size of 1 and add a dummy job. This is so
197
- # that any attempt to add a new job to the queue is going to halt the calling thread *until* we change the max.
198
- super(queue: Thread::SizedQueue.new(1))
199
- # Put caller thread into sleep until connected.
200
- Thread.stop
317
+ def try_to_perform_request!
318
+ unless @connected
319
+ debug "Defer performing request, because the connection has not been established yet"
320
+ return
201
321
  end
202
322
 
203
- # Tells the runloop to stop and halts the caller until finished.
204
- #
205
- # @return [void]
206
- #
207
- def stop
208
- thread[:should_exit] = true
209
- thread.join
210
- end
211
-
212
- # @return [Boolean]
213
- # whether or not the worker is still alive and kicking.
214
- #
215
- def working?
216
- alive? && !empty? && !@callbacks.empty?
323
+ unless @http.active_stream_count < @max_stream_count
324
+ debug "Defer performing request, because the maximum concurren stream count has been reached"
325
+ return
217
326
  end
218
327
 
219
- private
220
-
221
- def post_runloop
222
- @callbacks.kill
223
- @ssl.close
224
- super
328
+ unless request = @request_queue.shift
329
+ debug "Defer performing request, because the request queue is empty"
330
+ return
225
331
  end
226
332
 
227
- def pre_runloop
228
- super
229
-
230
- # Setup the request callbacks consumer here so its parent thread will be this worker thread.
231
- @callbacks = Threading::Consumer.new
333
+ apns_id = request.headers["apns-id"]
334
+ debug "[#{apns_id}] Performing request"
232
335
 
233
- @ssl = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(@uri.host, @uri.port), @ssl_context)
234
- @ssl.sync_close = true
235
- @ssl.hostname = @uri.hostname
236
- @ssl.connect
336
+ stream = @http.new_stream
337
+ response = Response.new
237
338
 
238
- @http = HTTP2::Client.new
239
- @http.on(:frame) do |bytes|
240
- # This is going to be performed on the worker thread and thus does *not* write to @ssl from another thread than
241
- # the thread it’s being read from.
242
- @ssl.print(bytes)
243
- @ssl.flush
244
- end
339
+ stream.on(:headers) do |headers|
340
+ headers = Hash[*headers.flatten]
341
+ debug "[#{apns_id}] Got response headers: #{headers.inspect}"
342
+ response.headers = headers
245
343
  end
246
344
 
247
- # Called when the HTTP client changes its state to `:connected` and lets the parent thread (which was stopped in
248
- # `#initialize`) continue.
249
- #
250
- # @return [void]
251
- #
252
- def change_to_connected_state
253
- queue.max = @http.remote_settings[:settings_max_concurrent_streams]
254
- @connected = true
255
- parent_thread.run
345
+ stream.on(:data) do |data|
346
+ debug "[#{apns_id}] Got response data: #{data}"
347
+ response.raw_body ||= ""
348
+ response.raw_body << data
256
349
  end
257
350
 
258
- # @note Only made into a method so it can be overriden from the tests, because our test setup doesn’t behave the
259
- # same as the real APNS service.
260
- #
261
- # @return [Boolean]
262
- # whether or not the HTTP client’s state is `:connected`.
263
- #
264
- def http_connected?
265
- @http.state == :connected
351
+ stream.on(:close) do
352
+ debug "[#{apns_id}] Request completed"
353
+ request.delegate.handle_apns_response(response, context: request.context)
354
+ async.try_to_perform_request!
266
355
  end
267
356
 
268
- # Start the main IO and HTTP processing loop.
269
- def runloop
270
- until thread[:should_exit] || @ssl.closed?
271
- # Once connected, add requests while the max stream count has not yet been reached.
272
- if !@connected
273
- change_to_connected_state if http_connected?
274
- elsif @http.active_stream_count < queue.max
275
- # Run dispatched jobs that add new requests.
276
- perform_job(non_block: true, arguments: [@http, @callbacks])
277
- end
278
- # Try to read data from the SSL socket without blocking and process it.
279
- begin
280
- @http << @ssl.read_nonblock(1024)
281
- rescue IO::WaitReadable
282
- end
283
- end
284
- end
357
+ stream.headers(request.headers, end_stream: false)
358
+ stream.data(request.body, end_stream: true)
285
359
  end
286
360
  end
287
361
  end
362
+