amqp-client 1.0.0 → 1.1.1

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,409 +3,490 @@
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
 
10
10
  module AMQP
11
- # Represents a single AMQP connection
12
- class Connection
13
- def self.connect(uri, read_loop_thread: true, **options)
14
- uri = URI.parse(uri)
15
- tls = uri.scheme == "amqps"
16
- port = port_from_env || uri.port || (tls ? 5671 : 5672)
17
- host = uri.host || "localhost"
18
- user = uri.user || "guest"
19
- password = uri.password || "guest"
20
- vhost = URI.decode_www_form_component(uri.path[1..-1] || "/")
21
- options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
22
-
23
- socket = Socket.tcp host, port, connect_timeout: 20, resolv_timeout: 5
24
- enable_tcp_keepalive(socket)
25
- if tls
26
- cert_store = OpenSSL::X509::Store.new
27
- cert_store.set_default_paths
28
- context = OpenSSL::SSL::SSLContext.new
29
- context.cert_store = cert_store
30
- context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless [false, "false", "none"].include? options[:verify_peer]
31
- socket = OpenSSL::SSL::SSLSocket.new(socket, context)
32
- socket.sync_close = true # closing the TLS socket also closes the TCP socket
33
- socket.hostname = host # SNI host
34
- socket.connect
35
- socket.post_connection_check(host) || raise(AMQP::Client::Error, "TLS certificate hostname doesn't match requested")
11
+ class Client
12
+ # Represents a single established AMQP connection
13
+ class Connection
14
+ # Establish a connection to an AMQP broker
15
+ # @param uri [String] URL on the format amqp://username:password@hostname/vhost, use amqps:// for encrypted connection
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
18
+ # @option options [Boolean] connection_name (PROGRAM_NAME) Set a name for the connection to be able to identify
19
+ # the client from the broker
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
22
+ # @option options [Integer] heartbeat (0) Heartbeat timeout, defaults to 0 and relies on TCP keepalive instead
23
+ # @option options [Integer] frame_max (131_072) Maximum frame size,
24
+ # the smallest of the client's and the broker's values will be used
25
+ # @option options [Integer] channel_max (2048) Maxium number of channels the client will be allowed to have open.
26
+ # Maxium allowed is 65_536. The smallest of the client's and the broker's value will be used.
27
+ # @return [Connection]
28
+ def initialize(uri = "", read_loop_thread: true, **options)
29
+ uri = URI.parse(uri)
30
+ tls = uri.scheme == "amqps"
31
+ port = port_from_env || uri.port || (tls ? 5671 : 5672)
32
+ host = uri.host || "localhost"
33
+ user = uri.user || "guest"
34
+ password = uri.password || "guest"
35
+ vhost = URI.decode_www_form_component(uri.path[1..] || "/")
36
+ options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
37
+
38
+ socket = open_socket(host, port, tls, options)
39
+ channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, options)
40
+
41
+ @socket = socket
42
+ @channel_max = channel_max.zero? ? 65_536 : channel_max
43
+ @frame_max = frame_max
44
+ @heartbeat = heartbeat
45
+ @channels = {}
46
+ @closed = nil
47
+ @replies = ::Queue.new
48
+ @write_lock = Mutex.new
49
+ @blocked = nil
50
+ Thread.new { read_loop } if read_loop_thread
36
51
  end
37
- channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, **options)
38
- Connection.new(socket, channel_max, frame_max, heartbeat, read_loop_thread: read_loop_thread)
39
- end
40
52
 
41
- def initialize(socket, channel_max, frame_max, heartbeat, read_loop_thread: true)
42
- @socket = socket
43
- @channel_max = channel_max.zero? ? 65_536 : channel_max
44
- @frame_max = frame_max
45
- @heartbeat = heartbeat
46
- @channels = {}
47
- @closed = false
48
- @replies = Queue.new
49
- @write_lock = Mutex.new
50
- Thread.new { read_loop } if read_loop_thread
51
- end
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
52
59
 
53
- attr_reader :frame_max
60
+ # The max frame size negotiated between the client and the broker
61
+ # @return [Integer]
62
+ attr_reader :frame_max
54
63
 
55
- def inspect
56
- "#<#{self.class} @closed=#{@closed} channel_count=#{@channels.size}>"
57
- end
64
+ # Custom inspect
65
+ # @return [String]
66
+ # @api private
67
+ def inspect
68
+ "#<#{self.class} @closed=#{@closed} channel_count=#{@channels.size}>"
69
+ end
58
70
 
