flipper 1.3.5 → 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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +93 -0
  7. data/README.md +4 -3
  8. data/examples/cloud/poll_interval/README.md +111 -0
  9. data/examples/cloud/poll_interval/client.rb +108 -0
  10. data/examples/cloud/poll_interval/server.rb +98 -0
  11. data/examples/expressions.rb +35 -11
  12. data/lib/flipper/adapter.rb +17 -1
  13. data/lib/flipper/adapters/actor_limit.rb +27 -1
  14. data/lib/flipper/adapters/cache_base.rb +21 -3
  15. data/lib/flipper/adapters/dual_write.rb +6 -2
  16. data/lib/flipper/adapters/failover.rb +9 -3
  17. data/lib/flipper/adapters/failsafe.rb +2 -2
  18. data/lib/flipper/adapters/http/client.rb +15 -4
  19. data/lib/flipper/adapters/http.rb +37 -2
  20. data/lib/flipper/adapters/instrumented.rb +2 -2
  21. data/lib/flipper/adapters/memoizable.rb +3 -3
  22. data/lib/flipper/adapters/memory.rb +1 -1
  23. data/lib/flipper/adapters/pstore.rb +1 -1
  24. data/lib/flipper/adapters/strict.rb +30 -0
  25. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  26. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  27. data/lib/flipper/adapters/sync.rb +7 -3
  28. data/lib/flipper/cli.rb +51 -0
  29. data/lib/flipper/cloud/configuration.rb +9 -4
  30. data/lib/flipper/cloud/dsl.rb +2 -2
  31. data/lib/flipper/cloud/middleware.rb +1 -1
  32. data/lib/flipper/cloud/migrate.rb +71 -0
  33. data/lib/flipper/cloud/telemetry.rb +1 -1
  34. data/lib/flipper/cloud.rb +1 -0
  35. data/lib/flipper/dsl.rb +1 -1
  36. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  37. data/lib/flipper/expressions/time.rb +8 -1
  38. data/lib/flipper/gates/expression.rb +2 -2
  39. data/lib/flipper/poller.rb +52 -9
  40. data/lib/flipper/version.rb +1 -1
  41. data/lib/flipper.rb +17 -1
  42. data/spec/flipper/adapter_spec.rb +20 -0
  43. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  44. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  45. data/spec/flipper/adapters/failover_spec.rb +12 -0
  46. data/spec/flipper/adapters/http_spec.rb +240 -0
  47. data/spec/flipper/adapters/strict_spec.rb +62 -4
  48. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  49. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  50. data/spec/flipper/adapters/sync_spec.rb +13 -0
  51. data/spec/flipper/cli_spec.rb +51 -0
  52. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  53. data/spec/flipper/cloud/dsl_spec.rb +10 -2
  54. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  55. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  56. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  57. data/spec/flipper/engine_spec.rb +2 -2
  58. data/spec/flipper/expressions/time_spec.rb +16 -0
  59. data/spec/flipper/gates/expression_spec.rb +82 -0
  60. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  61. data/spec/flipper/poller_spec.rb +347 -4
  62. data/spec/flipper_integration_spec.rb +133 -0
  63. data/spec/flipper_spec.rb +6 -1
  64. data/spec/spec_helper.rb +7 -0
  65. metadata +18 -112
  66. data/lib/flipper/expressions/duration.rb +0 -28
  67. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -263,10 +263,10 @@ RSpec.describe Flipper::Engine do
263
263
  Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
264
264
  }
265
265
 
266
- it "configures webhook app" do
266
+ it "configures webhook app and uses cache busting" do
267
267
  silence { application.initialize! }
268
268
 
269
- stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").with({
269
+ stub = stub_request(:get, /https:\/\/www\.flippercloud\.io\/adapter\/features\?_cb=\d+&exclude_gate_names=true/).with({
270
270
  headers: { "flipper-cloud-token" => ENV["FLIPPER_CLOUD_TOKEN"] },
271
271
  }).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {})
272
272
 
