lowdown 0.0.5 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5ce849fce8cd7a90dc303f9f761d88b497c7d80a
4
- data.tar.gz: 4c041b547386c1f591ac0a30326666f91f926b09
3
+ metadata.gz: 3bae6b12ffe8101e10ab41c87a7e58f191d1c9e6
4
+ data.tar.gz: c34a58e8561c4c65c2292b207800b63237fcd038
5
5
  SHA512:
6
- metadata.gz: abab9a4f67830d08411466d48c2ac48bcf6a9d5d4c91239096dcac04e418047ef2965ae7800311aa1e0a75554fc2e02f3b92269301fb0838e9c7d06c91b743e0
7
- data.tar.gz: 5c8715d560ab305e3eca365f94f5f2458183dc3972b10608a1f39e7457b6aa9bf2f121b0ee8dd6b2ee2c408c52682470bfcc31a333817f2fcd0a5a9c8e9f89b5
6
+ metadata.gz: 13de1cb6266a8a507228b3ded7443c098fd7582d4df3c52c6dfaaba71c6cd1ec7b7db31d365a441187d3ecf88bbbe9485379d68377ae0b1563fc17373f857c4a
7
+ data.tar.gz: 32b3ae325cf9029554044f7bc63e5dd2a84b00a529b861c430084eb3d0904e2f30fc8d6ffacaeb2547457d972619aefbefbb7299124072ae175b329223c95bb0
@@ -1,6 +1,6 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.1.0
3
+ - 2.1.1
4
4
  - 2.1
5
5
  - 2.2
6
6
  - 2.3.0
data/README.md CHANGED
@@ -8,6 +8,13 @@ Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification S
8
8
 
9
9
  Multiple notifications are multiplexed for efficiency.
10
10
 
