magick-feature-flags 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a34f03026e8631bc2d29c0935cb98e472e7ed387fc4628edc357776423bfc2c5
4
- data.tar.gz: d87af21843efb22d5dba60d342f3beb5fd807eb42f1b2948c82ae8dc3d6b67a2
3
+ metadata.gz: 3df9cccb3e3478e71402fa478d4a70401d0150290fdca89a6a47df7fbd901794
4
+ data.tar.gz: 929990ca6220cebb2e9215a1993df11fa25a71dbf620029a176db1c8b50edf48
5
5
  SHA512:
6
- metadata.gz: '0296397245455dbecd448a2dab448b2209579f4c9c39a1e93b424e8f6982fe8a3f498140ac7af5f1127c70fc2cdaa3378194135afe2e55fe9254646a4c761e64'
7
- data.tar.gz: 43e1ce6dcf51815f83ccf4440845d415f8d351e6a6038c8f7bcd0a294dfc0def6531a04a118119d66e71a64d7cfcc1607273eda0c181dfdf951c9ee61d4cfae7
6
+ metadata.gz: 225fdbbed5820df1ca9cb7f0283172b57ab5a3c0a26639fc6d11cef9553f80cc35402753878469db7470ef90ac691431daf399cac1b1cba26d23395ce5776f8c
7
+ data.tar.gz: da03312e98129cc04bc0ba63dfc06e67e171406e35fa4e46f9ff73c494a0596ef4e1e65868bab0a0ad7f43f05bbf449a81d9ac1b25eae5a0d40e1ba6245f4e44
data/README.md CHANGED
@@ -888,6 +888,43 @@ end
888
888
  - `:inactive` - Feature is disabled for everyone
889
889
  - `:deprecated` - Feature is deprecated (can be enabled with `allow_deprecated: true` in context)
890
890
 
891
+ ## Graceful Shutdown
892
+
893
+ Magick starts a background Redis Pub/Sub subscriber thread for cross-process
894
+ cache invalidation and an asynchronous metrics processor. Both must be
895
+ stopped before the host process exits or Puma's graceful-stop will block on
896
+ the still-running `Redis#subscribe` call.
897
+
898
+ The Railtie registers an `at_exit` hook that calls `Magick.shutdown!`
899
+ automatically, so most Rails apps don't need to do anything. In long-running
900
+ non-Rails processes (rake tasks, CLI tools) call it explicitly:
901
+
902
+ ```ruby
903
+ Magick.shutdown! # default 5 second join timeout
904
+ Magick.shutdown!(timeout: 1) # more aggressive
905
+ ```
906
+
907
+ Fork-based deployments (Puma workers with `preload_app`, Unicorn) are handled
908
+ automatically: `config.to_prepare` calls `ensure_subscriber!` and
909
+ `ensure_async_processor!` on every prepare cycle, so children that inherit
910
+ stale parent threads start fresh. No action required from the host app.
911
+
912
+ ## Admin UI Security
913
+
914
+ The Admin UI is CSRF-protected out of the box (`protect_from_forgery with:
915
+ :exception`) and 404s on unknown feature IDs instead of auto-creating
916
+ features from user-controlled `params[:id]`.
917
+
918
+ Authentication is **opt-in** — if `Magick::AdminUI.config.require_role` is
919
+ left `nil` the UI is reachable by anyone who can hit its routes. Always set
920
+ it behind your app's auth:
921
+
922
+ ```ruby
923
+ Magick::AdminUI.configure do |c|
924
+ c.require_role = ->(controller) { controller.current_user&.admin? }
925
+ end
926
+ ```
927
+
891
928
  ## Testing
892
929
 
893
930
  Use the testing helpers in your RSpec tests:
@@ -3,6 +3,13 @@
3
3
  module Magick
4
4
  module AdminUI
5
5
  class FeaturesController < ActionController::Base
6
+ # Inheriting ActionController::Base does NOT bring in CSRF protection.
7
+ # Include RequestForgeryProtection + explicit protect_from_forgery so
8
+ # cross-site form submissions cannot toggle flags behind a logged-in
9
+ # admin's back.
10
+ include ::ActionController::RequestForgeryProtection
11
+ protect_from_forgery with: :exception
12
+
6
13
  # Include route helpers so views can use magick_admin_ui.* helpers
7
14
  include Magick::AdminUI::Engine.routes.url_helpers
8
15
  layout 'application'
@@ -120,6 +127,10 @@ module Magick
120
127
  def update_targeting
121
128
  # Handle targeting updates from form
122
129
  targeting_params = params[:targeting] || {}
130
+ unless hash_like?(targeting_params)
131
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
132
+ return
133
+ end
123
134
 
124
135
  # Ensure we're using the registered feature instance
125
136
  feature_name = @feature.name.to_s
@@ -266,13 +277,18 @@ module Magick
266
277
 
267
278
  redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
268
279
  rescue StandardError => e
269
- Rails.logger.error "Magick: Error updating targeting: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
270
- redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating targeting: #{e.message}"
280
+ Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
281
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
271
282
  end
272
283
 
273
284
  def update_variants
274
285
  variants_data = []
275
286
 
287
+ if params[:variants].present? && !hash_like?(params[:variants])
288
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid variants payload.'
289
+ return
290
+ end
291
+
276
292
  if params[:variants].present?
277
293
  params[:variants].each do |_index, variant_params|
278
294
  next if variant_params[:name].blank?
@@ -295,8 +311,8 @@ module Magick
295
311
 
296
312
  redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
297
313
  rescue StandardError => e
298
- Rails.logger.error "Magick: Error updating variants: #{e.message}" if defined?(Rails)
299
- redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating variants: #{e.message}"
314
+ Rails.logger.error "Magick: Error updating variants for #{@feature.name}: #{e.class}: #{e.message}" if defined?(Rails)
315
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
300
316
  end
301
317
 
302
318
  private
@@ -313,13 +329,25 @@ module Magick
313
329
  end
314
330
  end
315
331
 
