amqp-client 0.3.0 → 1.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.
@@ -8,389 +8,485 @@ 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, **options)
14
- read_loop_thread = options[:read_loop_thread] || true
15
-
16
- uri = URI.parse(uri)
17
- tls = uri.scheme == "amqps"
18
- port = port_from_env || uri.port || (tls ? 5671 : 5672)
19
- host = uri.host || "localhost"
20
- user = uri.user || "guest"
21
- password = uri.password || "guest"
22
- vhost = URI.decode_www_form_component(uri.path[1..-1] || "/")
23
- options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
24
-
25
- socket = Socket.tcp host, port, connect_timeout: 20, resolv_timeout: 5
26
- enable_tcp_keepalive(socket)
27
- if tls
28
- cert_store = OpenSSL::X509::Store.new
29
- cert_store.set_default_paths
30
- context = OpenSSL::SSL::SSLContext.new
31
- context.cert_store = cert_store
32
- context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless [false, "false", "none"].include? options[:verify_peer]
33
- socket = OpenSSL::SSL::SSLSocket.new(socket, context)
34
- socket.sync_close = true # closing the TLS socket also closes the TCP socket
35
- socket.hostname = host # SNI host
36
- socket.connect
37
- end
38
- channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, **options)
39
- Connection.new(socket, channel_max, frame_max, heartbeat, read_loop_thread: read_loop_thread)
40
- end
41
-
42
- def initialize(socket, channel_max, frame_max, heartbeat, read_loop_thread: true)
43
- @socket = socket
44
- @channel_max = channel_max
45
- @frame_max = frame_max
46
- @heartbeat = heartbeat
47
- @channels = {}
48
- @closed = false
49
- @replies = Queue.new
50
- Thread.new { read_loop } if read_loop_thread
51
- end
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)
52
37
 
53
- attr_reader :frame_max
38
+ socket = open_socket(host, port, tls, options)
39
+ channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, options)
54
40
 
55
- def channel(id = nil)
56
- if id
57
- ch = @channels[id] ||= Channel.new(self, id)
58
- else
59
- id = 1.upto(@channel_max) { |i| break i unless @channels.key? i }
60
- ch = @channels[id] = Channel.new(self, id)
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
61
51
  end
62
- ch.open
63
- end
64
52
 
65
- # Declare a new channel, yield, and then close the channel
66
- def with_channel
67
- ch = channel
68
- begin
69
- yield ch
70
- ensure
71
- ch.close
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)
72
58
  end
73
- end
74
59
 
75
- def close(reason = "", code = 200)
76
- return if @closed
60
+ # The max frame size negotiated between the client and the broker
61
+ # @return [Integer]
62
+ attr_reader :frame_max
77
63
 
78
- write_bytes FrameBytes.connection_close(code, reason)
79
- expect(:close_ok)
80
- @closed = true
81
- end
64
+ # Custom inspect
65
+ # @return [String]
66
+ # @api private
67
+ def inspect
68
+ "#<#{self.class} @closed=#{@closed} channel_count=#{@channels.size}>"
69
+ end
82
70
 
83
- def closed?
84
- @closed
85
- end
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
86
77
 
87
- def write_bytes(*bytes)
88
- @socket.write(*bytes)
89
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
90
- raise AMQP::Client::Error.new("Could not write to socket", cause: e)
91
- end
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?
92
85
 
93
- # Reads from the socket, required for any kind of progress. Blocks until the connection is closed
94
- def read_loop
95
- socket = @socket
96
- frame_max = @frame_max
97
- buffer = String.new(capacity: frame_max)
98
- loop do
99
- begin
100
- socket.readpartial(frame_max, buffer)
101
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError
102
- break
86
+ ch = @channels[id] = Channel.new(self, id)
103
87
  end
88
+ ch.open
89
+ end
104
90
 
105
- pos = 0
106
- while pos < buffer.bytesize
107
- buffer += socket.read(pos + 8 - buffer.bytesize) if pos + 8 > buffer.bytesize
108
- type, channel_id, frame_size = buffer.byteslice(pos, 7).unpack("C S> L>")
109
- if frame_size > frame_max
110
- raise AMQP::Client::Error, "Frame size #{frame_size} larger than negotiated max frame size #{frame_max}"
111
- end
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
101
+ end
112
102
 
