rmagick_tidy 0.1.0 → 0.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: 8f147dcd4088210662a53b4a66911e721b5b5a506ff4b1bead65db5a94ac873b
4
- data.tar.gz: c80d6229d5ef1e1272097f3d30b939629c04ddb29bed8f185c2a01dc4f7f4d05
3
+ metadata.gz: d63fdf8da8e0b7479b85be728d7e00e3e7821a042fbaacb730d6c6a3d783d337
4
+ data.tar.gz: 52d88eedea15175cd1b35fd8046f4127ccc73cc3b98511bf7c5afcc02a258ded
5
5
  SHA512:
6
- metadata.gz: e60b3a6a5812a68936e71e73f073ce9f1d1f57032df67ff05c99c5279acba8fc7f935f8435c263388f95a31ac784ba09caa7e10d63954810f155ef3fadf5f592
7
- data.tar.gz: 63f5780ab884f412049b3fc7290e3f0a050ce8859a66fd87952417156e978ec6f0623efbadb2b98d88711289158e03130efecce5adedea1aa92351bda61b3d73
6
+ metadata.gz: 2087aa6871347fede9c6fa3022dad2aeb425a0b2e46cb181071fe623156ae1c0fb21e1ec0152ee8d84663b9e74695cd0e9097a219ce2194b3cceacec4df172ce
7
+ data.tar.gz: 44a0ea74486054b2b9ee69052540c2a6018a707d864ac21dab7ae7a8656e3c278886244553690f445938f6c55509e5e936d4f6e50f513ced5c62d75dc9633d86
data/CHANGELOG.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ Release notes for versions after 0.1.0 are published on
4
+ [GitHub Releases](https://github.com/jksy/rmagick_tidy/releases), generated
5
+ from merged pull requests. This file is kept for the history below.
4
6
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
7
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
8
 
10
9
  ## [0.1.0] - 2026-05-21
11
10
 
data/README.ja.md CHANGED
@@ -95,6 +95,64 @@ end
95
95
 
96
96
  `to_blob` は `String` を返すのでスコープの解放対象になりません。Image オブジェクトのみがクリーンアップされます。
97
97
 
98
+ ## Rails 以外・ジョブで使う
99
+
100
+ gem 自体が組み込むのは Rails のコントローラだけです。それ以外の場所では、処理の単位を自分で `RmagickTidy.scope` で包んでください。数行で書けます。アプリ側に置いておけば、各ライブラリのバージョンアップにも自分のペースで合わせられます。
101
+
102
+ ### Rack ミドルウェア(Sinatra / Hanami / 素の Rack など)
103
+
104
+ ```ruby
105
+ class RmagickTidyMiddleware
106
+ def initialize(app)
107
+ @app = app
108
+ end
109
+
110
+ def call(env)
111
+ RmagickTidy.scope { @app.call(env) }
112
+ end
113
+ end
114
+
115
+ use RmagickTidyMiddleware
116
+ ```
117
+
118
+ スコープはアプリから戻った時点で終わります。サーバがボディを送るより前です。`String` の `Array` や Rails・Sinatra の通常のレスポンスのように、中身ができあがっているボディなら問題ありません。streaming body や `Enumerator` のように送信中に画像を作ったり使ったりする場合は、その画像処理をボディの中に書き、ボディの中で `RmagickTidy.scope` を張ってください。アプリ側で作ってボディが後から使う画像は、その時点で解放済みです。
119
+
120
+ ### ActiveJob
121
+
122
+ ```ruby
123
+ class ApplicationJob < ActiveJob::Base
124
+ around_perform do |_job, block|
125
+ RmagickTidy.scope do
126
+ block.call
127
+ nil
128
+ end
129
+ end
130
+ end
131
+ ```
132
+
133
+ `nil` を返すのは、`perform` の戻り値を keep しないためです。`perform` の最後が `img.write(path)` だと戻り値は `img` 自身になり、そのままだと GC まで残ってしまいます。
134
+
135
+ ### Sidekiq
136
+
137
+ ```ruby
138
+ class RmagickTidySidekiqMiddleware
139
+ def call(_job, _payload, _queue)
140
+ RmagickTidy.scope do
141
+ yield
142
+ nil
143
+ end
144
+ end
145
+ end
146
+
147
+ Sidekiq.configure_server do |config|
148
+ config.server_middleware do |chain|
149
+ chain.add RmagickTidySidekiqMiddleware
150
+ end
151
+ end
152
+ ```
153
+
154
+ Sidekiq 上で動かす ActiveJob のジョブでは、上の `around_perform` とこのミドルウェアのどちらか一方で足ります。両方入れてもスコープが入れ子になるだけです。
155
+
98
156
  ## strict モード
99
157
 
100
158
  開発・テスト環境で「スコープ外で作られた Image」を検知したいときに使います。
@@ -106,23 +164,44 @@ end
106
164
  ```
107
165
 
108
166
  - `:off` — 何もしない(本番デフォルト)
109
- - `:warn` — `warn` で標準エラー出力に通知
167
+ - `:warn` — `warn` で標準エラー出力に通知(`logger` 設定時はそちらへ出力)
110
168
  - `:raise` — `RmagickTidy::OutOfScopeError` を発生
111
169
 
112
- > **Configuration はスレッドセーフではありません。** `strict_mode`(および将来追加されるオプション)は **起動時に 1 回だけ**設定してください(例: Rails の initializer)。ワーカースレッドが `Magick::Image` を使い始めたあとに別スレッドから書き換える挙動は未定義です。
170
+ `:warn` の出力先を標準エラー出力以外にしたい場合は、`warn(message)` に応答するオブジェクト(例: `Rails.logger`)を `logger` に設定します。`logger` が `nil`(デフォルト)のときは従来通り `Kernel#warn` にフォールバックします。Railtie は自動では設定しないため、Rails アプリでは initializer で明示的に指定してください。
171
+
172
+ ```ruby
173
+ RmagickTidy.configure do |c|
174
+ c.strict_mode = :warn
175
+ c.logger = Rails.logger
176
+ end
177
+ ```
178
+
179
+ > **Configuration はスレッドセーフではありません。** `strict_mode`・`logger`(および将来追加されるオプション)は **起動時に 1 回だけ**設定してください(例: Rails の initializer)。ワーカースレッドが `Magick::Image` を使い始めたあとに別スレッドから書き換える挙動は未定義です。
180
+
181
+ ## メモリ使用量のベンチマーク
182
+
183
+ [`benchmark/memory.rb`](benchmark/memory.rb) は、生成した画像の read / resize をループで繰り返し、`RmagickTidy.scope` なし・ありの 2 パターンでプロセスの RSS を記録します。
184
+
185
+ ```bash
186
+ bundle exec ruby benchmark/memory.rb
187
+ ITERATIONS=100 SIZE=4000 bundle exec ruby benchmark/memory.rb
188
+ ```
189
+
190
+ デフォルト設定(50 回、3000x3000 の画像。Linux 上の Ruby 3.4 / RMagick 7.2 / ImageMagick 7.1 Q16-HDRI)では、scope なしで RSS が約 340 MB まで増え、scope ありでは約 57 MB で安定しました。具体的な数値は環境や ImageMagick のビルドによって変わります。
113
191
 
114
192
  ## 仕組み
115
193
 
116
194
  - `Magick::Image` / `Magick::ImageList` に対し、`Module#prepend` で全ての public instance method(および `new`, `read`, `from_blob`, ...のクラスメソッド)をラップ
117
195
  - **戻り値の型をチェック**して `Magick::Image` であれば現在のスコープに登録(ホワイトリストを持たないので RMagick のバージョン差異を吸収)
118
- - 戻り値が `self` と `equal?` なら bang メソッドとみなして登録しない
196
+ - 戻り値が `self` と `equal?` なら bang メソッドとみなして登録しない。同様に、Image を登録するのは初めて見たときだけ(`ObjectSpace::WeakMap` で同一性を記録)。`ImageList#[]`・`#first`・`#pop`・`#delete` が返す要素や、浅いコピーである `ImageList#dup` の中身は再登録しないので、スコープ外で作ったリストを操作しても元の画像は破棄されない
197
+ - `dup` / `clone` もラップする。`Magick::Image#dup` / `#clone` は新しい画像を確保するので、他のメソッドと同様に登録される
119
198
  - スコープスタックは `Thread.current` 配下なのでマルチスレッド環境でも安全
120
199
  - 二重 `destroy!` は `destroyed?` 判定 + rescue で防止
121
200
 
122
201
  ## 対応バージョン
123
202
 
124
- - Ruby 3.2 以上
125
- - RMagick 2.x 〜 6.x(戻り値チェック方式のため幅広く動作)
203
+ - Ruby 3.3 以上
204
+ - RMagick 5.x 〜 7.x(CI では 6.x / 7.x でテスト)
126
205
 
127
206
  ## ライセンス
128
207
 
data/README.md CHANGED
@@ -95,6 +95,64 @@ end
95
95
 
96
96
  `to_blob` returns a `String`, so it is never treated as a cleanup target — only the image objects are released.
97
97
 
98
+ ## Other frameworks and job queues
99
+
100
+ The gem itself only hooks into Rails controllers. Anywhere else, wrap the unit of work in `RmagickTidy.scope` yourself. It only takes a few lines, and keeping them in your app means you control them when those libraries change.
101
+
102
+ ### Rack middleware (Sinatra, Hanami, plain Rack, ...)
103
+
104
+ ```ruby
105
+ class RmagickTidyMiddleware
106
+ def initialize(app)
107
+ @app = app
108
+ end
109
+
110
+ def call(env)
111
+ RmagickTidy.scope { @app.call(env) }
112
+ end
113
+ end
114
+
115
+ use RmagickTidyMiddleware
116
+ ```
117
+
118
+ The scope ends when the app returns, before the server sends the body. That is fine for bodies whose content is already built (an `Array` of strings, a Rails or Sinatra response). If a streaming body or an `Enumerator` creates or uses images while it is being sent, do that image work inside the body, in its own `RmagickTidy.scope`; images created by the app and used later by the body are already released by then.
119
+
120
+ ### ActiveJob
121
+
122
+ ```ruby
123
+ class ApplicationJob < ActiveJob::Base
124
+ around_perform do |_job, block|
125
+ RmagickTidy.scope do
126
+ block.call
127
+ nil
128
+ end
129
+ end
130
+ end
131
+ ```
132
+
133
+ The `nil` stops the scope from keeping what `perform` returns. A `perform` that ends with `img.write(path)` returns `img` itself, which would otherwise stay alive until GC.
134
+
135
+ ### Sidekiq
136
+
137
+ ```ruby
138
+ class RmagickTidySidekiqMiddleware
139
+ def call(_job, _payload, _queue)
140
+ RmagickTidy.scope do
141
+ yield
142
+ nil
143
+ end
144
+ end
145
+ end
146
+
147
+ Sidekiq.configure_server do |config|
148
+ config.server_middleware do |chain|
149
+ chain.add RmagickTidySidekiqMiddleware
150
+ end
151
+ end
152
+ ```
153
+
154
+ For ActiveJob jobs running on Sidekiq, either the `around_perform` above or this middleware is enough; using both just nests the scopes.
155
+
98
156
  ## Strict mode
99
157
 
100
158
  Use strict mode in development or test environments to catch images created outside of a scope.
@@ -106,26 +164,50 @@ end
106
164
  ```
107
165
 
108
166
  - `:off` — do nothing (the production default)
109
- - `:warn` — print a warning to stderr
167
+ - `:warn` — print a warning to stderr (or to `logger`, if set)
110
168
  - `:raise` — raise `RmagickTidy::OutOfScopeError`
111
169
 
112
- > **Configuration is not thread-safe.** Set `strict_mode` (and any future
113
- > options) **once at boot** — for example from a Rails initializer — before any
114
- > worker thread starts using `Magick::Image`. Reading and writing the value
170
+ To send `:warn` messages to a logger instead of stderr, set `logger` to any
171
+ object that responds to `warn(message)` (e.g. `Rails.logger`). When `logger`
172
+ is `nil` (the default), the warning falls back to `Kernel#warn`. The Railtie
173
+ does not set it for you, so Rails apps opt in from an initializer:
174
+
175
+ ```ruby
176
+ RmagickTidy.configure do |c|
177
+ c.strict_mode = :warn
178
+ c.logger = Rails.logger
179
+ end
180
+ ```
181
+
182
+ > **Configuration is not thread-safe.** Set `strict_mode` and `logger` (and
183
+ > any future options) **once at boot** — for example from a Rails initializer —
184
+ > before any worker thread starts using `Magick::Image`. Reading and writing the value
115
185
  > from multiple threads concurrently is undefined.
116
186
 
187
+ ## Memory benchmark
188
+
189
+ [`benchmark/memory.rb`](benchmark/memory.rb) reads and resizes a generated image in a loop, once without `RmagickTidy.scope` and once with it, and prints the process RSS as it goes:
190
+
191
+ ```bash
192
+ bundle exec ruby benchmark/memory.rb
193
+ ITERATIONS=100 SIZE=4000 bundle exec ruby benchmark/memory.rb
194
+ ```
195
+
196
+ With the defaults (50 iterations, 3000x3000 source; Ruby 3.4, RMagick 7.2, ImageMagick 7.1 Q16-HDRI on Linux), RSS climbed to about 340 MB without the scope and stayed at about 57 MB with it. Exact numbers depend on your platform and ImageMagick build.
197
+
117
198
  ## How it works
118
199
 
119
200
  - `Magick::Image` and `Magick::ImageList` are hooked with `Module#prepend`, wrapping every public instance method as well as class methods such as `new`, `read`, and `from_blob`.
120
201
  - Each wrapped method **inspects its return value**: if it is a `Magick::Image`, the image is registered with the current scope. Because the gem keeps no method whitelist, it works across a wide range of RMagick versions.
121
- - If the return value is `equal?` to `self`, it is treated as a bang method and not registered.
202
+ - If the return value is `equal?` to `self`, it is treated as a bang method and not registered. Likewise, an image is registered only the first time it is seen (tracked by identity in an `ObjectSpace::WeakMap`): images handed back from a list, such as by `ImageList#[]`, `#first`, `#pop`, `#delete`, or the shallow copy from `ImageList#dup`, are not registered again, so working with a list created outside the scope does not destroy its images.
203
+ - `dup` / `clone` are wrapped too: `Magick::Image#dup` and `#clone` allocate a new image, which is tracked like any other.
122
204
  - The scope stack lives in `Thread.current`, so it is safe to use under multi-threaded servers such as Puma.
123
205
  - Calling `destroy!` twice is guarded against with a `destroyed?` check and a defensive `rescue`.
124
206
 
125
207
  ## Compatibility
126
208
 
127
- - Ruby 3.2 or newer
128
- - RMagick 2.x through 6.x (the return-value approach keeps the gem broadly compatible)
209
+ - Ruby 3.3 or newer
210
+ - RMagick 5.x through 7.x (CI tests against 6.x and 7.x)
129
211
 
130
212
  ## License
131
213
 
@@ -3,8 +3,13 @@ module RmagickTidy
3
3
  # :off (default), :warn, :raise
4
4
  attr_accessor :strict_mode
5
5
 
6
+ # Receives strict_mode :warn messages via #warn (e.g. Rails.logger).
7
+ # nil (default) falls back to Kernel#warn, i.e. stderr.
8
+ attr_accessor :logger
9
+
6
10
  def initialize
7
11
  @strict_mode = :off
12
+ @logger = nil
8
13
  end
9
14
  end
10
15
 
@@ -8,6 +8,11 @@ module RmagickTidy
8
8
  tainted? untrusted? object_id class itself
9
9
  ].freeze