332
+ # A hash-like payload is either a raw Hash or an
333
+ # ActionController::Parameters (both respond to :each with key/value).
334
+ # Rejecting anything else lets us give a 400-style redirect instead of
335
+ # a 500 with a NoMethodError stack trace.
336
+ def hash_like?(obj)
337
+ obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
338
+ end
339
+
316
340
  def set_feature
317
341
  feature_name = params[:id].to_s
318
- @feature = Magick.features[feature_name] || Magick[feature_name]
319
- redirect_to magick_admin_ui.features_path, alert: 'Feature not found' unless @feature
320
-
321
- # Ensure we're working with the registered feature instance to keep state in sync
322
- @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
342
+ # Do NOT fall back to Magick[feature_name] that would lazily create
343
+ # and persist a brand-new feature from user-controlled input, letting
344
+ # an attacker (or even a stray crawler) pollute Redis/AR with arbitrary
345
+ # keys. Look up only registered features; 404 otherwise.
346
+ @feature = Magick.features[feature_name]
347
+ return if @feature
348
+
349
+ redirect_to magick_admin_ui.features_path, alert: 'Feature not found'
350
+ nil
323
351
  end
324
352
  end
325
353
  end
@@ -3,6 +3,9 @@
3
3
  module Magick
4
4
  module AdminUI
5
5
  class StatsController < ActionController::Base
6
+ include ::ActionController::RequestForgeryProtection
7
+ protect_from_forgery with: :exception
8
+
6
9
  include Magick::AdminUI::Engine.routes.url_helpers
7
10
  layout 'application'
8
11
 
@@ -13,8 +16,13 @@ module Magick
13
16
  end
14
17
 
15
18
  def show
16
- @feature = Magick.features[params[:id].to_s] || Magick[params[:id]]
17
- @stats = Magick.feature_stats(params[:id].to_sym) || {} if @feature
19
+ feature_name = params[:id].to_s
20
+ @feature = Magick.features[feature_name]
21
+ unless @feature
22
+ head :not_found
23
+ return
24
+ end
25
+ @stats = Magick.feature_stats(feature_name.to_sym) || {}
18
26
  end
19
27
  end
20
28
  end
@@ -32,3 +32,21 @@ Magick.configure do
32
32
  # Enable deprecation warnings in logs
33
33
  warn_on_deprecated enabled: true
34
34
  end
35
+
36
+ # Admin UI configuration (optional).
37
+ # The Admin UI is CSRF-protected and 404s on unknown feature IDs, BUT
38
+ # authentication is opt-in — if you mount the engine without configuring
39
+ # `require_role`, the UI will be reachable by anyone who can hit its routes.
40
+ # Always wire it to your host app's auth layer:
41
+ #
42
+ # Magick::AdminUI.configure do |c|
43
+ # c.require_role = ->(controller) { controller.current_user&.admin? }
44
+ # c.available_roles = %w[admin manager]
45
+ # c.available_tags = -> { Tag.pluck(:name) }
46
+ # end
47
+
48
+ # Graceful shutdown is handled automatically in Rails via an at_exit hook
49
+ # wired by the Railtie. In long-running non-Rails processes (rake tasks,
50
+ # CLI tools), call `Magick.shutdown!` explicitly before exit so the
51
+ # Redis Pub/Sub subscriber and async metrics thread terminate cleanly.
52
+
@@ -33,9 +33,13 @@ module Magick
33
33
  {}
34
34
  end
35
35
 
36
- # Bulk set multiple keys for a feature in one call (override for efficiency)
37
- def set_all_data(_feature_name, _data_hash)
38
- # Default: no-op, subclasses override
36
+ # Bulk set multiple keys for a feature in one call.
37
+ # Subclasses MUST implement this — a no-op default silently drops
38
+ # bulk writes, which is why this used to cause hard-to-diagnose lost
39
+ # updates for custom adapters (audit P2-Co6).
40
+ def set_all_data(feature_name, data_hash)
41
+ raise NotImplementedError,
42
+ "#{self.class} must implement #set_all_data (feature=#{feature_name}, keys=#{data_hash.keys.inspect})"
39
43
  end
40
44
  end
41
45
  end
@@ -31,6 +31,7 @@ module Magick
31
31
 
32
32
  def set(feature_name, key, value)
33
33
  mutex.synchronize do
34
+ cleanup_expired_if_needed
34
35
  feature_name_str = feature_name.to_s
35
36
  store[feature_name_str] ||= {}
36
37
  store[feature_name_str][key.to_s] = serialize_value(value)
@@ -91,6 +92,7 @@ module Magick
91
92
  # Bulk set all data for a feature (used by preloading)
92
93
  def set_all_data(feature_name, data_hash)
93
94
  mutex.synchronize do
95
+ cleanup_expired_if_needed
94
96
  feature_name_str = feature_name.to_s
95
97
  store[feature_name_str] ||= {}
96
98
  data_hash.each do |key, value|
@@ -103,13 +103,24 @@ module Magick
103
103
 
104
104
  attr_reader :redis, :namespace
105
105
 
106
- # Use SCAN instead of KEYS to avoid blocking Redis
106
+ # Use SCAN instead of KEYS to avoid blocking Redis.
107
+ # A mid-scan timeout would otherwise lose the cursor; retry once with
108
+ # exponential backoff before surfacing the error to the caller.
107
109
  def scan_keys
108
110
  pattern = "#{namespace}:*"
109
111
  keys = []
110
112
  cursor = '0'
113
+ retries = 0
111
114
  loop do
112
- cursor, batch = redis.scan(cursor, match: pattern, count: 100)
115
+ begin
116
+ cursor, batch = redis.scan(cursor, match: pattern, count: 100)
117
+ rescue StandardError => e
118
+ raise if retries >= 1
119
+
120
+ retries += 1
121
+ sleep(0.05 * retries)
122
+ retry
123
+ end
113
124
  keys.concat(batch)
114
125
  break if cursor == '0'
115
126
  end
@@ -7,6 +7,12 @@ module Magick
7
7
 
8
8
  LOCAL_WRITE_TTL = 2.0 # seconds to ignore self-invalidation after a local write
