amqp-client 1.1.6 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10119835607b70ee73dd581f9bef3211a26fc18bb6fb7fa2f6e6bfe913076b16
4
- data.tar.gz: a30989dab30c2dea294c0310bbfd211a36fa7453d935200c6b7783553f5e816c
3
+ metadata.gz: f0abeedd581cdc09eac6afaf690fb8a50fbaef0146e4c22be43d7cad90d8d9b6
4
+ data.tar.gz: 16161d756ce7d68ba24debc60eda941243bb951cd17262f1c148ef1039595e88
5
5
  SHA512:
6
- metadata.gz: 2658a0f4e151ab9fd0a56d1096e8d6b191b5e5164e27eadb8dc922651664440149fd1eef7b9d410fbcdf781eba3ef046b141c017b3b5d317b241ec7afa2d8150
7
- data.tar.gz: 73695493c9f72619279a5552bdea275f6aa781da3da1dadebb788eaff249f68e088b3eb9fc5cc7c3afab1d6df0b44bd83e18ce2aa3605ace83a00035dfd84890
6
+ metadata.gz: 272c099f3158572304b30bbf67227343ce5456b90e5b4a08ccaf9c2da0f9b3a42ffb02913431b7d98cc0b7f1d5d36ef2c61bd492cdabf4411b0d2f941c616f04
7
+ data.tar.gz: a2000478882d0e630d88fb67e09675b087981498ac9a5b7f7a80f69a092c082415530156136cca1fa2e1bf88874468437fd3dbc600ee354110fb0c6af59e1373
@@ -25,7 +25,7 @@ jobs:
25
25
 
26
26
  steps:
27
27
  - name: Checkout repository
28
- uses: actions/checkout@v4
28
+ uses: actions/checkout@v5
29
29
 
30
30
  # Initializes the CodeQL tools for scanning.
31
31
  - name: Initialize CodeQL
@@ -12,7 +12,7 @@ jobs:
12
12
  docs:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v5
16
16
  - name: Setup Ruby
17
17
  uses: ruby/setup-ruby@v1
18
18
  with:
@@ -19,21 +19,22 @@ jobs:
19
19
  matrix:
20
20
  sudo: [true]
21
21
  ruby:
22
- - "2.6"
23
- - "2.7"
24
- - "3.0"
25
- - "3.1"
26
22
  - "3.2"
27
23
  - "3.3"
24
+ - "3.4"
28
25
  include:
29
- - { ruby: jruby, allow-failure: true, sudo: false }
30
- - { ruby: truffleruby, allow-failure: true, sudo: false }
26
+ - { ruby: jruby, allow-failure: false, sudo: false }
27
+ - { ruby: truffleruby, allow-failure: false, sudo: false }
31
28
  steps:
29
+ - name: Configure dpkg to skip building man pages
30
+ run: |
31
+ echo 'path-exclude /usr/share/doc/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc
32
+ echo 'path-exclude /usr/share/man/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc
32
33
  - name: Install RabbitMQ
33
34
  run: sudo apt-get update && sudo apt-get install -y rabbitmq-server
34
35
  - name: Verify RabbitMQ started correctly
35
36
  run: while true; do sudo rabbitmq-diagnostics status 2>/dev/null && break; echo -n .; sleep 2; done
36
- - uses: actions/checkout@v4
37
+ - uses: actions/checkout@v5
37
38
  - uses: ruby/setup-ruby@v1
38
39
  with:
39
40
  bundler-cache: true
@@ -55,6 +56,10 @@ jobs:
55
56
  - "jruby"
56
57
  - "truffleruby"
57
58
  steps:
59
+ - name: Configure dpkg to skip building man pages
60
+ run: |
61
+ echo 'path-exclude /usr/share/doc/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc
62
+ echo 'path-exclude /usr/share/man/*' | sudo tee -a /etc/dpkg/dpkg.cfg.d/01_nodoc
58
63
  - name: Install RabbitMQ
59
64
  run: sudo apt-get update && sudo apt-get install -y rabbitmq-server
60
65
  - name: Stop RabbitMQ
@@ -81,12 +86,20 @@ jobs:
81
86
  run: sudo systemctl start rabbitmq-server
82
87
  - name: Verify RabbitMQ started correctly
83
88
  run: while true; do sudo rabbitmq-diagnostics status 2>/dev/null && break; echo -n .; sleep 2; done