59
- def channel(id = nil)
60
- raise ArgumentError, "Channel ID cannot be 0" if id&.zero?
61
- raise ArgumentError, "Channel ID higher than connection's channel max #{@channel_max}" if id && id > @channel_max
71
+ # Open an AMQP channel
72
+ # @param id [Integer, nil] If nil a new channel will be opened, otherwise an already open channel might be reused
73
+ # @return [Channel]
74
+ def channel(id = nil)
75
+ raise ArgumentError, "Channel ID cannot be 0" if id&.zero?
76
+ raise ArgumentError, "Channel ID higher than connection's channel max #{@channel_max}" if id && id > @channel_max
62
77
 
63
- if id
64
- ch = @channels[id] ||= Channel.new(self, id)
65
- else
66
- id = nil
67
- 1.upto(@channel_max) do |i|
68
- break id = i unless @channels.key? i
69
- end
70
- raise AMQP::Client::Error, "Max channels reached" if id.nil?
78
+ if id
79
+ ch = @channels[id] ||= Channel.new(self, id)
80
+ else
81
+ 1.upto(@channel_max) do |i|
82
+ break id = i unless @channels.key? i
83
+ end
84
+ raise Error, "Max channels reached" if id.nil?
71
85
 
72
- ch = @channels[id] = Channel.new(self, id)
86
+ ch = @channels[id] = Channel.new(self, id)
87
+ end
88
+ ch.open
73
89
  end
74
- ch.open
75
- end
76
90
 
77
- # Declare a new channel, yield, and then close the channel
78
- def with_channel
79
- ch = channel
80
- begin
81
- yield ch
82
- ensure
83
- ch.close
91
+ # Declare a new channel, yield, and then close the channel
92
+ # @yield [Channel]
93
+ # @return [Object] Whatever was returned by the block
94
+ def with_channel
95
+ ch = channel
96
+ begin
97
+ yield ch
98
+ ensure
99
+ ch.close
100
+ end
84
101
  end
85
- end
86
102
 
87
- def close(reason = "", code = 200)
88
- return if @closed
103
+ # Gracefully close a connection
104
+ # @param reason [String] A reason to close the connection can be logged by the broker
105
+ # @param code [Integer]
106
+ # @return [nil]
107
+ def close(reason: "", code: 200)
108
+ return if @closed
89
109
 
90
- @closed = true
91
- write_bytes FrameBytes.connection_close(code, reason)
92
- @channels.each_value { |ch| ch.closed!(code, reason, 0, 0) }
93
- expect(:close_ok)
94
- end
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
118
+ nil
119
+ end
95
120
 
96
- def closed?
97
- @closed
98
- end
121
+ # True if the connection is closed
122
+ # @return [Boolean]
123
+ def closed?
124
+ !@closed.nil?
125
+ end
99
126
 
100
- def write_bytes(*bytes)
101
- if @socket.is_a? OpenSSL::SSL::SSLSocket
127
+ # Write byte array(s) directly to the socket (thread-safe)
128
+ # @param bytes [String] One or more byte arrays
129
+ # @return [Integer] number of bytes written
130
+ # @api private
131
+ def write_bytes(*bytes)
132
+ blocked = @blocked
133
+ warn "AMQP-Client blocked by broker: #{blocked}" if blocked
102
134
  @write_lock.synchronize do
135
+ warn "AMQP-Client unblocked by broker" if blocked
103
136
  @socket.write(*bytes)
104
137
  end
105
- else
106
- @socket.write(*bytes)
138
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
139
+ raise Error::ConnectionClosed.new(*@closed) if @closed
140
+
141
+ raise Error, "Could not write to socket, #{e.message}"
107
142
  end
108
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
109
- raise AMQP::Client::Error, "Could not write to socket, #{e.message}"
110
- end
111
143
 
