lowdown 0.0.5 → 0.1.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.
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.