84
- - uses: actions/checkout@v4
89
+ - uses: actions/checkout@v5
85
90
  - uses: ruby/setup-ruby@v1
86
91
  with:
87
92
  bundler-cache: true
88
93
  ruby-version: ${{ matrix.ruby }}
94
+ - name: Run TLS tests (JRuby)
95
+ if: ${{ matrix.ruby == 'jruby' }}
96
+ run: bundle exec rake
97
+ env:
98
+ JAVA_OPTS: "-Djava.net.preferIPv4Stack=true"
99
+ TEST_AMQP_HOST: "localhost"
100
+ TESTOPTS: --name=/_tls$/
89
101
  - name: Run TLS tests
102
+ if: ${{ matrix.ruby != 'jruby' }}
90
103
  run: bundle exec rake
91
104
  env:
92
105
  TESTOPTS: --name=/_tls$/
@@ -104,7 +117,7 @@ jobs:
104
117
  run: brew install rabbitmq
105
118
  - name: Start RabbitMQ
106
119
  run: brew services start rabbitmq
107
- - uses: actions/checkout@v4
120
+ - uses: actions/checkout@v5
108
121
  - uses: ruby/setup-ruby@v1
109
122
  with:
110
123
  bundler-cache: true
@@ -113,3 +126,15 @@ jobs:
113
126
  run: while true; do rabbitmq-diagnostics status 2>/dev/null && break; echo -n .; sleep 2; done
114
127
  - name: Run tests (excluding TLS tests)
115
128
  run: bundle exec rake
129
+
130
+ lint:
131
+ runs-on: ubuntu-latest
132
+ timeout-minutes: 10
133
+ steps:
134
+ - uses: actions/checkout@v5
135
+ - uses: ruby/setup-ruby@v1
136
+ with:
137
+ bundler-cache: true
138
+ ruby-version: ruby
139
+ - name: Run RuboCop
140
+ run: bundle exec rake rubocop
@@ -13,7 +13,7 @@ jobs:
13
13
  permissions:
14
14
  id-token: write # for trusted publishing
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v5
17
17
  - uses: ruby/setup-ruby@v1
18
18
  with:
19
19
  bundler-cache: true
data/.rubocop.yml CHANGED
@@ -1,8 +1,10 @@
1
1
  inherit_from: .rubocop_todo.yml
2
+ plugins:
3
+ - rubocop-minitest
2
4
 
3
5
  AllCops:
4
6
  NewCops: disable
5
- TargetRubyVersion: 2.6
7
+ TargetRubyVersion: 3.2
6
8
  SuggestExtensions: false
7
9
 
8
10
  Style/StringLiterals:
@@ -18,11 +20,11 @@ Layout/LineLength:
18
20
 
19
21
  Naming/FileName:
20
22
  Exclude:
21
- - 'lib/amqp-client.rb'
23
+ - "lib/amqp-client.rb"
22
24
 
23
25
  Metrics/PerceivedComplexity:
24
26
  Exclude:
25
- - 'lib/amqp/client/properties.rb'
27
+ - "lib/amqp/client/properties.rb"
26
28
 
27
29
  Metrics/ParameterLists:
28
30
  Max: 8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0] - 2025-09-10
4
+
5
+ - Fixed: `Connection#channel` wasn't thread-safe
6
+ - Added: Support for heartbeats
7
+
8
+ ## [1.1.7] - 2024-05-12
9
+
10
+ - Support for Connection.update-secret
11
+ - Allow sub-second connect_timeout
12
+ - Fixed: undefinied variable if message was returned and no on_return block was set
13
+
3
14
  ## [1.1.6] - 2024-03-26
4
15
 
5
16
  - Fixed: Channel#wait_for_confirms now waits for all confirms, in a thread safe way
@@ -42,7 +53,7 @@
42
53
 
43
54
  ## [1.0.1] - 2021-09-06
44
55
 
45
- - The API is fully documented! https://cloudamqp.github.io/amqp-client.rb/
56
+ - The API is fully documented! <https://cloudamqp.github.io/amqp-client.rb/>
46
57
  - Fixed: Socket writing is now thread-safe
47
58
  - Change: Block while waiting for basic_cancel by default
48
59
  - Added: Can specify channel_max, heartbeat and frame_max as options to the Client/Connection