11
+ If you need to cotinuously send notifications, it’s a good idea to keep an open connection. Managing that, in for
12
+ instance a daemon, is beyond the scope of this library. We might release an extra daemon/server tool in the future that
13
+ provides this functionality, but for now you should simply use the Client provided in this library without the block
14
+ form (which automatically closes the connection) and build your own daemon/server setup, as required.
15
+
16
+ Also checkout [this library](https://github.com/alloy/time_zone_scheduler) for scheduling across time zones.
17
+
11
18
  NOTE: _It is not yet battle-tested. This will all follow over the next few weeks._
12
19
 
13
20
  ## Installation
@@ -3,7 +3,10 @@ require "lowdown/version"
3
3
 
4
4
  # Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.
5
5
  #
6
- # Multiple notifications are multiplexed for efficiency.
6
+ # Multiple notifications are multiplexed and responses are yielded onto a different thread for efficiency.
7
+ #
8
+ # Note that it is thus _your_ responsibility to take the threading issue into account. E.g. if you are planning to
9
+ # update a DB model with the status of a notification delivery, be sure to respect the treading rules of your DB client.
7
10
  #
8
11
  # The main classes you will interact with are {Lowdown::Client} and {Lowdown::Notification}. For testing purposes there
9
12
  # are some helpers available in {Lowdown::Mock}.
@@ -155,6 +155,8 @@ module Lowdown
155
155
  #
156
156
  # @see Connection#post
157
157
  #
158
+ # @note (see Connection#post)
159
+ #
158
160
  # @param [Notification] notification
159
161
  # the notification object whose data to send to the service.
160
162
  #
@@ -2,17 +2,20 @@ require "lowdown/threading"
2
2
  require "lowdown/response"
3
3
 
4
4
  require "http/2"
5
+
5
6
  require "openssl"
6
- require "uri"
7
7
  require "socket"
8
+ require "timeout"
9
+ require "uri"
8
10
 
9
11
  if HTTP2::VERSION == "0.8.0"
10
12
  # @!visibility private
11
13
  #
12
- # Monkey-patch http-2 gem until this PR is merged: https://github.com/igrigorik/http-2/pull/44
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
+ #
13
18
  class HTTP2::Client
14
- # This monkey-patch ensures that we send the HTTP/2 connection preface before anything else.
15
- #
16
19
  def connection_management(frame)
17
20
  if @state == :waiting_connection_preface
18
21
  send_connection_preface
@@ -22,6 +25,22 @@ if HTTP2::VERSION == "0.8.0"
22
25
  end
23
26
  end
24
27
  end
28
+
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
39
+
40
+ def prepend(x)
41
+ super(x.force_encoding(Encoding::BINARY))
42
+ end
43
+ end
25
44
  end
26
45
 
27
46
  module Lowdown
@@ -55,33 +74,9 @@ module Lowdown
55
74
  # @return [void]
56
75
  #
57
76
  def open
58
- @socket = TCPSocket.new(@uri.host, @uri.port)
59
-
60
- @ssl = OpenSSL::SSL::SSLSocket.new(@socket, @ssl_context)
61
- @ssl.sync_close = true
62
- @ssl.hostname = @uri.hostname
63
- @ssl.connect
64
-
65
- @http = HTTP2::Client.new
66
- @http.on(:frame) do |bytes|
67
- @ssl.print(bytes)
68
- @ssl.flush
69
- end
70
-
71
- @main_queue = Threading::DispatchQueue.new
72
- @work_queue = Threading::DispatchQueue.new
73
- @requests = Threading::Counter.new
74
- @exceptions = Queue.new
75
- @worker_thread = start_worker_thread!
76
- end
77
-
78
- # @return [Boolean]
79
- # whether or not the Connection is open.
80
- #
81
- # @todo Possibly add a HTTP/2 `PING` in the future.
82
- #
83
- def open?
84
- !@ssl.nil? && !@ssl.closed?
77
+ raise "Connection already open." if @worker
78
+ @requests = Threading::Counter.new
79
+ @worker = Worker.new(@uri, @ssl_context)
85
80
  end
86
81
 
87
82
  # Flushes the connection, terminates the worker thread, and closes the socket. Finally it peforms one more check for
@@ -90,17 +85,33 @@ module Lowdown
90
85
  # @return [void]
91
86
  #
92
87
  def close
88
+ return unless @worker
93
89
  flush
90
+ @worker.stop
91
+ @worker = @requests = nil
92
+ end
94
93
 
95
- @worker_thread[:should_exit] = true
96
- @worker_thread.join
97
-
98
- @ssl.close
99
-
100
- sleep 0.1
101
- @main_queue.drain!
102
-
103
- @socket = @ssl = @http = @main_queue = @work_queue = @requests = @exceptions = @worker_thread = nil
94
+ # This performs a HTTP/2 PING to determine if the connection is actually alive.
95
+ #
96
+ # @param [Numeric] timeout
97
+ # the maximum amount of time to wait for the service to reply to the PING.
98
+ #
99
+ # @return [Boolean]
100
+ # whether or not the Connection is open.
101
+ #
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('12345678') { caller_thread.run }
108
+ end
109
+ Thread.stop
110
+ 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
104
115
  end
105
116
 
106
117
  # Halts the calling thread until all dispatched requests have been performed.
@@ -108,14 +119,14 @@ module Lowdown
108
119
  # @return [void]
109
120
  #
110
121
  def flush
111
- until @work_queue.empty? && @requests.zero?
112
- @main_queue.drain!
113
- sleep 0.1
114
- end
122
+ return unless @worker
123
+ sleep 0.1 until !@worker.alive? || @worker.empty? && @requests.zero?
115
124
  end
116
125
 
117
126
  # Sends the provided data as a `POST` request to the service.
118
127
  #
128
+ # @note The callback is performed on a different thread, dedicated to perfoming these callbacks.
129
+ #
119
130
  # @param [String] path
120
131
  # the request path, which should be `/3/device/<device-token>`.
121
132
  #
@@ -141,11 +152,11 @@ module Lowdown
141
152
 
142
153
  def request(method, path, custom_headers, body, &callback)
143
154
  @requests.increment!
144
- @work_queue.dispatch do
155
+ @worker.enqueue do |http, callbacks|
145
156
  headers = { ":method" => method.to_s, ":path" => path.to_s, "content-length" => body.bytesize.to_s }
146
157
  custom_headers.each { |k, v| headers[k] = v.to_s }
147
158
 
148
- stream = @http.new_stream
159
+ stream = http.new_stream
149
160
  response = Response.new
150
161
 
151
162
  stream.on(:headers) do |response_headers|
@@ -158,7 +169,7 @@ module Lowdown
158
169
  end
159
170
 
160
171
  stream.on(:close) do
161
- @main_queue.dispatch do
172
+ callbacks << lambda do
162
173
  callback.call(response)
163
174
  @requests.decrement!
164
175
  end
@@ -167,54 +178,131 @@ module Lowdown
167
178
  stream.headers(headers, end_stream: false)
168
179
  stream.data(body, end_stream: true)
169
180
  end
170
-
171
- # The caller might be posting many notifications, so use this time to also dispatch work onto the main thread.
172
- @main_queue.drain!
173
181
  end
174
182
 
175
- def start_worker_thread!
176
- Thread.new do
177
- until Thread.current[:should_exit] || @ssl.closed?
178
- # Run any dispatched jobs that add new requests.
179
- #
180
- # Re-raising a worker exception aids the development process. In production there’s no reason why this should
181
- # raise at all.
182
- if exception = @work_queue.drain!
183
- exception_occurred_in_worker(exception)
184
- end
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 < Thread
191
+ def initialize(uri, ssl_context)
192
+ @uri, @ssl_context = uri, ssl_context
185
193
 
186
- # Try to read data from the SSL socket without blocking. If it would block, catch the exception and restart
187
- # the loop.
188
- begin
189
- data = @ssl.read_nonblock(1024)
190
- rescue IO::WaitReadable
191
- data = nil
192
- rescue EOFError => exception
193
- exception_occurred_in_worker(exception)
194
- Thread.current[:should_exit] = true
195
- data = nil
196
- end
194
+ # 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
195
+ # that any attempt to add a new job to the queue is going to halt the calling thread *until* we change the max.
196
+ @queue = SizedQueue.new(1)
197
+ @queue << lambda { |*_| }
198
+
199
+ # Setup the consumer that performs the callbacks passed to Connection#request
200
+ @callback_queue = Queue.new
201
+ @callback_thread = Thread.new(@callback_queue) { |q| loop { q.pop.call } }
202
+
203
+ # Store the caller thread to be able to resume it once connected and to send exceptions to.
204
+ @caller_thread = Thread.current
205
+ # Start the worker thread.
206
+ super(&method(:main))
207
+ # Put caller thread into sleep until connected.
208
+ Thread.stop
209
+ end
210
+
211
+ # @yield [http, callbacks_queue]
212
+ #
213
+ # @yieldparam [HTTP2::Client] http
214
+ # the HTTP2 client instance.
215
+ #
216
+ # @yieldparam [Queue] callbacks_queue
217
+ # the queue on which request callbacks should be performed.
218
+ #
219
+ # @return [void]
220
+ #
221
+ def enqueue(&job)
222
+ @queue << job
223
+ end
224
+
225
+ # @return [Boolean]
226
+ # whether or not the work queue is empty.
227
+ #
228
+ def empty?
229
+ @queue.empty?
230
+ end
231
+
232
+ # Tells the runloop to stop and halts the caller until finished.
233
+ #
234
+ # @return [void]
235
+ #
236
+ def stop
237
+ self[:should_exit] = true
238
+ join
239
+ end
240
+
241
+ private
197
242
 
198
- # Process incoming HTTP data. If any processing exception occurs, fail the whole process.
199
- if data
243
+ def main
244
+ connect
245
+ runloop
246
+ rescue Exception => exception
247
+ # Send any unexpected exceptions back to the thread that started the loop.
248
+ @caller_thread.raise(exception)
249
+ ensure
250
+ cleanup
251
+ end
252
+
253
+ def cleanup
254
+ @callback_thread.kill
255
+ @ssl.close
256
+ end
257
+
258
+ def connect
259
+ @ssl = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(@uri.host, @uri.port), @ssl_context)
260
+ @ssl.sync_close = true
261
+ @ssl.hostname = @uri.hostname
262
+ @ssl.connect
263
+
264
+ @http = HTTP2::Client.new
265
+ @http.on(:frame) do |bytes|
266
+ # This is going to be performed on the worker thread and thus does *not* write to @ssl from another thread than
267
+ # the thread it’s being read from.
268
+ @ssl.print(bytes)
269
+ @ssl.flush
270
+ end
271
+ end
272
+
273
+ def change_to_connected_state
274
+ @queue.max = @http.remote_settings[:settings_max_concurrent_streams]
275
+ @connected = true
276
+ @caller_thread.run
277
+ end
278
+
279
+ # @note Only made into a method so it can be overriden from the tests, because our test setup doesn’t behave the
280
+ # same as the real APNS service.
281
+ #
282
+ def http_connected?
283
+ @http.state == :connected
284
+ end
285
+
286
+ # Start the main IO and HTTP processing loop.
287
+ def runloop
288
+ until self[:should_exit] || @ssl.closed?
289
+ # Once connected, add requests while the max stream count has not yet been reached.
290
+ if !@connected
291
+ change_to_connected_state if http_connected?
292
+ elsif @http.active_stream_count < @queue.max
200
293
  begin