9
9
 
10
+ # Accept only conservative feature identifiers coming off the wire.
11
+ # Anything outside this alphabet (newlines, spaces, Unicode punctuation,
12
+ # 200-char garbage) is a sign of a malformed or malicious publisher and
13
+ # must not be fed back into Magick.features[...] / feature.reload.
14
+ FEATURE_NAME_PATTERN = /\A[a-zA-Z0-9_\-.:]{1,120}\z/.freeze
15
+
10
16
  def initialize(memory_adapter, redis_adapter = nil, active_record_adapter: nil, circuit_breaker: nil,
11
17
  async: false, primary: nil)
12
18
  @memory_adapter = memory_adapter
@@ -21,11 +27,54 @@ module Magick
21
27
  @last_reload_times = {} # Track last reload time per feature for debouncing
22
28
  @local_writes = {} # Track recent local writes to skip self-invalidation
23
29
  @reload_mutex = Mutex.new
30
+ @stopping = false
31
+ @shutdown_mutex = Mutex.new
32
+ @owner_pid = Process.pid
24
33
  # Only start Pub/Sub subscriber if Redis is available
25
34
  # In memory-only mode, each process has isolated cache (no cross-process invalidation)
26
35
  start_cache_invalidation_subscriber if redis_adapter
27
36
  end
28
37
 
38
+ # Restart the Pub/Sub subscriber after a fork. The subscriber thread is
39
+ # not carried into child processes, so a worker inheriting a stale
40
+ # reference must re-create its own subscription. Safe to call on every
41
+ # request; it only does work when Process.pid changes.
42
+ def ensure_subscriber!
43
+ return if @owner_pid == Process.pid
44
+
45
+ @shutdown_mutex.synchronize do
46
+ return if @owner_pid == Process.pid
47
+
48
+ @subscriber_thread = nil
49
+ @subscriber = nil
50
+ @owner_pid = Process.pid
51
+ @stopping = false
52
+ end
53
+
54
+ start_cache_invalidation_subscriber if redis_adapter
55
+ end
56
+
57
+ # Gracefully terminate the Pub/Sub subscriber thread and its Redis connection.
58
+ # Without this, Ruby/Puma shutdown waits on the blocking `subscribe` call.
59
+ def shutdown(timeout: 5)
60
+ @shutdown_mutex.synchronize do
61
+ return if @stopping
62
+
63
+ @stopping = true
64
+ end
65
+
66
+ close_subscriber_connection(@subscriber)
67
+ terminate_subscriber_thread(@subscriber_thread, timeout)
68
+
69
+ @subscriber = nil
70
+ @subscriber_thread = nil
71
+ true
72
+ end
73
+
74
+ def stopping?
75
+ @stopping == true
76
+ end
77
+
29
78
  def get(feature_name, key)
30
79
  # Try memory first (fastest) - no Redis calls needed thanks to Pub/Sub invalidation
31
80
  value = memory_adapter.get(feature_name, key) if memory_adapter
@@ -76,11 +125,7 @@ module Magick
76
125
  end
77
126
 
78
127
  if @async && defined?(Thread)
79
- Thread.new do
80
- update_redis.call
81
- # Publish AFTER Redis write so other processes read fresh data
82
- publish_cache_invalidation(feature_name)
83
- end
128
+ spawn_async_write(feature_name, update_redis)
84
129
  else
85
130
  update_redis.call
86
131
  publish_cache_invalidation(feature_name)
@@ -226,11 +271,7 @@ module Magick
226
271
  end
227
272
 
228
273
  if @async && defined?(Thread)
229
- Thread.new do
230
- update_redis.call
231
- # Publish AFTER Redis write so other processes read fresh data
232
- publish_cache_invalidation(feature_name)
233
- end
274
+ spawn_async_write(feature_name, update_redis)
234
275
  else
235
276
  update_redis.call
236
277
  publish_cache_invalidation(feature_name)
@@ -288,11 +329,57 @@ module Magick
288
329
 
289
330
  attr_reader :memory_adapter, :redis_adapter, :active_record_adapter, :circuit_breaker
290
331
 
332
+ # Signal the subscribe loop to return, then close the connection so any
333
+ # retry/reconnect attempt fails fast instead of sleeping for 5s.
334
+ def close_subscriber_connection(subscriber)
335
+ return unless subscriber
336
+
337
+ begin
338
+ subscriber.unsubscribe(CACHE_INVALIDATION_CHANNEL)
339
+ rescue StandardError
340
+ # connection may already be dead; fall through to close/kill
341
+ end
342
+
343
+ begin
344
+ subscriber.close
345
+ rescue StandardError
346
+ # ignore: best-effort close
347
+ end
348
+ end
349
+
350
+ def terminate_subscriber_thread(thread, timeout)
351
+ return unless thread
352
+ return if thread.join(timeout)
353
+
354
+ thread.kill
355
+ thread.join(1) # give it a moment to actually unwind
356
+ end
357
+
358
+ # Fire-and-forget async Redis write. Wrapped so that a failure in the
359
+ # update or publish step is logged rather than silently killing the
360
+ # thread — Thread#abort_on_exception is false, which otherwise swallows
361
+ # the error completely.
362
+ def spawn_async_write(feature_name, update_redis)
363
+ thread = Thread.new do
364
+ update_redis.call
365
+ publish_cache_invalidation(feature_name)
366
+ rescue StandardError => e
367
+ warn "Magick: async Redis write failed for '#{Magick::LogSafe.sanitize(feature_name)}': #{e.class}: #{Magick::LogSafe.sanitize(e.message)}"
368
+ end
369
+ thread.name = "magick-async-write-#{feature_name}" if thread.respond_to?(:name=)
370
+ thread.abort_on_exception = false
371
+ thread
372
+ end
373
+
291
374
  # Record that this process just wrote a feature, so the subscriber
292
375
  # ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
293
376
  def record_local_write(feature_name)
294
377
  @reload_mutex.synchronize do
295
378
  @local_writes[feature_name.to_s] = Time.now.to_f
