amqp-client 0.3.0 → 1.1.0

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