113
- frame_end_pos = pos + 7 + frame_size
114
- buffer += socket.read(frame_end_pos - buffer.bytesize + 1) if frame_end_pos + 1 > buffer.bytesize
115
- frame_end = buffer[frame_end_pos].ord
116
- raise AMQP::Client::UnexpectedFrameEnd, frame_end if frame_end != 206
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
117
109
 
118
- buf = buffer.byteslice(pos, frame_size + 8)
119
- pos += frame_size + 8
120
- parse_frame(type, channel_id, frame_size, buf) || return
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)
121
117
  end
122
- end
123
- ensure
124
- @closed = true
125
- @replies.close
126
- begin
127
- @socket.close
128
- rescue IOError
129
118
  nil
130
119
  end
131
- end
132
120
 
133
- private
134
-
135
- def parse_frame(type, channel_id, frame_size, buf)
136
- case type
137
- when 1 # method frame
138
- class_id, method_id = buf.unpack("@7 S> S>")
139
- case class_id
140
- when 10 # connection
141
- raise AMQP::Client::Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
142
-
143
- case method_id
144
- when 50 # connection#close
145
- code, text_len = buf.unpack("@11 S> C")
146
- text = buf.byteslice(14, text_len).force_encoding("utf-8")
147
- error_class_id, error_method_id = buf.byteslice(14 + text_len, 4).unpack("S> S>")
148
- warn "Connection closed #{code} #{text} #{error_class_id} #{error_method_id}"
149
- write_bytes FrameBytes.connection_close_ok
150
- return false
151
- when 51 # connection#close-ok
152
- @replies.push [:close_ok]
153
- return false
154
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
155
- end
156
- when 20 # channel
157
- case method_id
158
- when 11 # channel#open-ok
159
- @channels[channel_id].reply [:channel_open_ok]
160
- when 40 # channel#close
161
- reply_code, reply_text_len = buf.unpack("@11 S> C")
162
- reply_text = buf.byteslice(14, reply_text_len).force_encoding("utf-8")
163
- classid, methodid = buf.byteslice(14 + reply_text_len, 4).unpack("S> S>")
164
- channel = @channels.delete(channel_id)
165
- channel.closed!(reply_code, reply_text, classid, methodid)
166
- when 41 # channel#close-ok
167
- channel = @channels.delete(channel_id)
168
- channel.reply [:channel_close_ok]
169
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
170
- end
171
- when 40 # exchange
172
- case method_id
173
- when 11 # declare-ok
174
- @channels[channel_id].reply [:exchange_declare_ok]
175
- when 21 # delete-ok
176
- @channels[channel_id].reply [:exchange_delete_ok]
177
- when 31 # bind-ok
178
- @channels[channel_id].reply [:exchange_bind_ok]
179
- when 51 # unbind-ok
180
- @channels[channel_id].reply [:exchange_unbind_ok]
181
- else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
182
- end
183
- when 50 # queue
184
- case method_id
185
- when 11 # declare-ok
186
- queue_name_len = buf.unpack1("@11 C")
187
- queue_name = buf.byteslice(12, queue_name_len).force_encoding("utf-8")
188
- message_count, consumer_count = buf.byteslice(12 + queue_name_len, 8).unpack1("L> L>")
189
- @channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
190
- when 21 # bind-ok
191
- @channels[channel_id].reply [:queue_bind_ok]
192
- when 31 # purge-ok
193
- @channels[channel_id].reply [:queue_purge_ok]
194
- when 41 # delete-ok
195
- message_count = buf.unpack1("@11 L>")
196
- @channels[channel_id].reply [:queue_delete, message_count]
197
- when 51 # unbind-ok
198
- @channels[channel_id].reply [:queue_unbind_ok]
199
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
200
- end
201
- when 60 # basic
202
- case method_id
203
- when 11 # qos-ok
204
- @channels[channel_id].reply [:basic_qos_ok]
205
- when 21 # consume-ok
206
- tag_len = buf.unpack1("@11 C")
207
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
208
- @channels[channel_id].reply [:basic_consume_ok, tag]
209
- when 30 # cancel
210
- tag_len = buf.unpack1("@11 C")
211
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
212
- no_wait = buf[12 + tag_len].ord
213
- @channels[channel_id].consumers.fetch(tag).close
214
- write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait == 1
215
- when 31 # cancel-ok
216
- tag_len = buf.unpack1("@11 C")
217
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
218
- @channels[channel_id].reply [:basic_cancel_ok, tag]
219
- when 50 # return
220
- reply_code, reply_text_len = buf.unpack("@11 S> C")
221
- pos = 14
222
- reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
223
- pos += reply_text_len
224
- exchange_len = buf[pos].ord
225
- pos += 1
226
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
227
- pos += exchange_len
228
- routing_key_len = buf[pos].ord
229
- pos += 1
230
- routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
231
- @channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
232
- when 60 # deliver
233
- ctag_len = buf[11].ord
234
- consumer_tag = buf.byteslice(12, ctag_len).force_encoding("utf-8")
235
- pos = 12 + ctag_len
236
- delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
237
- pos += 8 + 1 + 1
238
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
239
- pos += exchange_len
240
- rk_len = buf[pos].ord
241
- pos += 1
242
- routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
243
- loop do
244
- if (consumer = @channels[channel_id].consumers[consumer_tag])
245
- consumer.push [:deliver, delivery_tag, redelivered == 1, exchange, routing_key]
246
- break
247
- else
248
- Thread.pass
249
- end
250
- end
251
- when 71 # get-ok
252
- delivery_tag, redelivered, exchange_len = buf.unpack("@11 Q> C C")
253
- pos = 21
254
- exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
255
- pos += exchange_len
256
- routing_key_len = buf[pos].ord
257
- pos += 1
258
- routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
259
- pos += routing_key_len
260
- message_count = buf.byteslice(pos, 4).unpack1("L>")
261
- redelivered = redelivered == 1
262
- @channels[channel_id].reply [:basic_get_ok, delivery_tag, exchange, routing_key, message_count, redelivered]
263
- when 72 # get-empty
264
- @channels[channel_id].reply [:basic_get_empty]
265
- when 80 # ack
266
- delivery_tag, multiple = buf.unpack("@11 Q> C")
267
- @channels[channel_id].confirm [:ack, delivery_tag, multiple == 1]
268
- when 111 # recover-ok
269
- @channels[channel_id].reply [:basic_recover_ok]
270
- when 120 # nack
271
- delivery_tag, multiple, requeue = buf.unpack("@11 Q> C C")
272
- @channels[channel_id].confirm [:nack, delivery_tag, multiple == 1, requeue == 1]
273
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
274
- end
275
- when 85 # confirm
276
- case method_id
277
- when 11 # select-ok
278
- @channels[channel_id].reply [:confirm_select_ok]
279
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
280
- end
281
- when 90 # tx
282
- case method_id
283
- when 11 # select-ok
284
- @channels[channel_id].reply [:tx_select_ok]
285
- when 21 # commit-ok
286
- @channels[channel_id].reply [:tx_commit_ok]
287
- when 31 # rollback-ok
288
- @channels[channel_id].reply [:tx_rollback_ok]
289
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
290
- end
291
- else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
121
+ # True if the connection is closed
122
+ # @return [Boolean]
123
+ def closed?
124
+ !@closed.nil?
125
+ end
126
+
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
134
+ @write_lock.synchronize do
135
+ warn "AMQP-Client unblocked by broker" if blocked
136
+ @socket.write(*bytes)
292
137
  end