112
- # Reads from the socket, required for any kind of progress. Blocks until the connection is closed
113
- def read_loop
114
- # read more often than write so that channel errors crop up early
115
- Thread.current.priority += 1
116
- socket = @socket
117
- frame_max = @frame_max
118
- frame_start = String.new(capacity: 7)
119
- frame_buffer = String.new(capacity: frame_max)
120
- loop do
121
- socket.read(7, frame_start)
122
- type, channel_id, frame_size = frame_start.unpack("C S> L>")
123
- if frame_size > frame_max
124
- raise AMQP::Client::Error, "Frame size #{frame_size} is larger than negotiated max frame size #{frame_max}"
125
- end
144
+ # Reads from the socket, required for any kind of progress.
145
+ # Blocks until the connection is closed. Normally run as a background thread automatically.
146
+ # @return [nil]
147
+ def read_loop
148
+ # read more often than write so that channel errors crop up early
149
+ Thread.current.priority += 1
150
+ socket = @socket
151
+ frame_max = @frame_max
152
+ frame_start = String.new(capacity: 7)
153
+ frame_buffer = String.new(capacity: frame_max)
154
+ loop do
155
+ socket.read(7, frame_start)
156
+ type, channel_id, frame_size = frame_start.unpack("C S> L>")
157
+ frame_max >= frame_size || raise(Error, "Frame size #{frame_size} larger than negotiated max frame size #{frame_max}")
126
158
 
127
- # read the frame content
128
- socket.read(frame_size, frame_buffer)
159
+ # read the frame content
160
+ socket.read(frame_size, frame_buffer)
129
161
 
130
- # make sure that the frame end is correct
131
- frame_end = socket.readchar.ord
132
- raise AMQP::Client::UnexpectedFrameEnd, frame_end if frame_end != 206
162
+ # make sure that the frame end is correct
163
+ frame_end = socket.readchar.ord
164
+ raise UnexpectedFrameEnd, frame_end if frame_end != 206
133
165
 
134
- # parse the frame, will return false if a close frame was received
135
- parse_frame(type, channel_id, frame_size, frame_buffer) || return
136
- end
137
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
138
- warn "AMQP-Client read error: #{e.inspect}"
139
- nil # ignore read errors
140
- ensure
141
- @closed = true
142
- @replies.close
143
- begin
144
- @socket.close
145
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError
166
+ # parse the frame, will return false if a close frame was received
167
+ parse_frame(type, channel_id, frame_buffer) || return
168
+ end
146
169
  nil
