amqp-client 1.0.1 → 1.1.2

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.
@@ -3,7 +3,7 @@
3
3
  require "socket"
4
4
  require "uri"
5
5
  require "openssl"
6
- require_relative "./frames"
6
+ require_relative "./frame_bytes"
7
7
  require_relative "./channel"
8
8
  require_relative "./errors"
9
9
 
@@ -13,58 +13,50 @@ module AMQP
13
13
  class Connection
14
14
  # Establish a connection to an AMQP broker
15
15
  # @param uri [String] URL on the format amqp://username:password@hostname/vhost, use amqps:// for encrypted connection
16
- # @param read_loop_thread [Boolean] Set to false if you manually want to run the {#read_loop}
16
+ # @param read_loop_thread [Boolean] If true run {#read_loop} in a background thread,
17
+ # otherwise the user have to run it explicitly, without {#read_loop} the connection won't function
17
18
  # @option options [Boolean] connection_name (PROGRAM_NAME) Set a name for the connection to be able to identify
18
19
  # the client from the broker
19
20
  # @option options [Boolean] verify_peer (true) Verify broker's TLS certificate, set to false for self-signed certs
21
+ # @option options [Integer] connect_timeout (30) TCP connection timeout
20
22
  # @option options [Integer] heartbeat (0) Heartbeat timeout, defaults to 0 and relies on TCP keepalive instead
21
23
  # @option options [Integer] frame_max (131_072) Maximum frame size,
22
24
  # the smallest of the client's and the broker's values will be used
23
25
  # @option options [Integer] channel_max (2048) Maxium number of channels the client will be allowed to have open.
24
26
  # Maxium allowed is 65_536. The smallest of the client's and the broker's value will be used.
25
27
  # @return [Connection]
26
- def self.connect(uri, read_loop_thread: true, **options)
28
+ def initialize(uri = "", read_loop_thread: true, **options)
27
29
  uri = URI.parse(uri)
28
30
  tls = uri.scheme == "amqps"
29
31
  port = port_from_env || uri.port || (tls ? 5671 : 5672)
30
32
  host = uri.host || "localhost"
31
33
  user = uri.user || "guest"
32
34
  password = uri.password || "guest"
33
- vhost = URI.decode_www_form_component(uri.path[1..-1] || "/")
35
+ vhost = URI.decode_www_form_component(uri.path[1..] || "/")
34
36
  options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
35
37
 
36
- socket = Socket.tcp host, port, connect_timeout: 20, resolv_timeout: 5
37
- enable_tcp_keepalive(socket)
38
- if tls
39
- cert_store = OpenSSL::X509::Store.new
40
- cert_store.set_default_paths
41
- context = OpenSSL::SSL::SSLContext.new
42
- context.cert_store = cert_store
43
- context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless [false, "false", "none"].include? options[:verify_peer]
44
- socket = OpenSSL::SSL::SSLSocket.new(socket, context)
45
- socket.sync_close = true # closing the TLS socket also closes the TCP socket
46
- socket.hostname = host # SNI host
47
- socket.connect
48
- socket.post_connection_check(host) || raise(Error, "TLS certificate hostname doesn't match requested")
49
- end
38
+ socket = open_socket(host, port, tls, options)
50
39
  channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, options)
51
- Connection.new(socket, channel_max, frame_max, heartbeat, read_loop_thread: read_loop_thread)
52
- end
53
40
 
54
- # Requires an already established TCP/TLS socket
55
- # @api private
56
- def initialize(socket, channel_max, frame_max, heartbeat, read_loop_thread: true)
57
41
  @socket = socket
58
42
  @channel_max = channel_max.zero? ? 65_536 : channel_max
59
43
  @frame_max = frame_max
60
44
  @heartbeat = heartbeat
61
45
  @channels = {}
62
- @closed = false
46
+ @closed = nil
63
47
  @replies = ::Queue.new
64
48
  @write_lock = Mutex.new
49
+ @blocked = nil
65
50
  Thread.new { read_loop } if read_loop_thread
66
51
  end
67
52
 
53
+ # Alias for {#initialize}
54
+ # @see #initialize
55
+ # @deprecated
56
+ def self.connect(uri, read_loop_thread: true, **options)
57
+ new(uri, read_loop_thread: read_loop_thread, **options)
58
+ end
59
+
68
60
  # The max frame size negotiated between the client and the broker
69
61
  # @return [Integer]
70
62
  attr_reader :frame_max
@@ -115,17 +107,21 @@ module AMQP
115
107
  def close(reason: "", code: 200)
116
108
  return if @closed
117
109
 