379
+ # Also sweep stale tracking entries on the write path — a write-heavy
380
+ # process that rarely reads would otherwise never trigger cleanup,
381
+ # letting @local_writes and @last_reload_times grow unboundedly.
382
+ cleanup_stale_tracking_entries
296
383
  end
297
384
  end
298
385
 
@@ -357,6 +444,16 @@ module Magick
357
444
  on.message do |_channel, feature_name|
358
445
  feature_name_str = feature_name.to_s
359
446
 
447
+ # Reject malformed payloads before doing anything with them.
448
+ # A shared Redis DB is not a trust boundary — reject anything
449
+ # that isn't a plausible feature identifier.
450
+ unless FEATURE_NAME_PATTERN.match?(feature_name_str)
451
+ if defined?(Rails) && Rails.env.development?
452
+ warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)"
453
+ end
454
+ next
455
+ end
456
+
360
457
  # Skip self-invalidation: if this process just wrote this feature,
361
458
  # memory already has the correct value. Reloading from Redis would
362
459
  # revert it to stale data (especially with async writes).
@@ -408,7 +505,7 @@ module Magick
408
505
  end
409
506
 
410
507
  if defined?(Rails) && Rails.env.development?
411
- warn "Magick: Error processing cache invalidation for '#{feature_name}': #{e.message}"
508
+ warn "Magick: Error processing cache invalidation for '#{Magick::LogSafe.sanitize(feature_name)}': #{Magick::LogSafe.sanitize(e.message)}"
412
509
  end
413
510
  end
414
511
  end
@@ -421,9 +518,13 @@ module Magick
421
518
  (defined?(Rails) && Rails.env.test?)
422
519
  return if is_rspec_error
423
520
 
521
+ # Stop cleanly during app shutdown instead of sleeping + retrying,
522
+ # which would keep the process alive and delay termination.
523
+ return if @stopping
524
+
424
525
  warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
425
526
  sleep 5
426
- retry
527
+ retry unless @stopping
427
528
  end
428
529
  @subscriber_thread.abort_on_exception = false
429
530
  end
@@ -4,28 +4,33 @@ module Magick
4
4
  module AdminUI
5
5
  module Helpers
6
6
  def self.feature_status_badge(status)
7
- case status.to_sym
8
- when :active
9
- '<span class="badge badge-success">Active</span>'
10
- when :deprecated
11
- '<span class="badge badge-warning">Deprecated</span>'
12
- when :inactive
13
- '<span class="badge badge-danger">Inactive</span>'
7
+ klass = case status.to_sym
8
+ when :active then 'badge badge-success'
9
+ when :deprecated then 'badge badge-warning'
10
+ when :inactive then 'badge badge-danger'
11
+ else 'badge'
12
+ end
13
+ label = case status.to_sym
14
+ when :active then 'Active'
15
+ when :deprecated then 'Deprecated'
16
+ when :inactive then 'Inactive'
17
+ else 'Unknown'
18
+ end
19
+
20
+ if defined?(ActionController::Base)
21
+ ActionController::Base.helpers.content_tag(:span, label, class: klass)
14
22
  else
15
- '<span class="badge">Unknown</span>'
23
+ # Fallback when Rails is not present. Label is a whitelisted literal.
24
+ "<span class=\"#{klass}\">#{label}</span>"
16
25
  end
17
26
  end
18
27
 
19
28
  def self.feature_type_label(type)
20
29
  case type.to_sym
21
- when :boolean
22
- 'Boolean'
23
- when :string
24
- 'String'
25
- when :number
26
- 'Number'
27
- else
28
- type.to_s.capitalize
30
+ when :boolean then 'Boolean'
31
+ when :string then 'String'
32
+ when :number then 'Number'
33
+ else type.to_s.capitalize
29
34
  end
30
35
  end
31
36
  end
@@ -26,20 +26,27 @@ module Magick
26
26
  end
27
27
  end
28
28
 
29
- def initialize(adapter = nil)
29
+ DEFAULT_MAX_ENTRIES = 10_000
30
+
31
+ def initialize(adapter = nil, max_entries: DEFAULT_MAX_ENTRIES)
30
32
  @adapter = adapter || default_adapter
31
33
  @logs = []
34
+ @max_entries = max_entries.to_i.positive? ? max_entries.to_i : DEFAULT_MAX_ENTRIES
32
35
  @mutex = Mutex.new
33
36
  end
34
37
 
38
+ attr_reader :max_entries
39
+
35
40
  def log(feature_name, action, user_id: nil, changes: {}, metadata: {})
36
41
  entry = Entry.new(feature_name, action, user_id: user_id, changes: changes, metadata: metadata)
37
42
  @mutex.synchronize do
38
43
  @logs << entry
44
+ # Cap in-memory ring; older entries fall out once we cross the limit.
45
+ # This keeps long-running processes from growing @logs unboundedly.
46
+ @logs.shift while @logs.size > @max_entries
39
47
  @adapter.append(entry) if @adapter.respond_to?(:append)
40
48
  end
41
49
 
42
- # Rails 8+ event
43
50
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
44
51
  Magick::Rails::Events.audit_logged(feature_name, action: action, user_id: user_id, changes: changes, **metadata)
45
52
  end
@@ -48,9 +55,13 @@ module Magick
48
55
  end
49
56
 
50
57
  def entries(feature_name: nil, limit: 100)
51
- result = @logs
52
- result = result.select { |e| e.feature_name == feature_name.to_s } if feature_name
53
- result.last(limit)
58
+ snapshot = @mutex.synchronize { @logs.dup }
59
+ snapshot = snapshot.select { |e| e.feature_name == feature_name.to_s } if feature_name
60
+ snapshot.last(limit)
61
+ end
62
+
63
+ def size
64
+ @mutex.synchronize { @logs.size }
54
65
  end
55
66
 
56
67
  private
data/lib/magick/config.rb CHANGED
@@ -317,9 +317,29 @@ module Magick
317
317
  config
318
318
  end
319
319
 