147
- end
148
- end
149
-
150
- private
151
-
152
- def parse_frame(type, channel_id, frame_size, buf)
153
- case type
154
- when 1 # method frame
155
- class_id, method_id = buf.unpack("S> S>")
156
- case class_id
157
- when 10 # connection
158
- raise AMQP::Client::Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
159
-
160
- case method_id
161
- when 50 # connection#close
162
- @closed = true
163
- code, text_len = buf.unpack("@4 S> C")
164
- text = buf.byteslice(7, text_len).force_encoding("utf-8")
165
- error_class_id, error_method_id = buf.byteslice(7 + text_len, 4).unpack("S> S>")
166
- @channels.each_value { |ch| ch.closed!(code, text, error_class_id, error_method_id) }
167
- begin
168
- write_bytes FrameBytes.connection_close_ok
169
- rescue AMQP::Client::Error
170
- nil # rabbitmq closes the socket after sending Connection::Close, so ignore write errors
171
- end
172
- return false
173
- when 51 # connection#close-ok
174
- @closed = true
175
- @replies.push [:close_ok]
176
- return false
177
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
178
- end
179
- when 20 # channel
180
- case method_id
181
- when 11 # channel#open-ok
182
- @channels[channel_id].reply [:channel_open_ok]
183
- when 40 # channel#close
184
- reply_code, reply_text_len = buf.unpack("@4 S> C")
185
- reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
186
- classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
187
- channel = @channels.delete(channel_id)
188
- channel.closed!(reply_code, reply_text, classid, methodid)
189
- write_bytes FrameBytes.channel_close_ok(channel_id)
190
- when 41 # channel#close-ok
191
- channel = @channels.delete(channel_id)
192
- channel.reply [:channel_close_ok]
193
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
194
- end
195
- when 40 # exchange
196
- case method_id
197
- when 11 # declare-ok
198
- @channels[channel_id].reply [:exchange_declare_ok]
199
- when 21 # delete-ok
200
- @channels[channel_id].reply [:exchange_delete_ok]
201
- when 31 # bind-ok
202
- @channels[channel_id].reply [:exchange_bind_ok]
203
- when 51 # unbind-ok
204
- @channels[channel_id].reply [:exchange_unbind_ok]
205
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
206
- end
207
- when 50 # queue
208
- case method_id
209
- when 11 # declare-ok
210
- queue_name_len = buf.unpack1("@4 C")
211
- queue_name = buf.byteslice(5, queue_name_len).force_encoding("utf-8")
212
- message_count, consumer_count = buf.byteslice(5 + queue_name_len, 8).unpack("L> L>")
213
- @channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
214
- when 21 # bind-ok
215
- @channels[channel_id].reply [:queue_bind_ok]
216
- when 31 # purge-ok
217
- @channels[channel_id].reply [:queue_purge_ok]
218
- when 41 # delete-ok
219
- message_count = buf.unpack1("@4 L>")
220
- @channels[channel_id].reply [:queue_delete, message_count]
221
- when 51 # unbind-ok
222
- @channels[channel_id].reply [:queue_unbind_ok]
223
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
224
- end
225
- when 60 # basic
226
- case method_id
227
- when 11 # qos-ok
228
- @channels[channel_id].reply [:basic_qos_ok]
229
- when 21 # consume-ok
230
- tag_len = buf.unpack1("@4 C")
231
- tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
232
- @channels[channel_id].reply [:basic_consume_ok, tag]
233
- when 30 # cancel
234
- tag_len = buf.unpack1("@4 C")
235
- tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
236
- no_wait = buf[5 + tag_len].ord
237
- @channels[channel_id].close_consumer(tag)
238
- write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait == 1
239
- when 31 # cancel-ok
240
- tag_len = buf.unpack1("@4 C")
241
- tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
242
- @channels[channel_id].reply [:basic_cancel_ok, tag]
243
- when 50 # return
244
- reply_code, reply_text_len = buf.unpack("@4 S> C")
245
- pos = 7
246
- reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
247
- pos += reply_text_len
248
- exchange_len = buf[pos].ord
249
- pos += 1
250
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
251
- pos += exchange_len
252
- routing_key_len = buf[pos].ord
253
- pos += 1
254
- routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
255
- @channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
256
- when 60 # deliver
257
- ctag_len = buf[4].ord
258
- consumer_tag = buf.byteslice(5, ctag_len).force_encoding("utf-8")
259
- pos = 5 + ctag_len
260
- delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
261
- pos += 8 + 1 + 1
262
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
263
- pos += exchange_len
264
- rk_len = buf[pos].ord
265
- pos += 1
266
- routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
267
- @channels[channel_id].message_delivered(consumer_tag, delivery_tag, redelivered == 1, exchange, routing_key)
268
- when 71 # get-ok
269
- delivery_tag, redelivered, exchange_len = buf.unpack("@4 Q> C C")
270
- pos = 14
271
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
272
- pos += exchange_len
273
- routing_key_len = buf[pos].ord
274
- pos += 1
275
- routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
276
- pos += routing_key_len
277
- _message_count = buf.byteslice(pos, 4).unpack1("L>")
278
- @channels[channel_id].message_delivered(nil, delivery_tag, redelivered == 1, exchange, routing_key)
279
- when 72 # get-empty
280
- @channels[channel_id].basic_get_empty
281
- when 80 # ack
282
- delivery_tag, multiple = buf.unpack("@4 Q> C")
283
- @channels[channel_id].confirm [:ack, delivery_tag, multiple == 1]
284
- when 111 # recover-ok
285
- @channels[channel_id].reply [:basic_recover_ok]
286
- when 120 # nack
287
- delivery_tag, multiple, requeue = buf.unpack("@4 Q> C C")
288
- @channels[channel_id].confirm [:nack, delivery_tag, multiple == 1, requeue == 1]
289
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
290
- end
291
- when 85 # confirm
292
- case method_id
293
- when 11 # select-ok
294
- @channels[channel_id].reply [:confirm_select_ok]
295
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
296
- end
297
- when 90 # tx
298
- case method_id
299
- when 11 # select-ok
300
- @channels[channel_id].reply [:tx_select_ok]
301
- when 21 # commit-ok
302
- @channels[channel_id].reply [:tx_commit_ok]
303
- when 31 # rollback-ok
304
- @channels[channel_id].reply [:tx_rollback_ok]
305
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
170
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
171
+ @closed ||= [400, "read error: #{e.message}"]
172
+ nil # ignore read errors
173
+ ensure
174
+ @closed ||= [400, "unknown"]
175
+ @replies.close
176
+ begin
177
+ @write_lock.synchronize do
178
+ @socket.close
306
179
  end
307
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
180
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError
181
+ nil
308
182
  end
309
- when 2 # header
310
- body_size = buf.unpack1("@4 Q>")
311
- properties = Properties.decode(buf.byteslice(12, buf.bytesize - 12))
312
- @channels[channel_id].header_delivered body_size, properties
313
- when 3 # body
314
- @channels[channel_id].body_delivered buf
315
- else raise AMQP::Client::UnsupportedFrameType, type
316
183
  end