118
- @closed = true
119
- write_bytes FrameBytes.connection_close(code, reason)
120
- @channels.each_value { |ch| ch.closed!(code, reason, 0, 0) }
121
- expect(:close_ok)
110
+ @closed = [code, reason]
111
+ @channels.each_value { |ch| ch.closed!(:connection, code, reason, 0, 0) }
112
+ if @blocked
113
+ @socket.close
114
+ else
115
+ write_bytes FrameBytes.connection_close(code, reason)
116
+ expect(:close_ok)
117
+ end
122
118
  nil
123
119
  end
124
120
 
125
121
  # True if the connection is closed
126
122
  # @return [Boolean]
127
123
  def closed?
128
- @closed
124
+ !@closed.nil?
129
125
  end
130
126
 
131
127
  # Write byte array(s) directly to the socket (thread-safe)
@@ -133,10 +129,19 @@ module AMQP
133
129
  # @return [Integer] number of bytes written
134
130
  # @api private
135
131
  def write_bytes(*bytes)
132
+ blocked = @blocked
133
+ warn "AMQP-Client blocked by broker: #{blocked}" if blocked
136
134
  @write_lock.synchronize do
137
- @socket.write(*bytes)
135
+ warn "AMQP-Client unblocked by broker" if blocked
136
+ if RUBY_ENGINE == "truffleruby"
137
+ bytes.each { |b| @socket.write b }
138
+ else
139
+ @socket.write(*bytes)
140
+ end
138
141
  end
139
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
142
+ rescue *READ_EXCEPTIONS => e
143
+ raise Error::ConnectionClosed.new(*@closed) if @closed
144
+
140
145
  raise Error, "Could not write to socket, #{e.message}"
141
146
  end
142
147
 
@@ -151,12 +156,12 @@ module AMQP
151
156
  frame_start = String.new(capacity: 7)
152
157
  frame_buffer = String.new(capacity: frame_max)
153
158
  loop do
154
- socket.read(7, frame_start)
159
+ socket.read(7, frame_start) || raise(IOError)
155
160
  type, channel_id, frame_size = frame_start.unpack("C S> L>")
156
161
  frame_max >= frame_size || raise(Error, "Frame size #{frame_size} larger than negotiated max frame size #{frame_max}")
157
162
 
158
163
  # read the frame content
159
- socket.read(frame_size, frame_buffer)
164
+ socket.read(frame_size, frame_buffer) || raise(IOError)
160
165
 
161
166
  # make sure that the frame end is correct
162
167
  frame_end = socket.readchar.ord
@@ -166,21 +171,26 @@ module AMQP
166
171
  parse_frame(type, channel_id, frame_buffer) || return
167
172
  end
168
173
  nil
169
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
170
- warn "AMQP-Client read error: #{e.inspect}"
174
+ rescue *READ_EXCEPTIONS => e
175
+ @closed ||= [400, "read error: #{e.message}"]
171
176
  nil # ignore read errors
172
177
  ensure
173
- @closed = true
178
+ @closed ||= [400, "unknown"]
174
179
  @replies.close
175
180
  begin
176
- @socket.close
177
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError
181
+ @write_lock.synchronize do
182
+ @socket.close
183
+ end
184
+ rescue *READ_EXCEPTIONS
178
185
  nil
179
186
  end
180
187
  end
181
188
 
182
189
  private
183
190
 
191
+ READ_EXCEPTIONS = [IOError, OpenSSL::OpenSSLError, SystemCallError,
192
+ RUBY_ENGINE == "jruby" ? java.lang.NullPointerException : nil].compact.freeze
193
+
184
194
  def parse_frame(type, channel_id, buf)
185
195
  case type
186
196
  when 1 # method frame
@@ -191,11 +201,11 @@ module AMQP
191
201
 
192
202
  case method_id
193
203
  when 50 # connection#close
194
- @closed = true
195
204
  code, text_len = buf.unpack("@4 S> C")
196
205
  text = buf.byteslice(7, text_len).force_encoding("utf-8")
197
206
  error_class_id, error_method_id = buf.byteslice(7 + text_len, 4).unpack("S> S>")
198
- @channels.each_value { |ch| ch.closed!(code, text, error_class_id, error_method_id) }
207
+ @closed = [code, text, error_class_id, error_method_id]
208
+ @channels.each_value { |ch| ch.closed!(:connection, code, text, error_class_id, error_method_id) }
199
209
  begin
200
210
  write_bytes FrameBytes.connection_close_ok
201
211
  rescue Error
@@ -203,9 +213,16 @@ module AMQP
203
213
  end
204
214
  return false
205
215
  when 51 # connection#close-ok
206
- @closed = true
207
216
  @replies.push [:close_ok]
208
217
  return false
218
+ when 60 # connection#blocked
219
+ reason_len = buf.getbyte(4)
220
+ reason = buf.byteslice(5, reason_len).force_encoding("utf-8")
221
+ @blocked = reason
222
+ @write_lock.lock
223
+ when 61 # connection#unblocked
224
+ @blocked = nil
225
+ @write_lock.unlock
209
226
  else raise Error::UnsupportedMethodFrame, class_id, method_id