320
+ # Load a Magick configuration DSL file by path.
321
+ #
322
+ # SECURITY: This method evaluates the file's contents as Ruby via
323
+ # instance_eval. Never pass a path derived from HTTP input, ENV
324
+ # variables, build artifacts, or any other untrusted source — doing so
325
+ # is remote code execution. Callers must guarantee the path points at a
326
+ # file that lives inside the project tree (typical use:
327
+ # Rails.root.join('config/features.rb')).
328
+ #
329
+ # The path is resolved with File.realpath and must be inside the
330
+ # current working directory. An explicit opt-in env var
331
+ # (MAGICK_ALLOW_CONFIG_EVAL=1) is required to load paths outside CWD.
320
332
  def self.load_from_file(file_path)
333
+ resolved = File.realpath(file_path)
334
+ allow_outside_cwd = ENV['MAGICK_ALLOW_CONFIG_EVAL'] == '1'
335
+ unless allow_outside_cwd || resolved.start_with?(Dir.pwd)
336
+ raise SecurityError,
337
+ "Refusing to load Magick config from outside the project tree: #{resolved}. " \
338
+ 'Set MAGICK_ALLOW_CONFIG_EVAL=1 to override (only if you trust the file).'
339
+ end
340
+
321
341
  config = Config.new
322
- config.instance_eval(File.read(file_path), file_path)
342
+ config.instance_eval(File.read(resolved), resolved)
323
343
  config.apply!
324
344
  config
325
345
  end
@@ -4,12 +4,23 @@ require 'json'
4
4
 
5
5
  module Magick
6
6
  class ExportImport
7
+ # Hard cap on the number of features accepted by a single import call.
8
+ # Guards against DoS via an oversized payload and (combined with Admin UI
9
+ # auth) stops an attacker from using import as a flag-replacement
10
+ # primitive. Override with the MAGICK_MAX_IMPORT_FEATURES env var.
11
+ DEFAULT_MAX_IMPORT_FEATURES = 10_000
12
+
13
+ class ImportError < StandardError; end
14
+
15
+ def self.max_import_features
16
+ ENV.fetch('MAGICK_MAX_IMPORT_FEATURES', DEFAULT_MAX_IMPORT_FEATURES).to_i
17
+ end
18
+
7
19
  def self.export(features_hash)
8
20
  result = features_hash.map do |_name, feature|
9
21
  feature.to_h
10
22
  end
11
23
 
12
- # Rails 8+ event
13
24
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
14
25
  Magick::Rails::Events.exported(format: :hash, feature_count: result.length)
15
26
  end
@@ -20,7 +31,6 @@ module Magick
20
31
  def self.export_json(features_hash)
21
32
  result = JSON.pretty_generate(export(features_hash))
22
33
 
23
- # Rails 8+ event
24
34
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
25
35
  Magick::Rails::Events.exported(format: :json, feature_count: features_hash.length)
26
36
  end
@@ -31,54 +41,135 @@ module Magick
31
41
  def self.import(data, adapter_registry)
32
42
  features = {}
33
43
  data = JSON.parse(data) if data.is_a?(String)
44
+ list = Array(data)
34
45
 
35
- Array(data).each do |feature_data|
36
- name = feature_data['name'] || feature_data[:name]
37
- next unless name
46
+ cap = max_import_features
47
+ if list.size > cap
48
+ raise ImportError,
49
+ "Magick.import: refused to import #{list.size} features; limit is #{cap}. " \
50
+ 'Set MAGICK_MAX_IMPORT_FEATURES to override.'
51
+ end
38
52
 
39
- feature = Feature.new(
40
- name,
41
- adapter_registry,
42
- type: (feature_data['type'] || feature_data[:type] || :boolean).to_sym,
43
- status: (feature_data['status'] || feature_data[:status] || :active).to_sym,
44
- default_value: feature_data['default_value'] || feature_data[:default_value],
45
- description: feature_data['description'] || feature_data[:description]
46
- )
47
-
48
- if feature_data['value'] || feature_data[:value]
49
- feature.set_value(feature_data['value'] || feature_data[:value])
53
+ list.each do |feature_data|
54
+ unless feature_data.is_a?(Hash)
55
+ raise ImportError, "Magick.import: each feature payload must be a Hash, got #{feature_data.class}"
50
56
  end
51
57
 
52
- # Import targeting
53
- if feature_data['targeting'] || feature_data[:targeting]
54
- targeting = feature_data['targeting'] || feature_data[:targeting]
55
- targeting.each do |type, values|
56
- Array(values).each do |value|
57
- case type.to_sym
58
- when :user
59
- feature.enable_for_user(value)
60
- when :group
61
- feature.enable_for_group(value)
62
- when :role
63
- feature.enable_for_role(value)
64
- when :percentage_users
65
- feature.enable_percentage_of_users(value)
66
- when :percentage_requests
67
- feature.enable_percentage_of_requests(value)
68
- end
69
- end
70
- end
71
- end
58
+ name = fetch(feature_data, :name)
59
+ next unless name
60
+
61
+ feature = build_feature(name, feature_data, adapter_registry)
62
+ apply_value(feature, feature_data)
63
+ apply_targeting(feature, fetch(feature_data, :targeting) || {})
64
+ apply_variants(feature, fetch(feature_data, :variants) || [])
65
+ apply_dependencies(feature, fetch(feature_data, :dependencies) || [])
72
66
 
73
67
  features[name.to_s] = feature
74
68
  end
75
69
 
76
- # Rails 8+ event
77
70
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
78
71
  Magick::Rails::Events.imported(format: :json, feature_count: features.length)
79
72
  end
80
73
 
81
74
  features
82
75
  end