@@ -9,5 +9,21 @@ RSpec.describe Flipper::Expressions::Time do
9
9
  it "returns time for #iso8601 format" do
10
10
  expect(described_class.call(time.iso8601)).to eq(time)
11
11
  end
12
+
13
+ it "returns time for epoch integer" do
14
+ expect(described_class.call(time.to_i)).to eq(Time.at(time.to_i).utc)
15
+ end
16
+
17
+ it "returns time for epoch float" do
18
+ expect(described_class.call(time.to_f)).to be_within(0.001).of(time)
19
+ end
20
+
21
+ it "returns utc for string input" do
22
+ expect(described_class.call(time.to_s).utc?).to be(true)
23
+ end
24
+
25
+ it "returns utc for numeric input" do
26
+ expect(described_class.call(time.to_i).utc?).to be(true)
27
+ end
12
28
  end
13
29
  end
@@ -62,6 +62,30 @@ RSpec.describe Flipper::Gates::Expression do
62
62
  end
63
63
  end
64
64
 
65
+ context 'for actor in context' do
66
+ it 'passes actor to expression context' do
67
+ actor = Flipper::Actor.new("User;1", {type: "User"})
68
+ wrapped_actor = Flipper::Types::Actor.new(actor)
69
+ expression = Flipper.property(:flipper_id).eq("User;1")
70
+ ctx = Flipper::FeatureCheckContext.new(
71
+ feature_name: feature_name,
72
+ values: Flipper::GateValues.new(expression: expression.value),
73
+ actors: [wrapped_actor]
74
+ )
75
+ expect(subject.open?(ctx)).to be(true)
76
+ end
77
+
78
+ it 'passes nil actor when no actors provided' do
79
+ expression = Flipper.boolean(true).eq(true)
80
+ ctx = Flipper::FeatureCheckContext.new(
81
+ feature_name: feature_name,
82
+ values: Flipper::GateValues.new(expression: expression.value),
83
+ actors: nil
84
+ )
85
+ expect(subject.open?(ctx)).to be(true)
86
+ end
87
+ end
88
+
65
89
  context 'for properties that have symbol keys' do
66
90
  it 'returns true when expression evalutes to true' do
67
91
  expression = Flipper.property(:type).eq("User")
@@ -75,6 +99,64 @@ RSpec.describe Flipper::Gates::Expression do
75
99
  expect(subject.open?(context)).to be(false)
76
100
  end
77
101
  end
102
+
103
+ context 'for time-based expressions' do
104
+ it 'enables when now is past a scheduled epoch' do
105
+ past_epoch = Time.now.to_i - 86_400
106
+ expression = Flipper.now.gte(Flipper.time(past_epoch))
107
+ expect(subject.open?(context(expression.value))).to be(true)
108
+ end
109
+
110
+ it 'does not enable when now is before a future epoch' do
111
+ future_epoch = Time.now.to_i + 86_400
112
+ expression = Flipper.now.gte(Flipper.time(future_epoch))
113
+ expect(subject.open?(context(expression.value))).to be(false)
114
+ end
115
+
116
+ it 'enables when now is past a scheduled datetime' do
117
+ past_time = (Time.now.utc - 86_400).iso8601
118
+ expression = Flipper.now.gte(Flipper.time(past_time))
119
+ expect(subject.open?(context(expression.value))).to be(true)
120
+ end
121
+
122
+ it 'does not enable when now is before a future datetime' do
123
+ future_time = (Time.now.utc + 86_400).iso8601
124
+ expression = Flipper.now.gte(Flipper.time(future_time))
125
+ expect(subject.open?(context(expression.value))).to be(false)
126
+ end
127
+
128
+ it 'enables expiring features with lt' do
129
+ future_time = (Time.now.utc + 86_400).iso8601
130
+ expression = Flipper.now.lt(Flipper.time(future_time))
131
+ expect(subject.open?(context(expression.value))).to be(true)
132
+ end
133
+
134
+ it 'disables expired features with lt' do
135
+ past_time = (Time.now.utc - 86_400).iso8601
136
+ expression = Flipper.now.lt(Flipper.time(past_time))
137
+ expect(subject.open?(context(expression.value))).to be(false)
138
+ end
139
+
140
+ it 'enables within a time window using all' do
141
+ start_time = (Time.now.utc - 86_400).iso8601
142
+ end_time = (Time.now.utc + 86_400).iso8601
143
+ expression = Flipper.all(
144
+ Flipper.now.gte(Flipper.time(start_time)),
145
+ Flipper.now.lt(Flipper.time(end_time))
146
+ )
147
+ expect(subject.open?(context(expression.value))).to be(true)
148
+ end
149
+
150
+ it 'does not enable outside a time window' do
151
+ start_time = (Time.now.utc + 86_400).iso8601
152
+ end_time = (Time.now.utc + 172_800).iso8601
153
+ expression = Flipper.all(
154
+ Flipper.now.gte(Flipper.time(start_time)),
155
+ Flipper.now.lt(Flipper.time(end_time))
156
+ )
157
+ expect(subject.open?(context(expression.value))).to be(false)
158
+ end
159
+ end
78
160
  end