10
10
 
11
+ # Copy methods inherited from Kernel, so public_instance_methods(false)
12
+ # misses them. Image#dup / #clone allocate a new C image; ImageList's
13
+ # share the receiver's images, which Tracker.track has already seen.
14
+ COPY_METHODS = %i[dup clone].freeze
15
+
11
16
  # Class-level methods on Magick::Image that produce new images.
12
17
  CLASS_METHODS = %i[new read ping from_blob read_inline capture constitute combine].freeze
13
18
 
@@ -32,7 +37,7 @@ module RmagickTidy
32
37
  mod = Module.new do
33
38
  def self.inspect = "#<RmagickTidy::InstanceHook>"
34
39
  end
35
- method_names = klass.public_instance_methods(false) - SKIP_INSTANCE_METHODS
40
+ method_names = (klass.public_instance_methods(false) | COPY_METHODS) - SKIP_INSTANCE_METHODS
36
41
  method_names.each do |name|
37
42
  next if name.to_s.end_with?("=")
38
43
 
@@ -1,11 +1,22 @@
1
1
  module RmagickTidy
2
2
  module Tracker
3
+ # Every Image that has passed through track, held weakly and compared by
4
+ # identity (RMagick maps one Ruby object to one C image). An image seen
5
+ # before is not a new allocation, whichever method hands it back:
6
+ # ImageList#dup, #[], #first share the list's images, and #pop, #shift,
7
+ # #delete return ones taken out of it. Registering those would destroy
8
+ # images that belong to an outer scope or to no scope at all.
9
+ SEEN = ObjectSpace::WeakMap.new
10
+
3
11
  module_function