76
+
77
+ def self.fetch(hash, key)
78
+ # Must not use `||` — falsy legitimate values (false, 0, "") would
79
+ # silently fall through to the string-key lookup (and then to nil).
80
+ return hash[key] if hash.key?(key)
81
+
82
+ string_key = key.to_s
83
+ return hash[string_key] if hash.key?(string_key)
84
+
85
+ nil
86
+ end
87
+
88
+ def self.build_feature(name, feature_data, adapter_registry)
89
+ Feature.new(
90
+ name,
91
+ adapter_registry,
92
+ type: (fetch(feature_data, :type) || :boolean).to_sym,
93
+ status: (fetch(feature_data, :status) || :active).to_sym,
94
+ default_value: fetch(feature_data, :default_value),
95
+ description: fetch(feature_data, :description),
96
+ display_name: fetch(feature_data, :display_name),
97
+ group: fetch(feature_data, :group)
98
+ )
99
+ end
100
+
101
+ def self.apply_value(feature, feature_data)
102
+ value = fetch(feature_data, :value)
103
+ feature.set_value(value) if !value.nil? && !(value.is_a?(String) && value.empty?)
104
+ end
105
+
106
+ # rubocop:disable Metrics/MethodLength
107
+ # rubocop:disable Metrics/CyclomaticComplexity
108
+ def self.apply_targeting(feature, targeting)
109
+ targeting.each do |type, values|
110
+ case type.to_sym
111
+ when :user, :users
112
+ Array(values).each { |v| feature.enable_for_user(v) }
113
+ when :excluded_users
114
+ Array(values).each { |v| feature.exclude_user(v) }
115
+ when :group, :groups
116
+ Array(values).each { |v| feature.enable_for_group(v) }
117
+ when :excluded_groups
118
+ Array(values).each { |v| feature.exclude_group(v) }
119
+ when :role, :roles
120
+ Array(values).each { |v| feature.enable_for_role(v) }
121
+ when :excluded_roles
122
+ Array(values).each { |v| feature.exclude_role(v) }
123
+ when :tag, :tags
124
+ Array(values).each { |v| feature.enable_for_tag(v) }
125
+ when :excluded_tags
126
+ Array(values).each { |v| feature.exclude_tag(v) }
127
+ when :ip_address, :ip_addresses
128
+ feature.enable_for_ip_addresses(Array(values))
129
+ when :excluded_ip_addresses
130
+ feature.exclude_ip_addresses(Array(values))
131
+ when :percentage_users
132
+ feature.enable_percentage_of_users(values)
133
+ when :percentage_requests
134
+ feature.enable_percentage_of_requests(values)
135
+ when :date_range
136
+ range = values.is_a?(Hash) ? values.transform_keys(&:to_sym) : values
137
+ feature.enable_for_date_range(range[:start], range[:end]) if range.is_a?(Hash) && range[:start] && range[:end]
138
+ when :custom_attributes
139
+ apply_custom_attributes(feature, values)
140
+ end
141
+ end
142
+ end
143
+ # rubocop:enable Metrics/MethodLength
144
+ # rubocop:enable Metrics/CyclomaticComplexity
145
+
146
+ def self.apply_custom_attributes(feature, values)
147
+ return unless values.is_a?(Hash)
148
+
149
+ values.each do |attr, rule|
150
+ rule_h = rule.is_a?(Hash) ? rule.transform_keys(&:to_sym) : {}
151
+ next unless rule_h[:values]
152
+
153
+ feature.enable_for_custom_attribute(attr, rule_h[:values], operator: (rule_h[:operator] || :equals).to_sym)
154
+ end
155
+ end
156
+
157
+ def self.apply_variants(feature, variants)
158
+ return unless feature.respond_to?(:add_variant)
159
+
160
+ Array(variants).each do |v|
161
+ h = v.is_a?(Hash) ? v.transform_keys(&:to_sym) : {}
162
+ next unless h[:name]
163
+
164
+ feature.add_variant(h[:name], weight: h[:weight], value: h[:value])
165
+ end
166
+ end
167
+
168
+ def self.apply_dependencies(feature, deps)
169
+ list = Array(deps).map(&:to_s)
170
+ return if list.empty?
171
+
172
+ feature.instance_variable_set(:@dependencies, list)
173
+ end
83
174
  end
84
175
  end
@@ -85,7 +85,7 @@ module Magick
85
85
  perf_metrics.record(name, 'enabled?', duration, success: false)
86
86
  end
87
87
  # Return false on any error (fail-safe)
88
- warn "Magick: Error checking feature '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
88
+ warn "Magick: Error checking feature '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
89
89
  false
90
90
  end
91
91
 
@@ -152,7 +152,7 @@ module Magick
152
152
  end
153
153
  rescue StandardError => e
154
154
  # Return false on any error (fail-safe)
155
- warn "Magick: Error in check_enabled for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
155
+ warn "Magick: Error in check_enabled for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
156
156
  false
157
157
  end
158
158
 
@@ -223,7 +223,7 @@ module Magick
223
223
  end
224
224
  rescue StandardError => e
225
225
  # Return default value on error (fail-safe)
226
- warn "Magick: Error in get_value for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
226
+ warn "Magick: Error in get_value for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
227
227
  default_value
228
228
  end
229
229
 
@@ -310,7 +310,15 @@ module Magick
310
310
  end
311
311
 
312
312
  def exclude_ip_addresses(ip_addresses)
313
- enable_targeting(:excluded_ip_addresses, Array(ip_addresses))
313
+ # excluded_ip_addresses is stored as a flat Array of strings; bypass the
314
+ # generic enable_targeting path whose "array type" branch stringifies the
315
+ # incoming array into a single element.
316
+ @targeting[:excluded_ip_addresses] ||= []
317
+ Array(ip_addresses).each do |ip|
318
+ str = ip.to_s
319
+ @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
320
+ end
321
+ save_targeting
314
322
  true
315
323
  end
316
324
 
@@ -370,7 +378,15 @@ module Magick
370
378
  end
371
379
 
372
380
  def enable_for_ip_addresses(ip_addresses)
373
- enable_targeting(:ip_address, Array(ip_addresses))
381
+ # ip_address is stored as a flat Array of strings; bypass the generic
382
+ # enable_targeting path whose "array type" branch stringifies the
383
+ # incoming array into a single '["x.y.z"]' entry.
384
+ @targeting[:ip_address] ||= []
385
+ Array(ip_addresses).each do |ip|
386
+ str = ip.to_s
387
+ @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
388
+ end
389
+ save_targeting
374
390
  true