293
- when 2 # header
294
- body_size = buf.unpack1("@11 Q>")
295
- properties = Properties.decode(buf.byteslice(19, buf.bytesize - 20))
296
- @channels[channel_id].reply [:header, body_size, properties]
297
- when 3 # body
298
- body = buf.byteslice(7, frame_size)
299
- @channels[channel_id].reply [:body, body]
300
- else raise AMQP::Client::UnsupportedFrameType, type
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}"
301
142
  end
302
- true
303
- end
304
143
 
305
- def expect(expected_frame_type)
306
- frame_type, args = @replies.shift
307
- frame_type == expected_frame_type || raise(UnexpectedFrame.new(expected_frame_type, frame_type))
308
- args
309
- 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}")
158
+
159
+ # read the frame content
160
+ socket.read(frame_size, frame_buffer)
310
161
 
311
- def self.establish(socket, user, password, vhost, **options)
312
- channel_max, frame_max, heartbeat = nil
313
- socket.write "AMQP\x00\x00\x09\x01"
314
- buf = String.new(capacity: 4096)
315
- loop do
162
+ # make sure that the frame end is correct
163
+ frame_end = socket.readchar.ord
164
+ raise UnexpectedFrameEnd, frame_end if frame_end != 206
165
+
166
+ # parse the frame, will return false if a close frame was received
167
+ parse_frame(type, channel_id, frame_buffer) || return
168
+ end
169
+ nil
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
316
176
  begin