4
12
 
5
13
  def track(result, receiver = nil)
6
14
  return result if receiver && result.equal?(receiver)
7
15
 
8
16
  each_image(result) do |img|
17
+ next if SEEN.key?(img)
18
+
19
+ SEEN[img] = true
9
20
  register(img)
10
21
  end
11
22
  result
@@ -50,7 +61,16 @@ module RmagickTidy
50
61
  raise OutOfScopeError,
51
62
  "Magick::Image #{img.inspect} was created outside of RmagickTidy.scope"
52
63
  when :warn
53
- warn "[rmagick_tidy] Magick::Image created outside of RmagickTidy.scope (#{img.class})"
64
+ report("[rmagick_tidy] Magick::Image created outside of RmagickTidy.scope (#{img.class})")
65
+ end
66
+ end
67
+
68
+ def report(message)
69
+ logger = RmagickTidy.configuration.logger
70
+ if logger
71
+ logger.warn(message)
72
+ else
73
+ warn message
54
74
  end
55
75
  end
56
76
  end
@@ -1,3 +1,3 @@
1
1
  module RmagickTidy
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rmagick_tidy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Junichiro Kasuya
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-21 00:00:00.000000000 Z
10
+ date: 2026-11-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rmagick
@@ -77,14 +77,14 @@ dependencies:
77
77
  requirements:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
- version: '0.22'
80
+ version: '1.0'
81
81
  type: :development
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
- version: '0.22'
87
+ version: '1.0'
88
88
  - !ruby/object:Gem::Dependency
89
89
  name: simplecov-lcov
90
90
  requirement: !ruby/object:Gem::Requirement
@@ -125,7 +125,7 @@ licenses:
125
125
  metadata:
126
126
  homepage_uri: https://github.com/jksy/rmagick_tidy
127
127
  source_code_uri: https://github.com/jksy/rmagick_tidy/tree/main
128
- changelog_uri: https://github.com/jksy/rmagick_tidy/blob/main/CHANGELOG.md
128
+ changelog_uri: https://github.com/jksy/rmagick_tidy/releases
129
129
  bug_tracker_uri: https://github.com/jksy/rmagick_tidy/issues
130
130
  rubygems_mfa_required: 'true'
131
131
  rdoc_options: []
@@ -135,7 +135,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - ">="
137
137
  - !ruby/object:Gem::Version
138
- version: 3.2.0
138
+ version: 3.3.0
139
139
  required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  requirements:
141
141
  - - ">="