79
161
 
80
162
  describe '#protects?' do
@@ -2,6 +2,8 @@ require 'rack/test'
2
2
  require 'active_support/cache'
3
3
  require 'flipper/adapters/active_support_cache_store'
4
4
  require 'flipper/adapters/operation_logger'
5
+ require 'flipper/adapters/actor_limit'
6
+ require 'flipper/adapters/sync'
5
7
 
6
8
  RSpec.describe Flipper::Middleware::Memoizer do
7
9
  include Rack::Test::Methods
@@ -470,18 +472,47 @@ RSpec.describe Flipper::Middleware::Memoizer do
470
472
 
471
473
  get '/', {}, 'flipper' => flipper
472
474
  expect(logged_cached.count(:get_all)).to be(1)
473
- expect(logged_memory.count(:features)).to be(1)
474
- expect(logged_memory.count(:get_multi)).to be(1)
475
+ expect(logged_memory.count(:get_all)).to be(1)
475
476
 
476
477
  get '/', {}, 'flipper' => flipper
477
478
  expect(logged_cached.count(:get_all)).to be(2)
478
- expect(logged_memory.count(:features)).to be(1)
479
- expect(logged_memory.count(:get_multi)).to be(1)
479
+ expect(logged_memory.count(:get_all)).to be(1)
480
480
 
481
481
  get '/', {}, 'flipper' => flipper
482
482
  expect(logged_cached.count(:get_all)).to be(3)
483
- expect(logged_memory.count(:features)).to be(1)
484
- expect(logged_memory.count(:get_multi)).to be(1)
483
+ expect(logged_memory.count(:get_all)).to be(1)
484
+ end
485
+ end
486
+
487
+ context 'with preload:true and Sync adapter wrapped with ActorLimit' do
488
+ it 'preloads even when remote has more actors than local limit' do
489
+ local = Flipper::Adapters::Memory.new
490
+ remote = Flipper::Adapters::Memory.new
491
+ remote_flipper = Flipper.new(remote)
492
+
493
+ # Remote has more actors than limit allows (actor-only enables, not boolean)
494
+ 10.times { |i| remote_flipper[:stats].enable_actor Flipper::Actor.new("User;#{i}") }
495
+
496
+ # Sync adapter will sync from remote to local, then ActorLimit wraps it
497
+ # Use interval: 0 to force sync on every call
498
+ sync_adapter = Flipper::Adapters::Sync.new(local, remote, interval: 0)
499
+ limited_adapter = Flipper::Adapters::ActorLimit.new(sync_adapter, 5)
500
+ test_flipper = Flipper.new(limited_adapter)
501
+
502
+ app = lambda do |env|
503
+ f = env['flipper']
504
+ f[:stats].enabled?
505
+ [200, {}, []]
506
+ end
507
+ middleware = described_class.new(app, preload: true)
508
+
509
+ # Preload should work without raising ActorLimit::LimitExceeded
510
+ expect {
511
+ middleware.call('flipper' => test_flipper)
512
+ }.not_to raise_error
513
+
514
+ # Verify actors were synced (all 10, not just 5)
515
+ expect(test_flipper[:stats].actors_value.size).to eq(10)
485
516
  end