201
- @http << data
202
- rescue Exception => exception
203
- @ssl.close
204
- exception_occurred_in_worker(exception)
294
+ # Run dispatched jobs that add new requests.
295
+ @queue.pop(true).call(@http, @callback_queue)
296
+ rescue ThreadError
205
297
  end
206
298
  end
299
+ # Try to read data from the SSL socket without blocking and process it.
300
+ begin
301
+ @http << @ssl.read_nonblock(1024)
302
+ rescue IO::WaitReadable
303
+ end
207
304
  end
208
305
  end
209
306
  end
210
-
211
- # Raise the exception on the main thread and reset the number of in-flight requests so that a potential blocked
212
- # caller of `Connection#flush` will continue.
213
- #
214
- def exception_occurred_in_worker(exception)
215
- @exceptions << exception
216
- @main_queue.dispatch { raise @exceptions.pop }
217
- @requests.value = @http.active_stream_count
218
- end
219
307
  end
220
308
  end
@@ -4,49 +4,6 @@ module Lowdown
4
4
  # A collection of internal threading related helpers.
5
5
  #
6
6
  module Threading
7
- # A queue of blocks that are to be dispatched onto another thread.
8
- #
9
- class DispatchQueue
10
- def initialize
11
- @queue = Queue.new
12
- end
13
-
14
- # Adds a block to the queue.
15
- #
16
- # @return [void]
17
- #
18
- def dispatch(&block)
19
- @queue << block
20
- end
21
-
22
- # @return [Boolean]
23
- # whether or not the queue is empty.
24
- #
25
- def empty?
26
- @queue.empty?
27
- end
28
-
29
- # Performs the number of dispatched blocks that were on the queue at the moment of calling `#drain!`. Unlike
30
- # performing blocks _until the queue is empty_, this ensures that it doesn’t block the calling thread too long if
31
- # another thread is dispatching more work at the same time.
32
- #
33
- # By default this will let any exceptions bubble up on the main thread or catch and return them on other threads.
34
- #
35
- # @param [Boolean] rescue_exceptions
36
- # whether or not to rescue exceptions.
37
- #
38
- # @return [Exception, nil]
39
- # in case of rescueing exceptions, this returns the exception raised during execution of a block.
40
- #
41
- def drain!(rescue_exceptions = (Thread.current != Thread.main))
42
- @queue.size.times { @queue.pop.call }
43
- nil
44
- rescue Exception => exception
45
- raise unless rescue_exceptions
46
- exception
47
- end
48
- end
49
-
50
7
  # A simple thread-safe counter.
