amqp-client 1.0.1 → 1.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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