data/CODEOWNERS ADDED
@@ -0,0 +1 @@
1
+ * @carlhoerberg @spuun @dentarg @baelter @walro
data/README.md CHANGED
@@ -8,7 +8,7 @@ It's safe by default, messages are published as persistent, and is waiting for c
8
8
 
9
9
  ## Support
10
10
 
11
- The library is fully supported by [CloudAMQP](https://www.cloudamqp.com), the largest RabbitMQ hosting provider in the world. Open [an issue](https://github.com/cloudamqp/amqp-client.rb/issues) or [email our support](mailto:support@cloudamqp.com) if you have problems or questions.
11
+ The library is fully supported by [CloudAMQP](https://www.cloudamqp.com), the largest LavinMQ and RabbitMQ hosting provider in the world. Open [an issue](https://github.com/cloudamqp/amqp-client.rb/issues) or [email our support](mailto:support@cloudamqp.com) if you have problems or questions.
12
12
 
13
13
  ## Documentation
14
14
 
@@ -113,17 +113,76 @@ gem 'amqp-client'
113
113
 
114
114
  And then execute:
115
115
 
116
- $ bundle install
116
+ bundle install
117
117
 
118
118
  Or install it yourself as:
119
119
 
120
- $ gem install amqp-client
120
+ gem install amqp-client
121
121
 
122
122
  ## Development
123
123
 
124
124
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
125
125
 
126
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the changelog and version number in `version.rb`, make a commit, and then run `bundle exec rake release:source_control_push`, which will create a git tag for the version, push git commits and the created tag. GitHub Actions will then push the `.gem` file to [rubygems.org](https://rubygems.org/gems/amqp-client).
126
+ To install this gem onto your local machine, run `bundle exec rake install`.
127
+
128
+ ### Release Process
129
+
130
+ The gem uses rake tasks to automate the release process. Make sure your working directory is clean before starting a release.
131
+
132
+ #### Quick Release (Patch Version)
133
+
134
+ ```bash
135
+ rake release:full
136
+ ```
137
+
138
+ This will:
139
+
140
+ 1. Run tests and RuboCop to ensure code quality
141
+ 2. Bump the patch version (e.g., 1.2.0 → 1.2.1)
142
+ 3. Update the CHANGELOG.md with the new version and current date
143
+ 4. Create a git commit and tag for the release
144
+ 5. Build and push the gem to RubyGems
145
+ 6. Push commits and tags to the remote repository
146
+
147
+ #### Custom Version Bump
148
+
149
+ For minor or major version bumps:
150
+
151
+ ```bash
152
+ # Minor version bump (e.g., 1.2.0 → 1.3.0)
153
+ rake release:full[minor]
154
+
155
+ # Major version bump (e.g., 1.2.0 → 2.0.0)
156
+ rake release:full[major]
157
+ ```
158
+
159
+ #### Individual Release Steps
160
+
161
+ You can also run individual steps if needed:
162
+
163
+ ```bash
164
+ # Bump version only
165
+ rake release:bump[patch] # or [minor] or [major]
166
+
167
+ # Update changelog with current version
168
+ rake release:changelog
169
+
170
+ # Create git tag
171
+ rake release:tag
172
+
173
+ # Build and push to RubyGems
174
+ rake release:push
175
+ ```
176
+
177
+ #### Manual Release Steps
178
+
179
+ If you prefer manual control:
180
+
181
+ 1. Update the version number in `lib/amqp/client/version.rb`
182
+ 2. Update the CHANGELOG.md with the new version and release notes
183
+ 3. Commit your changes: `git add . && git commit -m "Release X.Y.Z"`
184
+ 4. Create and push a tag: `git tag vX.Y.Z && git push origin vX.Y.Z`
185
+ 5. Build and push: `rake build && gem push amqp-client-X.Y.Z.gem`
127
186
 
128
187
  ## Contributing
129
188
 
data/Rakefile CHANGED
@@ -18,12 +18,163 @@ end
18
18
 
19
19
  require "rubocop/rake_task"
20
20
 
21
- RuboCop::RakeTask.new do |task|
22
- task.requires << "rubocop-minitest"
23
- end
21
+ RuboCop::RakeTask.new
24
22
 
25
23
  require "yard"
26
24
 
27
25
  YARD::Rake::YardocTask.new
28
26
 
29
- task default: [:test, *(:rubocop if RUBY_ENGINE == "ruby")]
27
+ # Release helper methods
28
+ def current_version
29
+ version_file = "lib/amqp/client/version.rb"
30
+ content = File.read(version_file)
31
+ content.match(/VERSION = "(.+)"/)[1]
32
+ end
33
+
34
+ def bump_version(version_type)
35
+ unless %w[major minor patch].include?(version_type)
36
+ puts "Invalid version type. Use: major, minor, or patch"
37
+ exit 1
38
+ end
39
+
40
+ version_file = "lib/amqp/client/version.rb"
41
+ content = File.read(version_file)
42
+
43
+ current_version = content.match(/VERSION = "(.+)"/)[1]
44
+ major, minor, patch = current_version.split(".").map(&:to_i)
45
+
46
+ case version_type
47
+ when "major"
48
+ major += 1
49
+ minor = 0
50
+ patch = 0
51
+ when "minor"
52
+ minor += 1
53
+ patch = 0
54
+ when "patch"
55
+ patch += 1
56
+ end
57
+
58
+ new_version = "#{major}.#{minor}.#{patch}"
59
+ new_content = content.gsub(/VERSION = ".+"/, %(VERSION = "#{new_version}"))
60
+
61
+ File.write(version_file, new_content)
62
+ puts "Bumped version from #{current_version} to #{new_version}"
63
+ end
64
+
65
+ def update_changelog
66
+ version = current_version
67
+ date = Time.now.strftime("%Y-%m-%d")
68
+
69
+ changelog = File.read("CHANGELOG.md")
70
+
71
+ if changelog.include?("## [#{version}]")
72
+ puts "Version #{version} already exists in CHANGELOG.md"
73
+ else
74
+ updated_changelog = changelog.sub(
75
+ "## [Unreleased]",
76
+ "## [Unreleased]\n\n## [#{version}] - #{date}"
77
+ )
78
+
79
+ File.write("CHANGELOG.md", updated_changelog)
80
+ puts "Updated CHANGELOG.md with version #{version}"
81
+ end
82
+ end
83
+
84
+ def create_git_tag
85
+ version = current_version
86
+
87
+ system("git add .")
88
+ system("git commit -m 'Release #{version}'")
89
+
90
+ # Check if tag already exists and remove it if it does
91
+ if system("git tag -l v#{version} | grep -q v#{version}")
92
+ puts "Tag v#{version} already exists, removing it..."
93
+ system("git tag -d v#{version}")
94
+ end
95
+
96
+ system("git tag v#{version}")
97
+
98
+ puts "Created git tag v#{version}"
99
+ end
100
+
101
+ def push_gem_to_rubygems
102
+ version = current_version
103
+
104
+ # Look for gem file in both current directory and pkg directory
105
+ gem_file = "amqp-client-#{version}.gem"
106
+ pkg_gem_file = "pkg/amqp-client-#{version}.gem"
107
+
108
+ if File.exist?(pkg_gem_file)
109
+ system("gem push #{pkg_gem_file}")
110
+ puts "Pushed #{pkg_gem_file} to RubyGems"
111
+ elsif File.exist?(gem_file)
112
+ system("gem push #{gem_file}")
113
+ puts "Pushed #{gem_file} to RubyGems"
114
+ else
115
+ puts "Gem file #{gem_file} not found in current directory or pkg/. Make sure to build first."
116
+ exit 1
117
+ end
118
+ end
119
+
120
+ def full_release_process(version_type)
121
+ puts "Starting release process..."
122
+
123
+ # Ensure working directory is clean
124
+ unless system("git diff --quiet && git diff --cached --quiet")
125
+ puts "Working directory is not clean. Please commit or stash changes first."
126
+ exit 1
127
+ end
128
+
129
+ # Bump version
130
+ Rake::Task["release:bump"].invoke(version_type)
131
+ Rake::Task["release:bump"].reenable
132
+
133
+ # Update changelog
134
+ Rake::Task["release:changelog"].invoke
135
+ Rake::Task["release:changelog"].reenable
136
+
137
+ # Create tag and push
138
+ Rake::Task["release:tag"].invoke
139
+ Rake::Task["release:tag"].reenable
140
+
141
+ # Build and push gem
142
+ Rake::Task["release:push"].invoke
143
+ Rake::Task["release:push"].reenable
144
+
145
+ # Push to git
146
+ system("git push origin")
147
+ system("git push origin --tags")
148
+
149
+ version = current_version
150
+ puts "Successfully released version #{version}!"
151
+ end
152
+
153
+ namespace :release do
154
+ desc "Bump version (usage: rake release:bump[major|minor|patch])"
155
+ task :bump, [:type] do |_t, args|
156
+ bump_version(args[:type] || "patch")
157
+ end
158
+
159
+ desc "Update changelog with current version"
160
+ task :changelog do
161
+ update_changelog
162
+ end
163
+
164
+ desc "Create git tag for current version"
165
+ task :tag do
166
+ create_git_tag
167
+ end
168
+
169
+ desc "Build and push gem to RubyGems"
170
+ task push: :build do
171
+ push_gem_to_rubygems
172
+ end
173
+
174
+ desc "Full release process (bump version, update changelog, tag, build and push)"
175
+ task :full, [:type] => %i[test rubocop] do |_t, args|
176
+ full_release_process(args[:type] || "patch")
177
+ end
178
+ end
179
+
180
+ task default: [:test, *(:rubocop if ENV["CI"] != "true")]
data/amqp-client.gemspec CHANGED
@@ -5,14 +5,14 @@ require_relative "lib/amqp/client/version"
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "amqp-client"
7
7
  spec.version = AMQP::Client::VERSION
8
- spec.authors = ["Carl Hörberg"]
9
- spec.email = ["carl@cloudamqp.com"]
8
+ spec.authors = ["CloudAMQP"]
9
+ spec.email = ["team@cloudamqp.com"]
10
10
 
11
11
  spec.summary = "AMQP 0-9-1 client"
12
12
  spec.description = "Modern AMQP 0-9-1 Ruby client"
13
13
  spec.homepage = "https://github.com/cloudamqp/amqp-client.rb"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0")
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = "#{spec.homepage}.git"
@@ -251,7 +251,7 @@ module AMQP
251
251
  # @option properties [Hash<String, Object>] headers Custom headers
252
252
  # @option properties [Integer] delivery_mode 2 for persisted message, transient messages for all other values
253
253
  # @option properties [Integer] priority A priority of the message (between 0 and 255)
254
- # @option properties [Integer] correlation_id A correlation id, most often used used for RPC communication
254
+ # @option properties [String] correlation_id A correlation id, most often used used for RPC communication
255
255
  # @option properties [String] reply_to Queue to reply RPC responses to
256
256
  # @option properties [Integer, String] expiration Number of seconds the message will stay in the queue
257
257
  # @option properties [String] message_id Can be used to uniquely identify the message, e.g. for deduplication
@@ -345,8 +345,8 @@ module AMQP
345
345
  end
346
346
 
347
347
  # Specify how many messages to prefetch for consumers with `no_ack: false`
348
- # @param prefetch_count [Integer] Number of messages to maxium keep in flight
349
- # @param prefetch_size [Integer] Number of bytes to maxium keep in flight
348
+ # @param prefetch_count [Integer] Number of messages to maximum keep in flight
349
+ # @param prefetch_size [Integer] Number of bytes to maximum keep in flight
350
350
  # @param global [Boolean] If true the limit will apply to channel rather than the consumer
351
351
  # @return [nil]
352
352
  def basic_qos(prefetch_count, prefetch_size: 0, global: false)
@@ -522,7 +522,7 @@ module AMQP
522
522
  if @on_return
523
523
  Thread.new { @on_return.call(next_msg) }
524
524
  else
525
- warn "AMQP-Client message returned: #{msg.inspect}"
525
+ warn "AMQP-Client message returned: #{next_msg.inspect}"
526
526
  end
527
527
  elsif next_msg.consumer_tag.nil?
528
528
  @basic_gets.push next_msg
@@ -18,7 +18,7 @@ module AMQP
18
18
  # @option options [Boolean] connection_name (PROGRAM_NAME) Set a name for the connection to be able to identify
19
19
  # the client from the broker
20
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
21
+ # @option options [Float] connect_timeout (30) TCP connection timeout
22
22
  # @option options [Integer] heartbeat (0) Heartbeat timeout, defaults to 0 and relies on TCP keepalive instead
23
23
  # @option options [Integer] frame_max (131_072) Maximum frame size,
24
24
  # the smallest of the client's and the broker's values will be used
@@ -44,6 +44,7 @@ module AMQP
44
44
  @frame_max = frame_max
45
45
  @heartbeat = heartbeat
46
46
  @channels = {}
47
+ @channels_lock = Mutex.new
47
48
  @closed = nil
48
49
  @replies = ::Queue.new
49
50
  @write_lock = Mutex.new
@@ -51,6 +52,9 @@ module AMQP
51
52
  @on_blocked = ->(reason) { warn "AMQP-Client blocked by broker: #{reason}" }
52
53
  @on_unblocked = -> { warn "AMQP-Client unblocked by broker" }
53
54
 
55
+ # Only used with heartbeats
56
+ @last_activity_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+
54
58
  Thread.new { read_loop } if read_loop_thread
55
59
  end
56
60
 
@@ -89,15 +93,17 @@ module AMQP
89
93
  raise ArgumentError, "Channel ID cannot be 0" if id&.zero?
90
94
  raise ArgumentError, "Channel ID higher than connection's channel max #{@channel_max}" if id && id > @channel_max
91
95
 
92
- if id
93
- ch = @channels[id] ||= Channel.new(self, id)
94
- else
95
- 1.upto(@channel_max) do |i|
96
- break id = i unless @channels.key? i
97
- end
98
- raise Error, "Max channels reached" if id.nil?
96
+ ch = @channels_lock.synchronize do
97
+ if id
98
+ @channels[id] ||= Channel.new(self, id)
99
+ else
100
+ 1.upto(@channel_max) do |i|
101
+ break id = i unless @channels.key? i
102
+ end
103
+ raise Error, "Max channels reached" if id.nil?
99
104
 
100
- ch = @channels[id] = Channel.new(self, id)
105
+ @channels[id] = Channel.new(self, id)
106
+ end
101
107
  end
102
108
  ch.open
103
109
  end
@@ -132,6 +138,16 @@ module AMQP
132
138
  nil
133
139
  end
134
140
 
141
+ # Update authentication secret, for example when an OAuth backend is used
142
+ # @param secret [String] The new secret
143
+ # @param reason [String] A reason to update it
144
+ # @return [nil]
145
+ def update_secret(secret, reason)
146
+ write_bytes FrameBytes.update_secret(secret, reason)
147
+ expect(:update_secret_ok)
148
+ nil
149
+ end
150
+
135
151
  # True if the connection is closed
136
152
  # @return [Boolean]
137
153
  def closed?
@@ -165,6 +181,7 @@ module AMQP
165
181
  def write_bytes(*bytes)
166
182
  @write_lock.synchronize do
167
183
  @socket.write(*bytes)
184
+ update_last_activity
168
185
  end
169
186
  rescue *READ_EXCEPTIONS => e
170
187
  raise Error::ConnectionClosed.new(*@closed) if @closed
@@ -196,6 +213,7 @@ module AMQP
196
213
 
197
214
  # parse the frame, will return false if a close frame was received
198
215
  parse_frame(type, channel_id, frame_buffer) || return
216
+ update_last_activity
199
217
  end
200
218
  nil
201
219
  rescue *READ_EXCEPTIONS => e
@@ -222,7 +240,7 @@ module AMQP
222
240
  READ_EXCEPTIONS = [IOError, OpenSSL::OpenSSLError, SystemCallError,
223
241
  RUBY_ENGINE == "jruby" ? java.lang.NullPointerException : nil].compact.freeze
224
242
 
225
- def parse_frame(type, channel_id, buf) # rubocop:disable Metrics/MethodLength
243
+ def parse_frame(type, channel_id, buf) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
226
244
  channel = @channels[channel_id]
227
245
  case type
228
246
  when 1 # method frame
@@ -255,6 +273,8 @@ module AMQP
255
273
  when 61 # connection#unblocked
256
274
  @blocked = nil
257
275
  @on_unblocked.call
276
+ when 71 # connection#update_secret_ok
277
+ @replies.push [:update_secret_ok]
258
278
  else raise Error::UnsupportedMethodFrame, class_id, method_id
259
279
  end
260
280
  when 20 # channel
@@ -265,11 +285,11 @@ module AMQP
265
285
  reply_code, reply_text_len = buf.unpack("@4 S> C")
266
286
  reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
267
287
  classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
268
- channel = @channels.delete(channel_id)
288
+ channel = @channels_lock.synchronize { @channels.delete(channel_id) }
269
289
  channel.closed!(:channel, reply_code, reply_text, classid, methodid)
270
290
  write_bytes FrameBytes.channel_close_ok(channel_id)
271
291
  when 41 # channel#close-ok
272
- channel = @channels.delete(channel_id)
292
+ channel = @channels_lock.synchronize { @channels.delete(channel_id) }
273
293
  channel.reply [:channel_close_ok]
274
294
  else raise Error::UnsupportedMethodFrame, class_id, method_id
275
295
  end
@@ -394,17 +414,61 @@ module AMQP
394
414
  channel.header_delivered body_size, properties
395
415
  when 3 # body
396
416
  channel.body_delivered buf
417
+ when 8 # heartbeat
418
+ handle_server_heartbeat(channel_id)
397
419
  else raise Error::UnsupportedFrameType, type
398
420
  end
399
421
  true
400
422
  end
401
423
 
424
+ def update_last_activity
425
+ return unless @heartbeat&.positive?
426
+
427
+ @last_activity_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
428
+ end
429
+
430
+ def handle_server_heartbeat(channel_id)
431
+ return if channel_id.zero?
432
+
433
+ raise Error::ConnectionClosed.new(501, "Heartbeat frame received on non-zero channel #{channel_id}")
434
+ end
435
+
436
+ # Start the heartbeat background thread (called from connection#tune)
437
+ def start_heartbeats(period)
438
+ Thread.new do
439
+ Thread.current.abort_on_exception = true # Raising an unhandled exception is a bug
440
+ interval = period / 2.0
441
+ loop do
442
+ sleep interval
443
+ break if @closed
444
+ next if @socket.nil?
445
+
446
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
447
+ # If we haven't sent anything recently, send a heartbeat
448
+ next unless now - @last_activity_time >= interval
449
+
450
+ begin
451
+ send_heartbeat
452
+ rescue Error => e
453
+ warn "AMQP-Client heartbeat send failed: #{e.inspect}"
454
+ break
455
+ end
456
+ end
457
+ end
458
+ end
459
+
460
+ def send_heartbeat
461
+ write_bytes FrameBytes.heartbeat
462
+ end
463
+
402
464
  def expect(expected_frame_type)
403
465
  frame_type, args = @replies.pop
404
466
  if frame_type.nil?
405
467
  return if expected_frame_type == :close_ok
406
468
 
407
- raise(Error::ConnectionClosed, "while waiting for #{expected_frame_type}")
469
+ raise Error::ConnectionClosed.new(*@closed) if @closed
470
+
471
+ raise Error, "Connection closed while waiting for #{expected_frame_type}"
408
472
  end
409
473
  frame_type == expected_frame_type || raise(Error::UnexpectedFrame.new(expected_frame_type, frame_type))
410
474
  args
@@ -414,7 +478,7 @@ module AMQP
414
478
  # @return [Socket]
415
479
  # @return [OpenSSL::SSL::SSLSocket]
416
480
  def open_socket(host, port, tls, options)
417
- connect_timeout = options.fetch(:connect_timeout, 30).to_i
481
+ connect_timeout = options.fetch(:connect_timeout, 30).to_f
418
482
  socket = Socket.tcp host, port, connect_timeout: connect_timeout
419
483
  keepalive = options.fetch(:keepalive, "").split(":", 3).map!(&:to_i)
420
484
  enable_tcp_keepalive(socket, *keepalive)
@@ -442,7 +506,7 @@ module AMQP
442
506
  channel_max, frame_max, heartbeat = nil
443
507
  socket.write "AMQP\x00\x00\x09\x01"
444
508
  buf = String.new(capacity: 4096)
445
- loop do
509
+ loop do # rubocop:disable Metrics/BlockLength
446
510
  begin
447
511
  socket.readpartial(4096, buf)
448
512
  rescue *READ_EXCEPTIONS => e
@@ -471,6 +535,7 @@ module AMQP
471
535
  channel_max = [channel_max, options.fetch(:channel_max, 2048).to_i].min
472
536
  frame_max = [frame_max, options.fetch(:frame_max, 131_072).to_i].min
473
537
  heartbeat = [heartbeat, options.fetch(:heartbeat, 0).to_i].min
538
+ start_heartbeats(heartbeat) if heartbeat.positive?
474
539
  socket.write FrameBytes.connection_tune_ok(channel_max, frame_max, heartbeat)
475
540
  socket.write FrameBytes.connection_open(vhost)
476
541
  when 41 # connection#open-ok
@@ -496,7 +561,7 @@ module AMQP
496
561
  raise e
497
562
  end
498
563
 
499
- # Enable TCP keepalive, which is prefered to heartbeats
564
+ # Enable TCP keepalive, which is preferred to heartbeats
500
565
  # @return [void]
501
566
  def enable_tcp_keepalive(socket, idle = 60, interval = 10, count = 3)
502
567
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
@@ -9,7 +9,7 @@ module AMQP
9
9
  # Each frame type implemented as a method
10
10
  # Having a class for each frame type is more expensive in terms of CPU and memory
11
11
  # @api private
12
- module FrameBytes
12
+ module FrameBytes # rubocop:disable Metrics/ModuleLength
13
13
  def self.connection_start_ok(response, properties)
14
14
  prop_tbl = Table.encode(properties)
15
15
  [
@@ -81,6 +81,20 @@ module AMQP
81
81
  ].pack("C S> L> S> S> C")
82
82
  end
83
83
 
84
+ def self.update_secret(secret, reason)
85
+ frame_size = 4 + 4 + secret.bytesize + 1 + reason.bytesize
86
+ [
87
+ 1, # type: method
88
+ 0, # channel id
89
+ frame_size, # frame size
90
+ 10, # class: connection
91
+ 70, # method: close-ok
92
+ secret.bytesize, secret,
93
+ reason.bytesize, reason,
94
+ 206 # frame end
95
+ ].pack("C S> L> S> S> L>a* Ca* C")
96
+ end
97
+
84
98
  def self.channel_open(id)
85
99
  [
86
100
  1, # type: method
@@ -528,6 +542,15 @@ module AMQP
528
542
  206 # frame end
529
543
  ].pack("C S> L> S> S> C")
530
544
  end
545
+
546
+ def self.heartbeat
547
+ [
548
+ 8, # type: heartbeat
549
+ 0, # channel id
550
+ 0, # frame size
551
+ 206 # frame end
552
+ ].pack("C S> L> C")
553
+ end
531
554
  end
532
555
  end
533
556
  end
@@ -3,6 +3,6 @@
3
3
  module AMQP
4
4
  class Client
5
5
  # Version of the client library
6
- VERSION = "1.1.6"
6
+ VERSION = "1.2.0"
7
7
  end
8
8
  end
data/lib/amqp/client.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
3
  require_relative "client/version"
5
4
  require_relative "client/connection"
6
5
  require_relative "client/exchange"
@@ -52,7 +51,7 @@ module AMQP
52
51
  def start
53
52
  @stopped = false
54
53
  Thread.new(connect(read_loop_thread: false)) do |conn|
55
- Thread.abort_on_exception = true # Raising an unhandled exception is a bug
54
+ Thread.current.abort_on_exception = true # Raising an unhandled exception is a bug
56
55
  loop do
57
56
  break if @stopped
58
57
 
metadata CHANGED
@@ -1,18 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amqp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.6
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - Carl Hörberg
8
- autorequire:
7
+ - CloudAMQP
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-03-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: Modern AMQP 0-9-1 Ruby client
14
13
  email:
15
- - carl@cloudamqp.com
14
+ - team@cloudamqp.com
16
15
  executables: []
17
16
  extensions: []
18
17
  extra_rdoc_files: []
@@ -26,6 +25,7 @@ files:
26
25
  - ".rubocop_todo.yml"
27
26
  - ".yardopts"
28
27
  - CHANGELOG.md
28
+ - CODEOWNERS
29
29
  - Gemfile
30
30
  - LICENSE.txt
31
31
  - README.md
@@ -52,7 +52,6 @@ metadata:
52
52
  homepage_uri: https://github.com/cloudamqp/amqp-client.rb
53
53
  source_code_uri: https://github.com/cloudamqp/amqp-client.rb.git
54
54
  changelog_uri: https://github.com/cloudamqp/amqp-client.rb/blob/main/CHANGELOG.md
55
- post_install_message:
56
55
  rdoc_options: []
57
56
  require_paths:
58
57
  - lib
@@ -60,15 +59,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
60
59
  requirements:
61
60
  - - ">="
62
61
  - !ruby/object:Gem::Version
63
- version: 2.6.0
62
+ version: 3.2.0
64
63
  required_rubygems_version: !ruby/object:Gem::Requirement
65
64
  requirements:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0'
69
68
  requirements: []
70
- rubygems_version: 3.5.3
71
- signing_key:
69
+ rubygems_version: 3.6.9
72
70
  specification_version: 4
73
71
  summary: AMQP 0-9-1 client
74
72
  test_files: []