317
- true
318
- end
319
-
320
- def expect(expected_frame_type)
321
- frame_type, args = @replies.pop
322
- frame_type == expected_frame_type || raise(AMQP::Client::UnexpectedFrame.new(expected_frame_type, frame_type))
323
- args
324
- end
325
184
 
326
- def self.establish(socket, user, password, vhost, **options)
327
- channel_max, frame_max, heartbeat = nil
328
- socket.write "AMQP\x00\x00\x09\x01"
329
- buf = String.new(capacity: 4096)
330
- loop do
331
- begin
332
- socket.readpartial(4096, buf)
333
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
334
- raise AMQP::Client::Error, "Could not establish AMQP connection: #{e.message}"
335
- end
336
-
337
- type, channel_id, frame_size = buf.unpack("C S> L>")
338
- frame_end = buf[frame_size + 7].ord
339
- raise UnexpectedFrameEndError, frame_end if frame_end != 206
185
+ private
340
186
 
187
+ def parse_frame(type, channel_id, buf)
341
188
  case type
342
189
  when 1 # method frame
343
- class_id, method_id = buf.unpack("@7 S> S>")
190
+ class_id, method_id = buf.unpack("S> S>")
344
191
  case class_id
345
192
  when 10 # connection
346
- raise AMQP::Client::Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
193
+ raise Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
347
194
 
348
195
  case method_id
349
- when 10 # connection#start
350
- conn_name = options[:connection_name] || $PROGRAM_NAME
351
- properties = CLIENT_PROPERTIES.merge({ connection_name: conn_name })
352
- socket.write FrameBytes.connection_start_ok "\u0000#{user}\u0000#{password}", properties
353
- when 30 # connection#tune
354
- channel_max, frame_max, heartbeat = buf.unpack("@11 S> L> S>")
355
- channel_max = [channel_max, 2048].min
356
- frame_max = [frame_max, 131_072].min
357
- heartbeat = [heartbeat, 0].min
358
- socket.write FrameBytes.connection_tune_ok(channel_max, frame_max, heartbeat)
359
- socket.write FrameBytes.connection_open(vhost)
360
- when 41 # connection#open-ok
361
- return [channel_max, frame_max, heartbeat]
362
196
  when 50 # connection#close
363
- code, text_len = buf.unpack("@11 S> C")
364
- text, error_class_id, error_method_id = buf.unpack("@14 a#{text_len} S> S>")
365
- socket.write FrameBytes.connection_close_ok
366
- raise AMQP::Client::Error, "Could not establish AMQP connection: #{code} #{text} #{error_class_id} #{error_method_id}"
367
- else raise AMQP::Client::Error, "Unexpected class/method: #{class_id} #{method_id}"
197
+ code, text_len = buf.unpack("@4 S> C")
198
+ text = buf.byteslice(7, text_len).force_encoding("utf-8")
199
+ error_class_id, error_method_id = buf.byteslice(7 + text_len, 4).unpack("S> S>")
200
+ @closed = [code, text, error_class_id, error_method_id]
201
+ @channels.each_value { |ch| ch.closed!(:connection, code, text, error_class_id, error_method_id) }
202
+ begin
203
+ write_bytes FrameBytes.connection_close_ok
204
+ rescue Error
205
+ nil # rabbitmq closes the socket after sending Connection::Close, so ignore write errors
206
+ end
207
+ return false
208
+ when 51 # connection#close-ok
209
+ @replies.push [:close_ok]
210
+ return false
211
+ when 60 # connection#blocked
212
+ reason_len = buf.getbyte(4)
213
+ reason = buf.byteslice(5, reason_len).force_encoding("utf-8")
214
+ @blocked = reason
215
+ @write_lock.lock
216
+ when 61 # connection#unblocked
217
+ @blocked = nil
218
+ @write_lock.unlock
219
+ else raise Error::UnsupportedMethodFrame, class_id, method_id
220
+ end
221
+ when 20 # channel
222
+ case method_id
223
+ when 11 # channel#open-ok
224
+ @channels[channel_id].reply [:channel_open_ok]
225
+ when 40 # channel#close
226
+ reply_code, reply_text_len = buf.unpack("@4 S> C")
227
+ reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
228
+ classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
229
+ channel = @channels.delete(channel_id)
230
+ channel.closed!(:channel, reply_code, reply_text, classid, methodid)
231
+ write_bytes FrameBytes.channel_close_ok(channel_id)
232
+ when 41 # channel#close-ok
233
+ channel = @channels.delete(channel_id)
234
+ channel.reply [:channel_close_ok]
235
+ else raise Error::UnsupportedMethodFrame, class_id, method_id
236
+ end
237
+ when 40 # exchange
238
+ case method_id
239
+ when 11 # declare-ok
240
+ @channels[channel_id].reply [:exchange_declare_ok]
241
+ when 21 # delete-ok
242
+ @channels[channel_id].reply [:exchange_delete_ok]
243
+ when 31 # bind-ok
244
+ @channels[channel_id].reply [:exchange_bind_ok]
245
+ when 51 # unbind-ok
246
+ @channels[channel_id].reply [:exchange_unbind_ok]
247
+ else raise Error::UnsupportedMethodFrame, class_id, method_id
248
+ end
249
+ when 50 # queue
250
+ case method_id
251
+ when 11 # declare-ok
252
+ queue_name_len = buf.getbyte(4)
253
+ queue_name = buf.byteslice(5, queue_name_len).force_encoding("utf-8")
254
+ message_count, consumer_count = buf.byteslice(5 + queue_name_len, 8).unpack("L> L>")
255
+ @channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
256
+ when 21 # bind-ok
257
+ @channels[channel_id].reply [:queue_bind_ok]
258
+ when 31 # purge-ok
259
+ @channels[channel_id].reply [:queue_purge_ok]
260
+ when 41 # delete-ok
261
+ message_count = buf.unpack1("@4 L>")
262
+ @channels[channel_id].reply [:queue_delete, message_count]
263
+ when 51 # unbind-ok
264
+ @channels[channel_id].reply [:queue_unbind_ok]
265
+ else raise Error::UnsupportedMethodFrame.new class_id, method_id
368
266
  end
