convert_sdk 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConvertSdk
6
+ # Feature resolution + typed-variable casting — the MAPPING + CASTING layer
7
+ # that turns the Epic 2 bucketing decisions (Story 2.11) into typed feature
8
+ # flags (FR24–FR27).
9
+ #
10
+ # == Features resolve THROUGH experiences (FR26)
11
+ #
12
+ # There is NO independent feature decision path. A feature is ENABLED exactly
13
+ # when the visitor is bucketed — via the ordered decision flow owned by
14
+ # {DataManager#get_bucketing} — into a variation that carries that feature. The
15
+ # carrying link lives in the variation's +changes+: a change with
16
+ # +type == "fullStackFeature"+ whose +data.feature_id+ matches a declared
17
+ # feature, and whose +data.variables_data+ holds the raw (string) variable
18
+ # values. This manager maps those bucketed variations onto declared features
19
+ # and casts the variable values; it NEVER re-evaluates rules (that would be a
20
+ # parity bug — the decision flow is owned in ONE place, the DataManager).
21
+ #
22
+ # == Typed variables (FR27) — the developer-experience core
23
+ #
24
+ # Each declared feature lists its variables as +{key, type}+; the bucketed
25
+ # variation supplies the raw values. {#cast_type} mirrors the JS
26
+ # +castType+ contract (javascript-sdk +packages/utils/src/types-utils.ts:13-54+)
27
+ # EXACTLY — five literal type strings:
28
+ #
29
+ # string -> String(value)
30
+ # boolean -> "true" -> true, "false" -> false, else truthiness
31
+ # integer -> true->1, false->0, else parseInt-style (leading digits)
32
+ # float -> true->1.0, false->0.0, else parseFloat-style (leading number)
33
+ # json -> already a Hash/Array? as-is; else JSON.parse, on FAILURE -> raw String
34
+ #
35
+ # There is NO +number+ type in the JS switch — none is added here. An unknown
36
+ # type returns the value unchanged (the JS +default+ branch). Casting is
37
+ # data-driven from the config's declared variable types — no per-feature cases.
38
+ #
39
+ # == Miss semantics (AC#5; feature-manager.ts:206-218)
40
+ #
41
+ # A miss is NEVER an exception. {#run_feature} returns a frozen
42
+ # {BucketedFeature} with +status == FeatureStatus::DISABLED+:
43
+ # * feature DECLARED but visitor not bucketed into a carrying variation ->
44
+ # +{id, name, key, status: DISABLED}+
45
+ # * feature NOT declared at all -> +{key, status: DISABLED}+
46
+ # Each miss is PAIRED with a +debug+ reason log (a Ruby observability addition;
47
+ # JS returns the disabled feature silently).
48
+ #
49
+ # == Sticky transitivity
50
+ #
51
+ # A returning visitor's stored bucketing (2.11) drives feature stability
52
+ # automatically — there is NO feature-level storage here.
53
+ #
54
+ # @api private
55
+ class FeatureManager
56
+ # Variation-change type that carries a fullstack feature link. Wire value
57
+ # byte-identical to the JS enum (variation-change-type.ts:13). Held here
58
+ # (not inlined at the use site) so the wire string lives in ONE place.
59
+ FULLSTACK_FEATURE = "fullStackFeature"
60
+
61
+ # The values JS treats as falsey for the +!!value+ boolean cast (after the
62
+ # explicit "true"/"false" string checks): nil, false, "", and 0.
63
+ JS_FALSEY = [nil, false, "", 0].freeze
64
+
65
+ # @param data_manager [DataManager] the 2.11 decision-flow owner (config
66
+ # readers + +get_bucketing+).
67
+ # @param log_manager [LogManager, nil] optional debug/warn logger.
68
+ def initialize(data_manager:, log_manager: nil)
69
+ @data_manager = data_manager
70
+ @log_manager = log_manager
71
+ end
72
+
73
+ # Resolve a SINGLE feature for a visitor (FR24).
74
+ #
75
+ # Mirrors JS +runFeature+ (feature-manager.ts:180-219): the feature is looked
76
+ # up by key; if declared, the bucketing flow runs FILTERED to this feature.
77
+ # On one carrying variation a single ENABLED {BucketedFeature} is returned; on
78
+ # several (the feature appears in multiple bucketed variations) an Array of
79
+ # ENABLED {BucketedFeature}s; on none the DISABLED fallback (+{id,name,key}+).
80
+ # An undeclared feature returns the +{key}+-only DISABLED fallback. Each miss
81
+ # is paired with a debug log; never raises.
82
+ #
83
+ # @param visitor_id [String] the visitor identifier.
84
+ # @param feature_key [String] the feature +key+ to resolve.
85
+ # @param attributes [Hash] bucketing attributes (+:visitor_properties+,
86
+ # +:location_properties+, +:environment+) — see {DataManager#get_bucketing}.
87
+ # @return [BucketedFeature, Array<BucketedFeature>] enabled feature(s) or a
88
+ # frozen DISABLED {BucketedFeature} on a miss.
89
+ def run_feature(visitor_id, feature_key, attributes = {})
90
+ declared = @data_manager.feature_by_key(feature_key)
91
+ unless declared
92
+ @log_manager&.debug("FeatureManager#run_feature: feature not declared key=#{feature_key}")
93
+ return disabled_feature(key: feature_key)
94
+ end
95
+
96
+ enabled = run_features(visitor_id, attributes, features: [feature_key])
97
+ if enabled.empty?
98
+ @log_manager&.debug("FeatureManager#run_feature: not bucketed into a carrying variation key=#{feature_key}")
99
+ return disabled_from_declared(declared)
100
+ end
101
+
102
+ enabled.length == 1 ? enabled.first : enabled
103
+ end
104
+
105
+ # Resolve ALL applicable features for a visitor (FR25).
106
+ #
107
+ # Mirrors JS +runFeatures+ (feature-manager.ts:327-463) under the Ruby
108
+ # across-all-experiences parity decision (Story 2.11 {ExperienceManager#select_variations}):
109
+ # misses are FILTERED OUT of the bucketed-variation set (sentinels never
110
+ # propagate), then every declared feature carried by a bucketed variation is
111
+ # collected as an ENABLED {BucketedFeature} (variables cast per declared type).
112
+ # When NO +features+ filter is supplied, every declared feature NOT already
113
+ # enabled is appended as a DISABLED {BucketedFeature} — so callers always see
114
+ # the full feature roster. With a +features+ filter, only enabled matches are
115
+ # returned (no DISABLED padding). Never raises.
116
+ #
117
+ # @param visitor_id [String] the visitor identifier.
118
+ # @param attributes [Hash] bucketing attributes (see {#run_feature}).
119
+ # @param experiences [Array<String>, nil] optional experience-key filter.
120
+ # @param features [Array<String>, nil] optional feature-key filter (suppresses
121
+ # the DISABLED padding).
122
+ # @return [Array<BucketedFeature>] the resolved features.
123
+ def run_features(visitor_id, attributes = {}, experiences: nil, features: nil)
124
+ declared_by_id = features_by_id
125
+ variations = bucketed_variations(visitor_id, attributes, experiences)
126
+
127
+ bucketed = collect_enabled(variations, declared_by_id, features)
128
+
129
+ # Pad with DISABLED features ONLY when no feature filter is supplied.
130
+ append_disabled(bucketed, declared_by_id) if features.nil?
131
+ bucketed
132
+ end
133
+
134
+ # Cast a raw variable value to its declared type. Mirrors JS +castType+
135
+ # (types-utils.ts:13-54) exactly — see the class doc for the truth table.
136
+ # Never raises: non-numeric integer/float inputs degrade to a leading-number
137
+ # parse (0 / 0.0 when there is no leading number), and a +json+ parse failure
138
+ # falls back to the raw String (JS +catch -> String(value)+).
139
+ #
140
+ # @param value [Object] the raw (typically String) variable value.
141
+ # @param type [String] the declared type: string/boolean/integer/float/json.
142
+ # @return [Object] the cast value.
143
+ def cast_type(value, type)
144
+ case type
145
+ when "string" then value.to_s
146
+ when "boolean" then cast_boolean(value)
147
+ when "integer" then cast_integer(value)
148
+ when "float" then cast_float(value)
149
+ when "json" then cast_json(value)
150
+ else value # JS default branch — unknown type passes through unchanged.
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ # Bucket the (optionally experience-filtered) experiences through the 2.11
157
+ # decision flow, keeping ONLY successful {BucketedVariation}s (sentinels and
158
+ # nils filtered — the Ruby across-all parity decision, Story 2.11).
159
+ def bucketed_variations(visitor_id, attributes, experience_keys)
160
+ experiences = target_experiences(experience_keys)
161
+ experiences.filter_map do |experience|
162
+ next unless experience.is_a?(Hash)
163
+
164
+ result = @data_manager.get_bucketing(visitor_id, experience["key"], attributes)
165
+ result if result.is_a?(BucketedVariation)
166
+ end
167
+ end
168
+
169
+ # The experiences to decide: the whole configured list, or just those whose
170
+ # key is in +experience_keys+ when a filter is supplied.
171
+ def target_experiences(experience_keys)
172
+ all = @data_manager.experiences
173
+ return all if experience_keys.nil? || experience_keys.empty?
174
+
175
+ wanted = experience_keys.map(&:to_s)
176
+ all.select { |experience| experience.is_a?(Hash) && wanted.include?(experience["key"].to_s) }
177
+ end
178
+
179
+ # Walk every bucketed variation's +fullStackFeature+ changes, mapping each to
180
+ # its declared feature (by id), casting the variables, and building an ENABLED
181
+ # {BucketedFeature}. Honours the optional +feature_keys+ filter.
182
+ def collect_enabled(variations, declared_by_id, feature_keys)
183
+ bucketed = [] #: Array[BucketedFeature]
184
+ variations.each do |variation|
185
+ feature_changes(variation).each do |change|
186
+ feature = enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
187
+ bucketed << feature if feature
188
+ end
189
+ end
190
+ bucketed
191
+ end
192
+
193
+ # The +fullStackFeature+ changes carried by a bucketed variation (a warn is
194
+ # logged for any non-feature change, mirroring JS VARIATION_CHANGE_NOT_SUPPORTED).
195
+ def feature_changes(variation)
196
+ changes = variation.changes
197
+ return [] unless changes.is_a?(Array)
198
+
199
+ changes.select do |change|
200
+ if change.is_a?(Hash) && change["type"] == FULLSTACK_FEATURE
201
+ true
202
+ else
203
+ @log_manager&.warn("FeatureManager#run_features: unsupported variation change type")
204
+ false
205
+ end
206
+ end
207
+ end
208
+
209
+ # Build the ENABLED {BucketedFeature} for one feature change, or nil when the
210
+ # change has no feature_id, the feature is undeclared, or it is filtered out.
211
+ def enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
212
+ data = change["data"]
213
+ declared = declared_for_change(data, declared_by_id)
214
+ return nil if declared.nil?
215
+ return nil if filtered_out?(declared, feature_keys)
216
+
217
+ build_enabled(variation, declared, cast_variables(declared, data["variables_data"]))
218
+ end
219
+
220
+ # The declared feature a feature-change maps to (by data.feature_id), or nil
221
+ # when the change carries no feature_id or the id is undeclared (each logged).
222
+ def declared_for_change(data, declared_by_id)
223
+ feature_id = data.is_a?(Hash) ? data["feature_id"] : nil
224
+ unless feature_id
225
+ @log_manager&.warn("FeatureManager#run_features: feature change without feature_id")
226
+ return nil
227
+ end
228
+ declared_by_id[feature_id.to_s]
229
+ end
230
+
231
+ # True when a feature filter is supplied and this declared feature's key is
232
+ # not in it.
233
+ def filtered_out?(declared, feature_keys)
234
+ return false if feature_keys.nil?
235
+
236
+ !feature_keys.map(&:to_s).include?(declared["key"].to_s)
237
+ end
238
+
239
+ # Cast every supplied raw variable per its declared type (data-driven). A
240
+ # variable with no declared type passes through uncast (JS warns
241
+ # FEATURE_VARIABLES_TYPE_NOT_FOUND). Returns a fresh string-keyed Hash.
242
+ def cast_variables(declared, raw)
243
+ unless raw.is_a?(Hash)
244
+ @log_manager&.warn("FeatureManager#run_features: feature variables not found")
245
+ return {}
246
+ end
247
+
248
+ definitions = declared["variables"]
249
+ cast = {} #: Hash[String, untyped]
250
+ raw.each do |name, value|
251
+ type = variable_type(definitions, name)
252
+ if type
253
+ cast[name.to_s] = cast_type(value, type)
254
+ else
255
+ @log_manager&.warn("FeatureManager#run_features: variable type not found name=#{name}")
256
+ cast[name.to_s] = value
257
+ end
258
+ end
259
+ cast
260
+ end
261
+
262
+ # The declared type for a variable name within a feature's +variables+ list.
263
+ def variable_type(definitions, name)
264
+ return nil unless definitions.is_a?(Array)
265
+
266
+ definition = definitions.find { |d| d.is_a?(Hash) && d["key"] == name }
267
+ definition && definition["type"]
268
+ end
269
+
270
+ # A frozen ENABLED {BucketedFeature} with the experience provenance + the
271
+ # declared feature's id/name/key + the cast variables.
272
+ def build_enabled(variation, declared, variables)
273
+ BucketedFeature.new(
274
+ experience_id: variation.experience_id,
275
+ experience_key: variation.experience_key,
276
+ experience_name: variation.experience_name,
277
+ id: declared["id"],
278
+ key: declared["key"],
279
+ name: declared["name"],
280
+ status: FeatureStatus::ENABLED,
281
+ variables: variables
282
+ )
283
+ end
284
+
285
+ # Append a DISABLED {BucketedFeature} for every declared feature not already
286
+ # present (enabled) in +bucketed+ — JS feature-manager.ts:448-461.
287
+ def append_disabled(bucketed, declared_by_id)
288
+ enabled_ids = bucketed.map(&:id)
289
+ declared_by_id.each_value do |declared|
290
+ next if enabled_ids.include?(declared["id"])
291
+
292
+ bucketed << disabled_from_declared(declared)
293
+ end
294
+ end
295
+
296
+ # A frozen DISABLED {BucketedFeature} for a DECLARED feature (id/name/key) —
297
+ # the "visitor not bucketed" miss shape (feature-manager.ts:206-211).
298
+ def disabled_from_declared(declared)
299
+ BucketedFeature.new(
300
+ id: declared["id"], name: declared["name"], key: declared["key"],
301
+ status: FeatureStatus::DISABLED
302
+ )
303
+ end
304
+
305
+ # A frozen DISABLED {BucketedFeature} for an UNDECLARED feature (key only) —
306
+ # the "feature not declared at all" miss shape (feature-manager.ts:214-217).
307
+ def disabled_feature(key:)
308
+ BucketedFeature.new(key: key, status: FeatureStatus::DISABLED)
309
+ end
310
+
311
+ # Declared features keyed by id (String), for the change->feature mapping and
312
+ # the DISABLED padding. Mirrors JS getListAsObject('id').
313
+ def features_by_id
314
+ result = {} #: Hash[String, untyped]
315
+ @data_manager.features.each do |feature|
316
+ result[feature["id"].to_s] = feature if feature.is_a?(Hash) && feature["id"]
317
+ end
318
+ result
319
+ end
320
+
321
+ # boolean: "true"->true, "false"->false, else Ruby truthiness of the value
322
+ # (JS !!value; "" and 0 are falsey in JS, so they map to false).
323
+ def cast_boolean(value)
324
+ return true if value == "true"
325
+ return false if value == "false"
326
+
327
+ !JS_FALSEY.include?(value)
328
+ end
329
+
330
+ # integer: true->1, false->0, else parseInt-style — leading integer digits of
331
+ # the string, 0 when there is no leading integer (JS parseInt returns NaN, but
332
+ # the never-crash contract degrades to 0).
333
+ def cast_integer(value)
334
+ return 1 if value == true
335
+ return 0 if value == false
336
+ return value if value.is_a?(Integer)
337
+
338
+ str = value.to_s.strip
339
+ match = str.match(/\A[+-]?\d+/)
340
+ match ? match[0].to_i : 0
341
+ end
342
+
343
+ # float: true->1.0, false->0.0, else parseFloat-style — leading numeric prefix
344
+ # of the string, 0.0 when there is no leading number (degrade, never crash).
345
+ def cast_float(value)
346
+ return 1.0 if value == true
347
+ return 0.0 if value == false
348
+ return value + 0.0 if value.is_a?(Integer) || value.is_a?(Float)
349
+
350
+ str = value.to_s.strip
351
+ match = str.match(/\A[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?/)
352
+ match ? match[0].to_f : 0.0
353
+ end
354
+
355
+ # json: an already-parsed Hash/Array passes through; otherwise JSON.parse,
356
+ # and on a parse failure fall back to the raw String (JS catch -> String(value)).
357
+ def cast_json(value)
358
+ return value if value.is_a?(Hash) || value.is_a?(Array)
359
+
360
+ begin
361
+ JSON.parse(value.to_s)
362
+ rescue JSON::ParserError, TypeError
363
+ value.to_s
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The SDK's single fork-detection authority and the ONLY +Process._fork+
5
+ # prepend in the gem — the SDK's only global mutation (NFR15, architecture
6
+ # Decision 6). It follows the Rails ForkTracker pattern: a module prepended
7
+ # onto +Process.singleton_class+ whose +_fork+ wraps +super+ and, when it
8
+ # returns +0+ (the child), runs the re-arm path. The prepend is installed once
9
+ # at SDK load (it must exist before any fork; installing it is cheap and
10
+ # thread-free, so it does not violate the NFR4 zero-threads-until-use rule —
11
+ # that rule concerns THREADS, not this hook).
12
+ #
13
+ # Fork detection elsewhere uses {.forked?} — a free integer comparison
14
+ # (+Process.pid != owner_pid+, the Datadog idiom) safe to call on every
15
+ # boundary, including JRuby (where it is always false).
16
+ #
17
+ # On JRuby (no +fork+, no +Process._fork+) the prepend is a no-op by
18
+ # construction: {.install!} skips it, so {.forked?} stays false forever.
19
+ #
20
+ # Consumers register their thread-owning timers via {.register_timer} and any
21
+ # child-side cleanup (e.g. ApiManager's queue-ownership clear in Story 4.2) via
22
+ # {.register_child_callback}, keeping ForkGuard decoupled from its callers.
23
+ # {.rearm!} is the shared re-arm path (also invoked by +Client#postfork+ in
24
+ # Epic 4): it marks every registered timer dead, then fires every registered
25
+ # child-callback in registration order, then resets +owner_pid+.
26
+ #
27
+ # The child hook path is LOCK-MINIMAL — mutexes held by other threads at the
28
+ # moment of fork are a classic deadlock source. It resets +owner_pid+ first,
29
+ # then iterates a SNAPSHOT of the timer registry and a SNAPSHOT of the
30
+ # child-callback registry taken under the registry mutex.
31
+ #
32
+ # @api private — not part of the public SDK surface.
33
+ module ForkGuard
34
+ # Thread safety: @registry_mutex guards @timers and @child_callbacks; the
35
+ # singleton-class prepend, @installed flag, @owner_pid, and @logger are
36
+ # module-level state mutated only at install / arm / wiring time.
37
+ @registry_mutex = Thread::Mutex.new
38
+ @timers = []
39
+ @child_callbacks = []
40
+ @installed = false
41
+ @owner_pid = Process.pid
42
+ @logger = nil
43
+
44
+ class << self
45
+ # @return [Integer] the pid that currently owns the SDK's threads.
46
+ attr_reader :owner_pid
47
+
48
+ # Module-level logger, settable at wiring time (Client wires it in 2.7).
49
+ # nil-safe before wiring — the hook never assumes a logger is present.
50
+ # @return [ConvertSdk::LogManager, nil]
51
+ attr_accessor :logger
52
+
53
+ # Install the +Process._fork+ prepend. Idempotent (double-install guarded)
54
+ # and a no-op when fork is unsupported (JRuby) — the prepend never lands,
55
+ # so the hook is a no-op by construction. Safe to call repeatedly.
56
+ # @return [void]
57
+ def install!
58
+ return unless Process.respond_to?(:_fork) && Process.respond_to?(:fork)
59
+ return if @installed
60
+
61
+ Process.singleton_class.prepend(ForkHook)
62
+ @installed = true
63
+ @owner_pid = Process.pid
64
+ end
65
+
66
+ # @return [Boolean] true iff the current process differs from the owner
67
+ # (i.e. we are in a forked child). A free comparison; false on JRuby.
68
+ def forked?
69
+ Process.pid != @owner_pid
70
+ end
71
+
72
+ # Register a thread-owning timer to be marked dead in a forked child.
73
+ # @param timer [#mark_dead]
74
+ # @return [void]
75
+ def register_timer(timer)
76
+ @registry_mutex.synchronize { @timers << timer }
77
+ end
78
+
79
+ # Register a child-side callback fired after timers are marked dead (e.g.
80
+ # queue-ownership clear). Keeps ForkGuard decoupled from its callers.
81
+ # @param callable [#call]
82
+ # @return [void]
83
+ def register_child_callback(callable)
84
+ @registry_mutex.synchronize { @child_callbacks << callable }
85
+ end
86
+
87
+ # The shared re-arm path: reset owner_pid, mark every registered timer
88
+ # dead, then fire every child-callback in registration order. Lock-minimal:
89
+ # owner_pid is reset first, then SNAPSHOTS of the registries are iterated
90
+ # outside the registry mutex (deadlock-safe in the fork hook).
91
+ # @return [void]
92
+ def rearm!
93
+ @owner_pid = Process.pid
94
+ timers, callbacks = @registry_mutex.synchronize { [@timers.dup, @child_callbacks.dup] }
95
+ @logger&.debug("ForkGuard#rearm!: fork detected, re-arming #{timers.size} timer(s) in pid #{Process.pid}")
96
+ timers.each(&:mark_dead)
97
+ callbacks.each(&:call)
98
+ end
99
+
100
+ # Test-only reap that STOPS (signals exit + joins) every registered timer
101
+ # so NO BackgroundTimer thread can survive into the next example. Distinct
102
+ # from {.reset_for_tests!}, which only clears the registry (it leaves any
103
+ # live thread running). A leaked flush/refresh timer thread firing a real
104
+ # POST/GET after its example ends pollutes a later example's zero-HTTP
105
+ # assertion under WebMock (intermittent on JRuby's thread scheduling) — a
106
+ # global +after(:each)+ reap closes that window deterministically. Iterates
107
+ # a SNAPSHOT taken under the registry mutex; +#stop+ is idempotent so this
108
+ # is a cheap no-op for already-stopped timers.
109
+ # @api private
110
+ # @return [void]
111
+ def stop_all_timers!
112
+ timers = @registry_mutex.synchronize { @timers.dup }
113
+ timers.each(&:stop)
114
+ end
115
+
116
+ # Test-only reset so the singleton-state module is order-independent under
117
+ # RSpec. Clears registries, resets owner_pid, drops the logger. Does NOT
118
+ # uninstall the prepend (it is harmless and global).
119
+ # @api private
120
+ # @return [void]
121
+ def reset_for_tests!
122
+ @registry_mutex.synchronize do
123
+ @timers = []
124
+ @child_callbacks = []
125
+ end
126
+ @owner_pid = Process.pid
127
+ @logger = nil
128
+ end
129
+ end
130
+
131
+ # The prepended +_fork+ wrapper (Rails ForkTracker pattern). In the child
132
+ # (+super+ returns 0) it runs the shared re-arm path; the parent path is a
133
+ # pass-through.
134
+ # @api private
135
+ module ForkHook
136
+ # @return [Integer] the pid returned by the real +_fork+.
137
+ def _fork
138
+ pid = super
139
+ ForkGuard.rearm! if pid.zero?
140
+ pid
141
+ end
142
+ end
143
+ end
144
+ end