486
517
  end
487
518
  end
@@ -1,19 +1,23 @@
1
1
  require "flipper/poller"
2
+ require "flipper/adapters/http"
2
3
 
3
4
  RSpec.describe Flipper::Poller do
4
- let(:remote_adapter) { Flipper::Adapters::Memory.new }
5
- let(:remote) { Flipper.new(remote_adapter) }
5
+ let(:url) { "http://app.com/flipper" }
6
+ let(:remote_adapter) { Flipper::Adapters::Http.new(url: url) }
6
7
  let(:local) { Flipper.new(subject.adapter) }
7
8
 
8
9
  subject do
9
10
  described_class.new(
10
11
  remote_adapter: remote_adapter,
11
12
  start_automatically: false,
12
- interval: Float::INFINITY
13
+ interval: 3600 # 1 hour
13
14
  )
14
15
  end
15
16
 
16
17
  before do
18
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
19
+ .to_return(status: 200, body: JSON.generate(features: []))
20
+
17
21
  allow(subject).to receive(:loop).and_yield # Make loop just call once
18
22
  allow(subject).to receive(:sleep) # Disable sleep
19
23
  allow(Thread).to receive(:new).and_yield # Disable separate thread
@@ -28,12 +32,320 @@ RSpec.describe Flipper::Poller do
28
32
 
29
33
  describe "#sync" do
30
34
  it "syncs remote adapter to local adapter" do
31
- remote.enable :polling
35
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
36
+ .to_return(status: 200, body: JSON.generate(
37
+ features: [
38
+ {
39
+ key: "polling",
40
+ gates: [
41
+ { key: "boolean", value: true }
42
+ ]
43
+ }
44
+ ]
45
+ ))
32
46
 
33
47
  expect(local.enabled?(:polling)).to be(false)
34
48
  subject.sync
35
49
  expect(local.enabled?(:polling)).to be(true)
36
50
  end