51
8
  #
52
9
  class Counter
@@ -1,5 +1,3 @@
1
1
  module Lowdown
2
- # The currect version of the Lowdown library.
3
- #
4
- VERSION = "0.0.5"
2
+ VERSION = "0.1.0"
5
3
  end
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
 
21
21
  # This is currently set to >= 2.0.0 in the http-2 gemspec, which is incorrect, as it uses required keyword arguments.
22
- spec.required_ruby_version = '>= 2.1.0'
22
+ spec.required_ruby_version = '>= 2.1.1'
23
23
 
24
24
  spec.add_runtime_dependency "http-2", ">= 0.8"
25
25
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lowdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eloy Durán
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-01-08 00:00:00.000000000 Z
11
+ date: 2016-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: http-2
@@ -106,7 +106,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
- version: 2.1.0
109
+ version: 2.1.1
110
110
  required_rubygems_version: !ruby/object:Gem::Requirement
111
111
  requirements:
112
112
  - - ">="
@@ -114,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
114
  version: '0'
115
115
  requirements: []
116
116
  rubyforge_project:
117
- rubygems_version: 2.4.5.1
117
+ rubygems_version: 2.2.2
118
118
  signing_key:
119
119
  specification_version: 4
120
120
  summary: A Ruby client for the HTTP/2 version of the Apple Push Notification Service.