375
391
  end
376
392
 
@@ -645,15 +661,31 @@ module Magick
645
661
  {
646
662
  name: name,
647
663
  display_name: display_name,
664
+ group: group,
648
665
  type: type,
649
666
  status: status,
650
667
  value: stored_value,
651
668
  default_value: default_value,
652
669
  description: description,
653
- targeting: targeting
670
+ targeting: targeting,
671
+ dependencies: (@dependencies || []).dup,
672
+ variants: variants_for_export
654
673
  }
655
674
  end
656
675
 
676
+ def variants_for_export
677
+ return [] unless defined?(Magick::FeatureVariant)
678
+
679
+ raw = @variants || []
680
+ raw.map do |v|
681
+ if v.is_a?(Magick::FeatureVariant)
682
+ { name: v.name, weight: v.weight, value: v.value }
683
+ else
684
+ v
685
+ end
686
+ end
687
+ end
688
+
657
689
  def save_targeting
658
690
  # Save targeting to adapter (this updates memory synchronously, then Redis/AR)
659
691
  # The set method already publishes cache invalidation to other processes via Pub/Sub
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ # Sanitize strings before they go into logs / warnings.
5
+ # Two concerns:
6
+ # 1) Newlines in a user-influenced string (feature name, exception
7
+ # message) let an attacker forge log entries ("log injection").
8
+ # 2) A long payload can flood a log pipeline.
9
+ # `LogSafe.sanitize` returns a single line at most 256 chars, control
10
+ # characters replaced with spaces.
11
+ module LogSafe
12
+ MAX_LEN = 256
13
+ CONTROL_CHARS = /[\r\n\t\e\u0000-\u001f\u007f]/.freeze
14
+
15
+ def self.sanitize(value, max: MAX_LEN)
16
+ str = value.to_s.dup
17
+ str.gsub!(CONTROL_CHARS, ' ')
18
+ str = str[0, max] if str.length > max
19
+ str
20
+ end
21
+ end
22
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Magick
4
4
  class PerformanceMetrics
5
+ METRICS_RING_CAP = 1_000
6
+
5
7
  class Metric
6
8
  attr_reader :feature_name, :operation, :duration, :timestamp, :success
7
9
 
@@ -45,7 +47,26 @@ module Magick
45
47
  @async_queue = Queue.new
46
48
  @async_thread = nil
47
49
  @async_enabled = true # Enable async by default for performance
50
+ @owner_pid = Process.pid
51
+ start_async_processor
52
+ end
53
+
54
+ # Restart the async processor after a fork. Child processes inherit a dead
55
+ # thread reference + a queue that was populated in the parent; both must
56
+ # be recreated. The inherited thread (if alive in the parent's address
57
+ # space at fork time) is killed so it cannot keep polling a detached queue.
58
+ def ensure_async_processor!
59
+ return if @owner_pid == Process.pid
60
+
61
+ stale_thread = @async_thread
62
+ stale_queue = @async_queue
63
+ @async_queue = Queue.new
64
+ @async_thread = nil
65
+ @owner_pid = Process.pid
48
66
  start_async_processor
67
+
68
+ stale_queue&.close if stale_queue.respond_to?(:close)
69
+ stale_thread&.kill
49
70
  end
50
71
 
51
72
  # Public accessor for redis_enabled
@@ -74,8 +95,9 @@ module Magick
74
95
  pending_count = nil
75
96
  total_pending = nil
76
97
  @mutex.synchronize do
77
- # Only create Metric object if we're keeping metrics in memory
78
- if @metrics.length < 1000
98
+ # Pre-insert cap: construct + append only when there is room.
99
+ # Keeps @metrics bounded at METRICS_RING_CAP under any load.
100
+ if @metrics.length < METRICS_RING_CAP
79
101
  metric = Metric.new(feature_name_str, operation_str, duration, success: success)
80
102
  @metrics << metric
81
103
  end
@@ -83,8 +105,6 @@ module Magick
83
105
  @pending_updates[feature_name_str] += 1
84
106
  pending_count = @pending_updates[feature_name_str]
85
107
  total_pending = @pending_updates.values.sum
86
- # Keep only last 1000 metrics (as a safety limit)
87
- @metrics.shift if @metrics.length > 1000
88
108
  end
89
109
 
90
110
  # Rails 8+ event for usage tracking (cached check)
@@ -119,12 +119,29 @@ if defined?(Rails)
119
119
  config.to_prepare do
120
120
  RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
121
121
 
122
+ # Restart background threads after Puma fork. ensure_*! is a no-op
123
+ # when Process.pid matches the owner pid, so it is cheap to call.
124
+ registry = Magick.adapter_registry
125
+ registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
126
+ Magick.performance_metrics&.ensure_async_processor!
127
+
122
128
  # Final check: ensure Redis tracking is enabled (runs on every request in development)
123
129
  # This is the absolute last chance to enable it
124
130
  if Magick.performance_metrics && Magick.adapter_registry.is_a?(Adapters::Registry) && Magick.adapter_registry.redis_available? && !Magick.performance_metrics.redis_enabled
125
131
  Magick.performance_metrics.enable_redis_tracking(enable: true)
126
132
  end
127
133
  end
134
+
135
+ # Terminate the Pub/Sub subscriber + async metrics thread on process exit.
136
+ # Without this, Ruby waits on the blocking `Redis#subscribe` call inside
137
+ # the subscriber thread and Puma/Rails shutdown stalls.
138
+ initializer 'magick.shutdown_hook' do
139
+ at_exit do
140
+ Magick.shutdown!
141
+ rescue StandardError
142
+ # Best-effort: never raise from an at_exit handler.
143
+ end
144
+ end
128
145
  end
129
146
  end
130
147
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.3.1'
4
+ VERSION = '1.4.1'
5
5
  end
@@ -30,19 +30,21 @@ module Magick
30
30
 
31
31
  def save_version(feature_name, version: nil, created_by: nil)