51
+
52
+ context "when poll-shutdown header is present" do
53
+ before do
54
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
55
+ .to_return(
56
+ status: 200,
57
+ body: JSON.generate(
58
+ features: [
59
+ {
60
+ key: "polling",
61
+ gates: [
62
+ { key: "boolean", value: true }
63
+ ]
64
+ }
65
+ ]
66
+ ),
67
+ headers: { "poll-shutdown" => "true" }
68
+ )
69
+ end
70
+
71
+ it "stops the poller when poll-shutdown header is true" do
72
+ expect(subject).to receive(:stop).and_call_original
73
+ subject.sync
74
+ end
75
+
76
+ it "prevents poller from restarting after shutdown" do
77
+ subject.sync # This should trigger shutdown
78
+
79
+ # Try to start again - should be a no-op
80
+ expect(Thread).not_to receive(:new)
81
+ subject.start
82
+ end
83
+
84
+ it "instruments the shutdown_requested event" do
85
+ instrumenter = subject.instance_variable_get(:@instrumenter)
86
+
87
+ expect(instrumenter).to receive(:instrument).with(
88
+ "poller.#{Flipper::InstrumentationNamespace}",
89
+ { operation: :poll }
90
+ ).and_call_original
91
+
92
+ expect(instrumenter).to receive(:instrument).with(
93
+ "poller.#{Flipper::InstrumentationNamespace}",
94
+ { operation: :shutdown_requested }
95
+ ).and_call_original
96
+
97
+ expect(instrumenter).to receive(:instrument).with(
98
+ "poller.#{Flipper::InstrumentationNamespace}",
99
+ { operation: :stop }
100
+ )
101
+
102
+ subject.sync
103
+ end
104
+ end
105
+
106
+ context "when poll-shutdown header is present on error response" do
107
+ before do
108
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
109
+ .to_return(
110
+ status: 404,
111
+ body: JSON.generate({ error: "Not found" }),
112
+ headers: { "poll-shutdown" => "true" }
113
+ )
114
+ end
115
+
116
+ it "stops polling even when sync fails with error response" do
117
+ # sync will raise an error, but should still check shutdown header
118
+ expect { subject.sync }.to raise_error(Flipper::Adapters::Http::Error)
119
+
120
+ # Verify shutdown was triggered
121
+ expect(Thread).not_to receive(:new)
122
+ subject.start
123
+ end
124
+ end
125
+
126
+ context "when poll-shutdown header is false" do
127
+ before do
128
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
129
+ .to_return(
130
+ status: 200,
131
+ body: JSON.generate(
132
+ features: [
133
+ {
134
+ key: "polling",
135
+ gates: [
136
+ { key: "boolean", value: true }
137
+ ]
138
+ }
139
+ ]
140
+ ),
141
+ headers: { "poll-shutdown" => "false" }
142
+ )
143
+ end
144
+
145
+ it "does not stop the poller" do
146
+ expect(subject).not_to receive(:stop)
147
+ subject.sync
148
+ end
149
+ end
150
+
151
+ context "when poll-shutdown header is missing" do
152
+ before do
153
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
154
+ .to_return(
155
+ status: 200,
156
+ body: JSON.generate(
157
+ features: [
158
+ {
159
+ key: "polling",
160
+ gates: [
161
+ { key: "boolean", value: true }
162
+ ]
163
+ }
164
+ ]
165
+ )
166
+ )
167
+ end
168
+
169
+ it "does not stop the poller" do
170
+ expect(subject).not_to receive(:stop)
171
+ subject.sync
172
+ end
173
+ end
174
+
175
+ context "when poll-interval header is lower than initial interval" do
176
+ before do
177
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
178
+ .to_return(
179
+ status: 200,
180
+ body: JSON.generate(
181
+ features: [
182
+ {
183
+ key: "polling",
184
+ gates: [
185
+ { key: "boolean", value: true }
186
+ ]
187
+ }
188
+ ]
189
+ ),
190
+ headers: { "poll-interval" => "30" }
191
+ )
192
+ end
193
+
194
+ it "uses the initial interval as minimum" do
195
+ expect(subject.interval).to eq(3600.0)
196
+ subject.sync
197
+ expect(subject.interval).to eq(3600.0) # Keeps 3600 because it's the initial interval
198
+ end
199
+ end
200
+
201
+ context "when poll-interval header is below minimum" do
202
+ subject do
203
+ described_class.new(
204
+ remote_adapter: remote_adapter,
205
+ start_automatically: false,
206
+ interval: 10 # Set initial to minimum
207
+ )
208
+ end
209
+
210
+ before do
211
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
212
+ .to_return(
213
+ status: 200,
214
+ body: JSON.generate(
215
+ features: [
216
+ {
217
+ key: "polling",
218
+ gates: [
219
+ { key: "boolean", value: true }
220
+ ]
221
+ }
222
+ ]
223
+ ),
224
+ headers: { "poll-interval" => "5" }
225
+ )
226
+ end
227
+
228
+ it "enforces minimum poll interval" do
229
+ expect(subject.interval).to eq(10.0)
230
+ subject.sync
231
+ # Header says 5, minimum is 10, initial is 10, so max(5->10, 10) = 10
232
+ expect(subject.interval).to eq(Flipper::Poller::MINIMUM_POLL_INTERVAL)
233
+ end
234
+ end
235
+
236
+ context "when poll-interval header is higher than initial interval" do
237
+ subject do
238
+ described_class.new(
239
+ remote_adapter: remote_adapter,
240
+ start_automatically: false,
241
+ interval: 20
242
+ )
243
+ end
244
+
245
+ before do
246
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
247
+ .to_return(
248
+ status: 200,
249
+ body: JSON.generate(
250
+ features: [
251
+ {
252
+ key: "polling",
253
+ gates: [
254
+ { key: "boolean", value: true }
255
+ ]
256
+ }
257
+ ]
258
+ ),
259
+ headers: { "poll-interval" => "60" }
260
+ )
261
+ end
262
+
263
+ it "updates to the higher interval from header" do
264
+ expect(subject.interval).to eq(20.0)
265
+ subject.sync
266
+ expect(subject.interval).to eq(60.0) # Uses 60 because it's higher than initial 20
267
+ end
268
+ end
269
+
270
+ context "when poll-interval header can decrease back to initial interval" do
271
+ subject do
272
+ described_class.new(
273
+ remote_adapter: remote_adapter,
274
+ start_automatically: false,
275
+ interval: 10
276
+ )
277
+ end
278
+
279
+ before do
280
+ # First sync increases interval to 60
281
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
282
+ .to_return(
283
+ status: 200,
284
+ body: JSON.generate(
285
+ features: [
286
+ {
287
+ key: "polling",
288
+ gates: [
289
+ { key: "boolean", value: true }
290
+ ]
291
+ }
292
+ ]
293
+ ),
294
+ headers: { "poll-interval" => "60" }
295
+ ).times(1).then
296
+ .to_return(
297
+ status: 200,
298
+ body: JSON.generate(
299
+ features: [
300
+ {
301
+ key: "polling",
302
+ gates: [
303
+ { key: "boolean", value: true }
304
+ ]
305
+ }
306
+ ]
307
+ ),
308
+ headers: { "poll-interval" => "10" }
309
+ )
310
+ end
311
+
312
+ it "allows interval to go back down to initial after being increased" do
313
+ expect(subject.interval).to eq(10.0)
314
+
315
+ # First sync: header says 60, initial is 10, so use 60
316
+ subject.sync
317
+ expect(subject.interval).to eq(60.0)
318
+
319
+ # Second sync: header says 10, initial is 10, so use 10
320
+ subject.sync
321
+ expect(subject.interval).to eq(10.0)
322
+ end
323
+ end
324
+
325
+ context "when poll-interval header is missing" do
326
+ before do
327
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
328
+ .to_return(
329
+ status: 200,
330
+ body: JSON.generate(
331
+ features: [
332
+ {
333
+ key: "polling",
334
+ gates: [
335
+ { key: "boolean", value: true }
336
+ ]
337
+ }
338
+ ]
339
+ )
340
+ )
341
+ end
342
+
343
+ it "does not change the interval" do
344
+ original_interval = subject.interval
345
+ subject.sync
346
+ expect(subject.interval).to eq(original_interval)
347
+ end
348
+ end
37
349
  end