317
- socket.readpartial(4096, buf)
318
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
319
- raise AMQP::Client::Error, "Could not establish AMQP connection: #{e.message}"
177
+ @write_lock.synchronize do
178
+ @socket.close
179
+ end
180
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError
181
+ nil
320
182
  end
183
+ end
321
184
 
322
- type, channel_id, frame_size = buf.unpack("C S> L>")
323
- frame_end = buf.unpack1("@#{frame_size + 7} C")
324
- raise UnexpectedFrameEndError, frame_end if frame_end != 206
185
+ private
325
186
 
187
+ def parse_frame(type, channel_id, buf)
326
188
  case type
327
189
  when 1 # method frame
328
- class_id, method_id = buf.unpack("@7 S> S>")
190
+ class_id, method_id = buf.unpack("S> S>")
329
191
  case class_id
330
192
  when 10 # connection
331
- 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
332
194
 
333
195
  case method_id
334
- when 10 # connection#start
335
- conn_name = options[:connection_name] || $PROGRAM_NAME
336
- properties = CLIENT_PROPERTIES.merge({ connection_name: conn_name })
337
- socket.write FrameBytes.connection_start_ok "\u0000#{user}\u0000#{password}", properties
338
- when 30 # connection#tune
339
- channel_max, frame_max, heartbeat = buf.unpack("@11 S> L> S>")
340
- channel_max = [channel_max, 2048].min
341
- frame_max = [frame_max, 131_072].min
342
- heartbeat = [heartbeat, 0].min
343
- socket.write FrameBytes.connection_tune_ok(channel_max, frame_max, heartbeat)
344
- socket.write FrameBytes.connection_open(vhost)
345
- when 41 # connection#open-ok
346
- return [channel_max, frame_max, heartbeat]
347
196
  when 50 # connection#close
348
- code, text_len = buf.unpack("@11 S> C")
349
- text, error_class_id, error_method_id = buf.unpack("@14 a#{text_len} S> S>")
350
- socket.write FrameBytes.connection_close_ok
351
- raise AMQP::Client::Error, "Could not establish AMQP connection: #{code} #{text} #{error_class_id} #{error_method_id}"
352
- 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
266
+ end
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
353
332
  end
354
- else raise AMQP::Client::Error, "Unexpected class/method: #{class_id} #{method_id}"
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
355
350
  end
356
- 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.byteslice(12, buf.bytesize - 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
357
358
  end
359
+ true
358
360
  end
359
- rescue StandardError
360
- socket.close rescue nil
361
- raise
362
- end
363
361
 
364
- def self.enable_tcp_keepalive(socket)
365
- socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
366
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
367
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
368
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
369
- rescue StandardError => e
370
- warn "amqp-client: Could not enable TCP keepalive on socket. #{e.inspect}"
371
- 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
372
366
 
373
- def self.port_from_env
374
- return unless (port = ENV["AMQP_PORT"])
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
375
372
 
376
- port.to_i
377
- end
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
395
+
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"])
378
472
 
379
- private_class_method :establish, :enable_tcp_keepalive, :port_from_env
380
-
381
- CLIENT_PROPERTIES = {
382
- capabilities: {
383
- authentication_failure_close: true,
384
- publisher_confirms: true,
385
- consumer_cancel_notify: true,
386
- exchange_exchange_bindings: true,
387
- "basic.nack": true,
388
- "connection.blocked": true
389
- },
390
- product: "amqp-client.rb",
391
- platform: RUBY_DESCRIPTION,
392
- version: AMQP::Client::VERSION,
393
- information: "http://github.com/cloudamqp/amqp-client.rb"
394
- }.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
395
491
  end
396
492
  end