210
227
  end
211
228
  when 20 # channel
@@ -217,7 +234,7 @@ module AMQP
217
234
  reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
218
235
  classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
219
236
  channel = @channels.delete(channel_id)
220
- channel.closed!(reply_code, reply_text, classid, methodid)
237
+ channel.closed!(:channel, reply_code, reply_text, classid, methodid)
221
238
  write_bytes FrameBytes.channel_close_ok(channel_id)
222
239
  when 41 # channel#close-ok
223
240
  channel = @channels.delete(channel_id)
@@ -239,7 +256,7 @@ module AMQP
239
256
  when 50 # queue
240
257
  case method_id
241
258
  when 11 # declare-ok
242
- queue_name_len = buf.unpack1("@4 C")
259
+ queue_name_len = buf.getbyte(4)
243
260
  queue_name = buf.byteslice(5, queue_name_len).force_encoding("utf-8")
244
261
  message_count, consumer_count = buf.byteslice(5 + queue_name_len, 8).unpack("L> L>")
245
262
  @channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
@@ -259,17 +276,17 @@ module AMQP
259
276
  when 11 # qos-ok
260
277
  @channels[channel_id].reply [:basic_qos_ok]
261
278
  when 21 # consume-ok
262
- tag_len = buf.unpack1("@4 C")
279
+ tag_len = buf.getbyte(4)
263
280
  tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
264
281
  @channels[channel_id].reply [:basic_consume_ok, tag]
265
282
  when 30 # cancel
266
- tag_len = buf.unpack1("@4 C")
283
+ tag_len = buf.getbyte(4)
267
284
  tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
268
- no_wait = buf[5 + tag_len].ord == 1
285
+ no_wait = buf.getbyte(5 + tag_len) == 1
269
286
  @channels[channel_id].close_consumer(tag)
270
287
  write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait
271
288
  when 31 # cancel-ok
272
- tag_len = buf.unpack1("@4 C")
289
+ tag_len = buf.getbyte(4)
273
290
  tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
274
291
  @channels[channel_id].reply [:basic_cancel_ok, tag]
275
292
  when 50 # return
@@ -277,23 +294,23 @@ module AMQP
277
294
  pos = 7
278
295
  reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
279
296
  pos += reply_text_len
280
- exchange_len = buf[pos].ord
297
+ exchange_len = buf.getbyte(pos)
281
298
  pos += 1
282
299
  exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
283
300
  pos += exchange_len
284
- routing_key_len = buf[pos].ord
301
+ routing_key_len = buf.getbyte(pos)
285
302
  pos += 1
286
303
  routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
287
304
  @channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
288
305
  when 60 # deliver
289
- ctag_len = buf[4].ord
306
+ ctag_len = buf.getbyte(4)
290
307
  consumer_tag = buf.byteslice(5, ctag_len).force_encoding("utf-8")
291
308
  pos = 5 + ctag_len
292
309
  delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
293
310
  pos += 8 + 1 + 1
294
311
  exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
295
312
  pos += exchange_len
296
- rk_len = buf[pos].ord
313
+ rk_len = buf.getbyte(pos)
297
314
  pos += 1
298
315
  routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
299
316
  @channels[channel_id].message_delivered(consumer_tag, delivery_tag, redelivered == 1, exchange, routing_key)
@@ -302,11 +319,11 @@ module AMQP
302
319
  pos = 14
303
320
  exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
304
321
  pos += exchange_len
305
- routing_key_len = buf[pos].ord
322
+ routing_key_len = buf.getbyte(pos)
306
323
  pos += 1
307
324
  routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
308
- pos += routing_key_len
309
- _message_count = buf.byteslice(pos, 4).unpack1("L>")
325
+ # pos += routing_key_len
326
+ # message_count = buf.byteslice(pos, 4).unpack1("L>")
310
327
  @channels[channel_id].message_delivered(nil, delivery_tag, redelivered == 1, exchange, routing_key)
311
328
  when 72 # get-empty
312
329
  @channels[channel_id].basic_get_empty
@@ -340,7 +357,7 @@ module AMQP
340
357
  end
341
358
  when 2 # header
342
359
  body_size = buf.unpack1("@4 Q>")
343
- properties = Properties.decode(buf.byteslice(12, buf.bytesize - 12))
360
+ properties = Properties.decode(buf, 12)
344
361
  @channels[channel_id].header_delivered body_size, properties
345
362
  when 3 # body
346
363
  @channels[channel_id].body_delivered buf
@@ -360,21 +377,44 @@ module AMQP
360
377
  args
361
378
  end
362
379
 