369
- else raise AMQP::Client::Error, "Unexpected class/method: #{class_id} #{method_id}"
267
+ when 60 # basic
268
+ case method_id
269
+ when 11 # qos-ok
270
+ @channels[channel_id].reply [:basic_qos_ok]
271
+ when 21 # consume-ok
272
+ tag_len = buf.getbyte(4)
273
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
274
+ @channels[channel_id].reply [:basic_consume_ok, tag]
275
+ when 30 # cancel
276
+ tag_len = buf.getbyte(4)
277
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
278
+ no_wait = buf.getbyte(5 + tag_len) == 1
279
+ @channels[channel_id].close_consumer(tag)
280
+ write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait
281
+ when 31 # cancel-ok
282
+ tag_len = buf.getbyte(4)
283
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
284
+ @channels[channel_id].reply [:basic_cancel_ok, tag]
285
+ when 50 # return
286
+ reply_code, reply_text_len = buf.unpack("@4 S> C")
287
+ pos = 7
288
+ reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
289
+ pos += reply_text_len
290
+ exchange_len = buf.getbyte(pos)
291
+ pos += 1
292
+ exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
293
+ pos += exchange_len
294
+ routing_key_len = buf.getbyte(pos)
295
+ pos += 1
296
+ routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
297
+ @channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
298
+ when 60 # deliver
299
+ ctag_len = buf.getbyte(4)
300
+ consumer_tag = buf.byteslice(5, ctag_len).force_encoding("utf-8")
301
+ pos = 5 + ctag_len
302
+ delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
303
+ pos += 8 + 1 + 1
304
+ exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
305
+ pos += exchange_len
306
+ rk_len = buf.getbyte(pos)
307
+ pos += 1
308
+ routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
309
+ @channels[channel_id].message_delivered(consumer_tag, delivery_tag, redelivered == 1, exchange, routing_key)
310
+ when 71 # get-ok
311
+ delivery_tag, redelivered, exchange_len = buf.unpack("@4 Q> C C")
312
+ pos = 14
313
+ exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
314
+ pos += exchange_len
315
+ routing_key_len = buf.getbyte(pos)
316
+ pos += 1
317
+ routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
318
+ # pos += routing_key_len
319
+ # message_count = buf.byteslice(pos, 4).unpack1("L>")
320
+ @channels[channel_id].message_delivered(nil, delivery_tag, redelivered == 1, exchange, routing_key)
321
+ when 72 # get-empty
322
+ @channels[channel_id].basic_get_empty
323
+ when 80 # ack
324
+ delivery_tag, multiple = buf.unpack("@4 Q> C")
325
+ @channels[channel_id].confirm [:ack, delivery_tag, multiple == 1]
326
+ when 111 # recover-ok
327
+ @channels[channel_id].reply [:basic_recover_ok]
328
+ when 120 # nack
329
+ delivery_tag, multiple, requeue = buf.unpack("@4 Q> C C")
330
+ @channels[channel_id].confirm [:nack, delivery_tag, multiple == 1, requeue == 1]
331
+ else raise Error::UnsupportedMethodFrame.new class_id, method_id
332
+ end
333
+ when 85 # confirm
334
+ case method_id
335
+ when 11 # select-ok
336
+ @channels[channel_id].reply [:confirm_select_ok]
337
+ else raise Error::UnsupportedMethodFrame.new class_id, method_id
338
+ end
339
+ when 90 # tx
340
+ case method_id
341
+ when 11 # select-ok
342
+ @channels[channel_id].reply [:tx_select_ok]
343
+ when 21 # commit-ok
344
+ @channels[channel_id].reply [:tx_commit_ok]
345
+ when 31 # rollback-ok
346
+ @channels[channel_id].reply [:tx_rollback_ok]
347
+ else raise Error::UnsupportedMethodFrame.new class_id, method_id
348
+ end
349
+ else raise Error::UnsupportedMethodFrame.new class_id, method_id
370
350
  end