32
32
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
33
- version ||= next_version(feature_name)
34
- version_data = Version.new(version, feature.to_h, created_by: created_by)
35
-
36
- @mutex.synchronize do
37
- @versions[feature_name.to_s] ||= []
38
- @versions[feature_name.to_s] << version_data
39
- # Store in adapter
40
- @adapter_registry.set(feature_name, "version_#{version}", version_data.to_h)
33
+ feature_name_str = feature_name.to_s
34
+
35
+ # Compute version + append under the same mutex so two concurrent
36
+ # save_version calls on the same feature can't both assign version N.
37
+ version_data = @mutex.synchronize do
38
+ list = (@versions[feature_name_str] ||= [])
39
+ resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
40
+ entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
41
+ list << entry
42
+ @adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
43
+ entry
41
44
  end
42
45
 
43
- # Rails 8+ event
44
46
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
45
- Magick::Rails::Events.version_saved(feature_name, version: version, created_by: created_by)
47
+ Magick::Rails::Events.version_saved(feature_name, version: version_data.version, created_by: created_by)
46
48
  end
47
49
 
48
50
  version_data
@@ -83,14 +85,16 @@ module Magick
83
85
  end
84
86
 
85
87
  def get_versions(feature_name)
86
- @versions[feature_name.to_s] || []
88
+ @mutex.synchronize { (@versions[feature_name.to_s] || []).dup }
87
89
  end
88
90
 
89
91
  private
90
92
 
91
93
  def next_version(feature_name)
92
- versions = get_versions(feature_name)
93
- versions.empty? ? 1 : versions.last.version + 1
94
+ @mutex.synchronize do
95
+ list = @versions[feature_name.to_s] || []
96
+ list.empty? ? 1 : list.last.version + 1
97
+ end
94
98
  end
95
99
  end
96
100
  end
data/lib/magick.rb CHANGED
@@ -18,8 +18,13 @@ require_relative 'magick/targeting/group'
18
18
  require_relative 'magick/targeting/role'
19
19
  require_relative 'magick/targeting/percentage'
20
20
  require_relative 'magick/targeting/request_percentage'
21
+ require_relative 'magick/targeting/date_range'
22
+ require_relative 'magick/targeting/ip_address'
23
+ require_relative 'magick/targeting/custom_attribute'
24
+ require_relative 'magick/targeting/complex'
21
25
  require_relative 'magick/errors'
22
26
 
27
+ require_relative 'magick/log_safe'
23
28
  require_relative 'magick/audit_log'
24
29
  require_relative 'magick/performance_metrics'
25
30
  require_relative 'magick/export_import'
@@ -114,13 +119,22 @@ module Magick
114
119
  features[feature_name.to_s] || Feature.new(feature_name, adapter_registry || default_adapter_registry)
115
120
  end
116
121
 
122
+ # Mutex that guards writes to @features. Reads are lock-free by design:
123
+ # register_feature swaps the @features reference to a NEW hash via
124
+ # copy-on-write, so a concurrent iterator keeps its own snapshot and
125
+ # never sees "can't add a new key into hash during iteration".
126
+ FEATURES_MUTEX = Mutex.new
127
+ private_constant :FEATURES_MUTEX
128
+
117
129
  def features
118
130
  @features ||= {}
119
131
  end
120
132
 
121
133
  def register_feature(name, **options)
122
134
  feature = Feature.new(name, adapter_registry || default_adapter_registry, **options)
123
- features[name.to_s] = feature
135
+ FEATURES_MUTEX.synchronize do
136
+ @features = (@features || {}).merge(name.to_s => feature)
137
+ end
124
138
  feature
125
139
  end
126
140
 
@@ -193,7 +207,9 @@ module Magick
193
207
 
194
208
  def import(data, format: :json)
195
209
  imported = ExportImport.import(data, adapter_registry || default_adapter_registry)
196
- imported.each { |name, feature| features[name] = feature }
210
+ FEATURES_MUTEX.synchronize do
211
+ @features = (@features || {}).merge(imported)
212
+ end
197
213
  imported
198
214
  end
199
215
 
@@ -259,12 +275,24 @@ module Magick
259
275
  end
260
276
 
261
277
  def reset!
278
+ safely_shutdown(@adapter_registry) { |r| r.shutdown }
279
+ safely_shutdown(@default_adapter_registry) { |r| r.shutdown }
262
280
  @features = {}
263
281
  @adapter_registry = nil
264
282
  @default_adapter = nil
283
+ @default_adapter_registry = nil
265
284
  @performance_metrics&.clear!
266
285
  end
267
286
 
287
+ # Gracefully terminate background threads (Redis Pub/Sub subscriber,
288
+ # async metrics processor) so the host process can exit promptly.
289
+ # Intended for use in Rails shutdown hooks, `at_exit`, or tests.
290
+ def shutdown!(timeout: 5)
291
+ safely_shutdown(@adapter_registry) { |r| r.shutdown(timeout: timeout) }
292
+ safely_shutdown(@performance_metrics, &:stop_async_processor)
293
+ true
294
+ end
295
+
268
296
  # Get default adapter registry (public method for use by other classes)
269
297
  def default_adapter_registry
270
298
  @default_adapter_registry ||= begin
@@ -279,5 +307,15 @@ module Magick
279
307
  end
280
308
 
281
309
  private
310
+
311
+ # Run a cleanup action on a collaborator, swallowing errors so that
312
+ # shutdown hooks (at_exit, Rails) never raise.
313
+ def safely_shutdown(collaborator)
314
+ return unless collaborator
315
+
316
+ yield(collaborator)
317
+ rescue StandardError
318
+ # Best-effort: termination paths must not raise.
319
+ end
282
320
  end
283
321
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -130,6 +130,7 @@ files:
130
130
  - lib/magick/feature.rb
131
131
  - lib/magick/feature_dependency.rb
132
132
  - lib/magick/feature_variant.rb
133
+ - lib/magick/log_safe.rb
133
134
  - lib/magick/performance_metrics.rb
134
135
  - lib/magick/rails.rb
135
136
  - lib/magick/rails/event_subscriber.rb