380
+ # Connect to the host/port, optionally establish a TLS connection
381
+ # @return [Socket]
382
+ # @return [OpenSSL::SSL::SSLSocket]
383
+ def open_socket(host, port, tls, options)
384
+ connect_timeout = options.fetch(:connect_timeout, 30).to_i
385
+ socket = Socket.tcp host, port, connect_timeout: connect_timeout
386
+ enable_tcp_keepalive(socket)
387
+ if tls
388
+ cert_store = OpenSSL::X509::Store.new
389
+ cert_store.set_default_paths
390
+ context = OpenSSL::SSL::SSLContext.new
391
+ context.cert_store = cert_store
392
+ verify_peer = [false, "false", "none"].include? options[:verify_peer]
393
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless verify_peer
394
+ socket = OpenSSL::SSL::SSLSocket.new(socket, context)
395
+ socket.sync_close = true # closing the TLS socket also closes the TCP socket
396
+ socket.hostname = host # SNI host
397
+ socket.connect
398
+ socket.post_connection_check(host) || raise(Error, "TLS certificate hostname doesn't match requested")
399
+ end
400
+ socket
401
+ end
402
+
363
403
  # Negotiate a connection
364
404
  # @return [Array<Integer, Integer, Integer>] channel_max, frame_max, heartbeat
365
- def self.establish(socket, user, password, vhost, options)
405
+ def establish(socket, user, password, vhost, options)
366
406
  channel_max, frame_max, heartbeat = nil
367
407
  socket.write "AMQP\x00\x00\x09\x01"
368
408
  buf = String.new(capacity: 4096)
369
409
  loop do
370
410
  begin
371
411
  socket.readpartial(4096, buf)
372
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
412
+ rescue *READ_EXCEPTIONS => e
373
413
  raise Error, "Could not establish AMQP connection: #{e.message}"
374
414
  end
375
415
 
376
416
  type, channel_id, frame_size = buf.unpack("C S> L>")
377
- frame_end = buf[frame_size + 7].ord
417
+ frame_end = buf.getbyte(frame_size + 7)
378
418
  raise UnexpectedFrameEndError, frame_end if frame_end != 206
379
419
 
380
420
  case type
@@ -411,16 +451,18 @@ module AMQP
411
451
  else raise Error, "Unexpected frame type: #{type}"
412
452
  end
413
453
  end
414
- rescue StandardError => e
454
+ rescue Exception => e
415
455
  begin
416
456
  socket.close
417
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError
457
+ rescue *READ_EXCEPTIONS
418
458
  nil
419
459
  end
420
460
  raise e
421
461
  end
422
462
 
423
- def self.enable_tcp_keepalive(socket)
463
+ # Enable TCP keepalive, which is prefered to heartbeats
464
+ # @return [void]
465
+ def enable_tcp_keepalive(socket)
424
466
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
425
467
  socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
426
468
  socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
@@ -429,14 +471,15 @@ module AMQP
429
471
  warn "AMQP-Client could not enable TCP keepalive on socket. #{e.inspect}"
430
472
  end
431
473
 
432
- def self.port_from_env
474
+ # Fetch the AMQP port number from ENV
475
+ # @return [Integer] A port number
476
+ # @return [nil] When the environment variable AMQP_PORT isn't set
477
+ def port_from_env
433
478
  return unless (port = ENV["AMQP_PORT"])
434
479
 
435
480
  port.to_i
436
481
  end
437
482
 
438
- private_class_method :establish, :enable_tcp_keepalive, :port_from_env
439
-
440
483
  CLIENT_PROPERTIES = {
441
484
  capabilities: {
442
485
  authentication_failure_close: true,
@@ -32,6 +32,19 @@ module AMQP
32
32
  end
33
33
  end
34
34
 
35
+ # Depending on close level a ConnectionClosed or ChannelClosed error is returned
36
+ class Closed < Error
37
+ def self.new(id, level, code, reason, classid = 0, methodid = 0)
38
+ case level
39
+ when :connection
40
+ ConnectionClosed.new(code, reason, classid, methodid)
41
+ when :channel
42
+ ChannelClosed.new(id, code, reason, classid, methodid)
43
+ else raise ArgumentError, "invalid level '#{level}'"
44
+ end
45
+ end
46
+ end
47
+
35
48
  # Raised if channel is already closed
36
49
  class ChannelClosed < Error
37
50
  def initialize(id, code, reason, classid = 0, methodid = 0)
@@ -40,7 +53,11 @@ module AMQP
40
53
  end
41
54
 
42
55
  # Raised if connection is unexpectedly closed
43
- class ConnectionClosed < Error; end
56
+ class ConnectionClosed < Error
57
+ def initialize(code, reason, classid = 0, methodid = 0)
58
+ super "Connection closed (#{code}) #{reason} (#{classid}/#{methodid})"
59
+ end
60
+ end
44
61
  end
45
62
  end
46
63
  end