371
- else raise AMQP::Client::Error, "Unexpected frame type: #{type}"
351
+ when 2 # header
352
+ body_size = buf.unpack1("@4 Q>")
353
+ properties = Properties.decode(buf, 12)
354
+ @channels[channel_id].header_delivered body_size, properties
355
+ when 3 # body
356
+ @channels[channel_id].body_delivered buf
357
+ else raise Error::UnsupportedFrameType, type
372
358
  end
359
+ true
373
360
  end
374
- rescue StandardError
375
- socket.close rescue nil
376
- raise
377
- end
378
361
 
379
- def self.enable_tcp_keepalive(socket)
380
- socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
381
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
382
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
383
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
384
- rescue StandardError => e
385
- warn "AMQP-Client could not enable TCP keepalive on socket. #{e.inspect}"
386
- end
362
+ def expect(expected_frame_type)
363
+ frame_type, args = @replies.pop
364
+ if frame_type.nil?
365
+ return if expected_frame_type == :close_ok
366
+
367
+ raise(Error::ConnectionClosed, "while waiting for #{expected_frame_type}")
368
+ end
369
+ frame_type == expected_frame_type || raise(Error::UnexpectedFrame.new(expected_frame_type, frame_type))
370
+ args
371
+ end
387
372
 
388
- def self.port_from_env
389
- return unless (port = ENV["AMQP_PORT"])
373
+ # Connect to the host/port, optionally establish a TLS connection
374
+ # @return [Socket]
375
+ # @return [OpenSSL::SSL::SSLSocket]
376
+ def open_socket(host, port, tls, options)
377
+ connect_timeout = options.fetch(:connect_timeout, 30).to_i
378
+ socket = Socket.tcp host, port, connect_timeout: connect_timeout
379
+ enable_tcp_keepalive(socket)
380
+ if tls
381
+ cert_store = OpenSSL::X509::Store.new
382
+ cert_store.set_default_paths
383
+ context = OpenSSL::SSL::SSLContext.new
384
+ context.cert_store = cert_store
385
+ verify_peer = [false, "false", "none"].include? options[:verify_peer]
386
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless verify_peer
387
+ socket = OpenSSL::SSL::SSLSocket.new(socket, context)
388
+ socket.sync_close = true # closing the TLS socket also closes the TCP socket
389
+ socket.hostname = host # SNI host
390
+ socket.connect
391
+ socket.post_connection_check(host) || raise(Error, "TLS certificate hostname doesn't match requested")
392
+ end
393
+ socket
394
+ end
390
395
 