38
350
 
39
351
  describe "#start" do
@@ -43,5 +355,36 @@ RSpec.describe Flipper::Poller do
43
355
  expect(subject).to receive(:sync)
44
356
  subject.start
45
357
  end
358
+
359
+ context "after shutdown_requested" do
360
+ before do
361
+ stub_request(:get, "#{url}/features?exclude_gate_names=true")
362
+ .to_return(
363
+ status: 200,
364
+ body: JSON.generate(features: []),
365
+ headers: { "poll-shutdown" => "true" }
366
+ )
367
+ end
368
+
369
+ it "does not start when shutdown was requested" do
370
+ subject.sync # This triggers shutdown
371
+
372
+ expect(Thread).not_to receive(:new)
373
+ subject.start
374
+ end
375
+
376
+ it "allows starting after a fork" do
377
+ subject.sync # This triggers shutdown
378
+
379
+ # Simulate fork by changing PID
380
+ allow(Process).to receive(:pid).and_return(subject.instance_variable_get(:@pid) + 1)
381
+
382
+ # After fork, start should work again
383
+ expect(Thread).to receive(:new).and_yield
384
+ expect(subject).to receive(:loop).and_yield
385
+ expect(subject).to receive(:sync)
386
+ subject.start
387
+ end
388
+ end
46
389
  end
47
390
  end