391
- port.to_i
392
- end
396
+ # Negotiate a connection
397
+ # @return [Array<Integer, Integer, Integer>] channel_max, frame_max, heartbeat
398
+ def establish(socket, user, password, vhost, options)
399
+ channel_max, frame_max, heartbeat = nil
400
+ socket.write "AMQP\x00\x00\x09\x01"
401
+ buf = String.new(capacity: 4096)
402
+ loop do
403
+ begin
404
+ socket.readpartial(4096, buf)
405
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
406
+ raise Error, "Could not establish AMQP connection: #{e.message}"
407
+ end
408
+
409
+ type, channel_id, frame_size = buf.unpack("C S> L>")
410
+ frame_end = buf.getbyte(frame_size + 7)
411
+ raise UnexpectedFrameEndError, frame_end if frame_end != 206
412
+
413
+ case type
414
+ when 1 # method frame
415
+ class_id, method_id = buf.unpack("@7 S> S>")
416
+ case class_id
417
+ when 10 # connection
418
+ raise Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
419
+
420
+ case method_id
421
+ when 10 # connection#start
422
+ conn_name = options[:connection_name] || $PROGRAM_NAME
423
+ properties = CLIENT_PROPERTIES.merge({ connection_name: conn_name })
424
+ socket.write FrameBytes.connection_start_ok "\u0000#{user}\u0000#{password}", properties
425
+ when 30 # connection#tune
426
+ channel_max, frame_max, heartbeat = buf.unpack("@11 S> L> S>")
427
+ channel_max = 65_536 if channel_max.zero?
428
+ channel_max = [channel_max, options.fetch(:channel_max, 2048).to_i].min
429
+ frame_max = [frame_max, options.fetch(:frame_max, 131_072).to_i].min
430
+ heartbeat = [heartbeat, options.fetch(:heartbeat, 0).to_i].min
431
+ socket.write FrameBytes.connection_tune_ok(channel_max, frame_max, heartbeat)
432
+ socket.write FrameBytes.connection_open(vhost)
433
+ when 41 # connection#open-ok
434
+ return [channel_max, frame_max, heartbeat]
435
+ when 50 # connection#close
436
+ code, text_len = buf.unpack("@11 S> C")
437
+ text, error_class_id, error_method_id = buf.unpack("@14 a#{text_len} S> S>")
438
+ socket.write FrameBytes.connection_close_ok
439
+ raise Error, "Could not establish AMQP connection: #{code} #{text} #{error_class_id} #{error_method_id}"
440
+ else raise Error, "Unexpected class/method: #{class_id} #{method_id}"
441
+ end
442
+ else raise Error, "Unexpected class/method: #{class_id} #{method_id}"
443
+ end
444
+ else raise Error, "Unexpected frame type: #{type}"
445
+ end
446
+ end
447
+ rescue StandardError => e
448
+ begin
449
+ socket.close
450
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError
451
+ nil
452
+ end
453
+ raise e
454
+ end
455
+
456
+ # Enable TCP keepalive, which is prefered to heartbeats
457
+ # @return [void]
458
+ def enable_tcp_keepalive(socket)
459
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
460
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
461
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
462
+ socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
463
+ rescue StandardError => e
464
+ warn "AMQP-Client could not enable TCP keepalive on socket. #{e.inspect}"
465
+ end
466
+
467
+ # Fetch the AMQP port number from ENV
468
+ # @return [Integer] A port number
469
+ # @return [nil] When the environment variable AMQP_PORT isn't set
470
+ def port_from_env
471
+ return unless (port = ENV["AMQP_PORT"])
393
472
 
394
- private_class_method :establish, :enable_tcp_keepalive, :port_from_env
395
-
396
- CLIENT_PROPERTIES = {
397
- capabilities: {
398
- authentication_failure_close: true,
399
- publisher_confirms: true,
400
- consumer_cancel_notify: true,
401
- exchange_exchange_bindings: true,
402
- "basic.nack": true,
403
- "connection.blocked": true
404
- },
405
- product: "amqp-client.rb",
406
- platform: RUBY_DESCRIPTION,
407
- version: AMQP::Client::VERSION,
408
- information: "http://github.com/cloudamqp/amqp-client.rb"
409
- }.freeze
473
+ port.to_i
474
+ end
475
+
476
+ CLIENT_PROPERTIES = {
477
+ capabilities: {
478
+ authentication_failure_close: true,
479
+ publisher_confirms: true,
480
+ consumer_cancel_notify: true,
481
+ exchange_exchange_bindings: true,
482
+ "basic.nack": true,
483
+ "connection.blocked": true
484
+ },
485
+ product: "amqp-client.rb",
486
+ platform: RUBY_DESCRIPTION,
487
+ version: VERSION,
488
+ information: "http://github.com/cloudamqp/amqp-client.rb"
489
+ }.freeze
490
+ end
410
491
  end
411
492
  end