state_machines 0.40.0 → 0.100.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: bc7dae3a88c68f1327da47debac2546fde7c4459346dbfefaabbc4bed5dc5554
4
- data.tar.gz: 2fbfc7157580635bd546b9096138677d44b58a92578d6341667a1722c91ccb66
3
+ metadata.gz: c2e1765c263d1be7747cf812e195b95ab82d183dbf62d83a4014bc47e34132ee
4
+ data.tar.gz: 35fc3eba5b2a7321436de83091467b0f9e65309356e7d948479acd46be85ee68
5
5
  SHA512:
6
- metadata.gz: 4b5ba6ecdeb4dd612c6865657e0a0b590d3fa67fed522359c7b388ab88401283d953d857fc73fab2fbeb97388c5bb71b5f4e125d70366a03547aea91b991ab35
7
- data.tar.gz: 07c96c5556ae74d933de3cf1b132cfda9144404361cca87c24c14694e2158e5f0437d65a4ab08b857dca8ad0c453d7fdc674abeb770c528d7ec45e4b060c8ddd
6
+ metadata.gz: 7b76d0c6596909901ccbeedf46b581b0fda2289466ae9c0bd402dc8b40db7d39fe74cbe7792a3b49c439e920ac6dd92c6bedfb51236155a3d0e61b0d7a84db51
7
+ data.tar.gz: 887b286630bd17fb51c9b00550ee3c6822fd3d1b0c2084630d4cf3e960c66abda12962e050bd09c1829e7091694d120378ad15607ba02f1f2594518b15173976
data/README.md CHANGED
@@ -36,6 +36,8 @@ Below is an example of many of the features offered by this plugin, including:
36
36
  * Namespaced states
37
37
  * Transition callbacks
38
38
  * Conditional transitions
39
+ * Coordinated state management guards
40
+ * Asynchronous state machines (async: true)
39
41
  * State-driven instance behavior
40
42
  * Customized state values
41
43
  * Parallel events
@@ -245,6 +247,206 @@ vehicle.alarm_state_name # => :active
245
247
 
246
248
  vehicle.fire_events!(:ignite, :enable_alarm) # => StateMachines:InvalidParallelTransition: Cannot run events in parallel: ignite, enable_alarm
247
249
 
250
+ # Coordinated State Management
251
+
252
+ State machines can coordinate with each other using state guards, allowing transitions to depend on the state of other state machines within the same object. This enables complex system modeling where components are interdependent.
253
+
254
+ ## State Guard Options
255
+
256
+ ### Single State Guards
257
+
258
+ * `:if_state` - Transition only if another state machine is in a specific state.
259
+ * `:unless_state` - Transition only if another state machine is NOT in a specific state.
260
+
261
+ ```ruby
262
+ class TorpedoSystem
263
+ state_machine :bay_doors, initial: :closed do
264
+ event :open do
265
+ transition closed: :open
266
+ end
267
+
268
+ event :close do
269
+ transition open: :closed
270
+ end
271
+ end
272
+
273
+ state_machine :torpedo_status, initial: :loaded do
274
+ event :fire_torpedo do
275
+ # Can only fire torpedo if bay doors are open
276
+ transition loaded: :fired, if_state: { bay_doors: :open }
277
+ end
278
+
279
+ event :reload do
280
+ # Can only reload if bay doors are closed (for safety)
281
+ transition fired: :loaded, unless_state: { bay_doors: :open }
282
+ end
283
+ end
284
+ end
285
+
286
+ system = TorpedoSystem.new
287
+ system.fire_torpedo # => false (bay doors are closed)
288
+
289
+ system.open_bay_doors!
290
+ system.fire_torpedo # => true (bay doors are now open)
291
+ ```
292
+
293
+ ### Multiple State Guards
294
+
295
+ * `:if_all_states` - Transition only if ALL specified state machines are in their respective states.
296
+ * `:unless_all_states` - Transition only if NOT ALL specified state machines are in their respective states.
297
+ * `:if_any_state` - Transition only if ANY of the specified state machines are in their respective states.
298
+ * `:unless_any_state` - Transition only if NONE of the specified state machines are in their respective states.
299
+
300
+ ```ruby
301
+ class StarshipBridge
302
+ state_machine :shields, initial: :down
303
+ state_machine :weapons, initial: :offline
304
+ state_machine :warp_core, initial: :stable
305
+
306
+ state_machine :alert_status, initial: :green do
307
+ event :red_alert do
308
+ # Red alert if ANY critical system needs attention
309
+ transition green: :red, if_any_state: { warp_core: :critical, shields: :down }
310
+ end
311
+
312
+ event :battle_stations do
313
+ # Battle stations only if ALL combat systems are ready
314
+ transition green: :battle, if_all_states: { shields: :up, weapons: :armed }
315
+ end
316
+ end
317
+ end
318
+ ```
319
+
320
+ ## Error Handling
321
+
322
+ State guards provide comprehensive error checking:
323
+
324
+ ```ruby
325
+ # Referencing a non-existent state machine
326
+ event :invalid, if_state: { nonexistent_machine: :some_state }
327
+ # => ArgumentError: State machine 'nonexistent_machine' is not defined for StarshipBridge
328
+
329
+ # Referencing a non-existent state
330
+ event :another_invalid, if_state: { shields: :nonexistent_state }
331
+ # => ArgumentError: State 'nonexistent_state' is not defined in state machine 'shields'
332
+ ```
333
+
334
+ # Asynchronous State Machines
335
+
336
+ State machines can operate asynchronously for high-performance applications. This is ideal for I/O-bound tasks, such as in web servers or other concurrent environments, where you don't want a long-running state transition (like one involving a network call) to block the entire thread.
337
+
338
+ This feature is powered by the [async](https://github.com/socketry/async) gem and uses `concurrent-ruby` for enterprise-grade thread safety.
339
+
340
+ ## Platform Compatibility
341
+
342
+ **Supported Platforms:**
343
+ * MRI Ruby (CRuby) 3.2+
344
+ * Other Ruby engines with full Fiber scheduler support
345
+
346
+ **Unsupported Platforms:**
347
+ * JRuby - Falls back to synchronous mode with warnings
348
+ * TruffleRuby - Falls back to synchronous mode with warnings
349
+
350
+ ## Basic Async Usage
351
+
352
+ Enable async mode by adding `async: true` to your state machine declaration:
353
+
354
+ ```ruby
355
+ class AutonomousDrone < StarfleetShip
356
+ # Async-enabled state machine for autonomous operation
357
+ state_machine :status, async: true, initial: :docked do
358
+ event :launch do
359
+ transition docked: :flying
360
+ end
361
+
362
+ event :land do
363
+ transition flying: :docked
364
+ end
365
+ end
366
+
367
+ # Mixed configuration: some machines async, others sync
368
+ state_machine :teleporter_status, async: true, initial: :offline do
369
+ event :power_up do
370
+ transition offline: :charging
371
+ end
372
+
373
+ event :teleport do
374
+ transition ready: :teleporting
375
+ end
376
+ end
377
+
378
+ # Weapons remain synchronous for safety
379
+ state_machine :weapons, initial: :disarmed do
380
+ event :arm do
381
+ transition disarmed: :armed
382
+ end
383
+ end
384
+ end
385
+ ```
386
+
387
+ ## Async Event Methods
388
+
389
+ Async-enabled machines automatically generate async versions of event methods:
390
+
391
+ ```ruby
392
+ drone = AutonomousDrone.new
393
+
394
+ # Within an Async context
395
+ Async do
396
+ # Async event firing - returns Async::Task
397
+ task = drone.launch_async
398
+ result = task.wait # => true
399
+
400
+ # Bang methods for strict error handling
401
+ drone.power_up_async! # => Async::Task (raises on failure)
402
+
403
+ # Generic async event firing
404
+ drone.fire_event_async(:teleport) # => Async::Task
405
+ end
406
+
407
+ # Outside Async context - raises error
408
+ drone.launch_async # => RuntimeError: launch_async must be called within an Async context
409
+ ```
410
+
411
+ ## Thread Safety
412
+
413
+ Async state machines use enterprise-grade thread safety with `concurrent-ruby`:
414
+
415
+ ```ruby
416
+ # Concurrent operations are automatically thread-safe
417
+ threads = []
418
+ 10.times do
419
+ threads << Thread.new do
420
+ Async do
421
+ drone.launch_async.wait
422
+ drone.land_async.wait
423
+ end
424
+ end
425
+ end
426
+ threads.each(&:join)
427
+ ```
428
+
429
+ ## Performance Considerations
430
+
431
+ * **Thread Safety**: Uses `Concurrent::ReentrantReadWriteLock` for optimal read/write performance.
432
+ * **Memory**: Each async-enabled object gets its own mutex (lazy-loaded).
433
+ * **Marshalling**: Objects with async state machines can be serialized (mutex excluded/recreated).
434
+ * **Mixed Mode**: You can mix async and sync state machines in the same class.
435
+
436
+ ## Dependencies
437
+
438
+ Async functionality requires:
439
+
440
+ ```ruby
441
+ # Gemfile (automatically scoped to MRI Ruby)
442
+ platform :ruby do
443
+ gem 'async', '>= 2.25.0'
444
+ gem 'concurrent-ruby', '>= 1.3.5'
445
+ end
446
+ ```
447
+
448
+ *Note: These gems are only installed on supported platforms. JRuby/TruffleRuby won't attempt installation.*
449
+
248
450
  # Human-friendly names can be accessed for states/events
249
451
  Vehicle.human_state_name(:first_gear) # => "first gear"
250
452
  Vehicle.human_alarm_state_name(:active) # => "active"
@@ -22,7 +22,7 @@ end
22
22
 
23
23
  # Load required gems with version constraints
24
24
  gem 'async', '>= 2.25.0'
25
- gem 'concurrent-ruby', '>= 1.3.5' # Security is not negotiable - enterprise-grade thread safety required
25
+ gem 'concurrent-ruby', '>= 1.3.5' # Security is not negotiable - enterprise-grade thread safety required
26
26
 
27
27
  require 'async'
28
28
  require 'concurrent-ruby'
@@ -34,6 +34,12 @@ module StateMachines
34
34
  # Build conditionals
35
35
  @if_condition = options.delete(:if)
36
36
  @unless_condition = options.delete(:unless)
37
+ @if_state_condition = options.delete(:if_state)
38
+ @unless_state_condition = options.delete(:unless_state)
39
+ @if_all_states_condition = options.delete(:if_all_states)
40
+ @unless_all_states_condition = options.delete(:unless_all_states)
41
+ @if_any_state_condition = options.delete(:if_any_state)
42
+ @unless_any_state_condition = options.delete(:unless_any_state)
37
43
 
38
44
  # Build event requirement
39
45
  @event_requirement = build_matcher(options, :on, :except_on)
@@ -88,6 +94,9 @@ module StateMachines
88
94
  def matches?(object, query = {})
89
95
  !match(object, query).nil?
90
96
  end
97
+
98
+ # Alias for Minitest's assert_match
99
+ alias =~ matches?
91
100
 
92
101
  # Attempts to match the given object / query against the set of requirements
93
102
  # configured for this branch. In addition to matching the event, from state,
@@ -182,21 +191,56 @@ module StateMachines
182
191
  # Verifies that the conditionals for this branch evaluate to true for the
183
192
  # given object. Event arguments are passed to guards that accept multiple parameters.
184
193
  def matches_conditions?(object, query, event_args = [])
185
- case [query[:guard], if_condition, unless_condition]
186
- in [false, _, _]
187
- true
188
- in [_, nil, nil]
189
- true
190
- in [_, if_conds, nil] if if_conds
191
- Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
192
- in [_, nil, unless_conds] if unless_conds
193
- Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
194
- in [_, if_conds, unless_conds] if if_conds || unless_conds
195
- Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) } &&
196
- Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
197
- else
198
- true
194
+ return true if query[:guard] == false
195
+
196
+ # Evaluate original if/unless conditions
197
+ if_passes = !if_condition || Array(if_condition).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
198
+ unless_passes = !unless_condition || Array(unless_condition).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
199
+
200
+ return false unless if_passes && unless_passes
201
+
202
+ # Consolidate all state guards
203
+ state_guards = {
204
+ if_state: @if_state_condition,
205
+ unless_state: @unless_state_condition,
206
+ if_all_states: @if_all_states_condition,
207
+ unless_all_states: @unless_all_states_condition,
208
+ if_any_state: @if_any_state_condition,
209
+ unless_any_state: @unless_any_state_condition
210
+ }.compact
211
+
212
+ return true if state_guards.empty?
213
+
214
+ validate_and_check_state_guards(object, state_guards)
215
+ end
216
+
217
+ private
218
+
219
+ def validate_and_check_state_guards(object, guards)
220
+ guards.all? do |guard_type, conditions|
221
+ case guard_type
222
+ when :if_state, :if_all_states
223
+ conditions.all? { |machine, state| check_state(object, machine, state) }
224
+ when :unless_state
225
+ conditions.none? { |machine, state| check_state(object, machine, state) }
226
+ when :if_any_state
227
+ conditions.any? { |machine, state| check_state(object, machine, state) }
228
+ when :unless_all_states
229
+ !conditions.all? { |machine, state| check_state(object, machine, state) }
230
+ when :unless_any_state
231
+ conditions.none? { |machine, state| check_state(object, machine, state) }
232
+ end
199
233
  end
200
234
  end
235
+
236
+ def check_state(object, machine_name, state_name)
237
+ machine = object.class.state_machines[machine_name]
238
+ raise ArgumentError, "State machine '#{machine_name}' is not defined for #{object.class.name}" unless machine
239
+
240
+ state = machine.states[state_name]
241
+ raise ArgumentError, "State '#{state_name}' is not defined in state machine '#{machine_name}'" unless state
242
+
243
+ state.matches?(object.send(machine_name))
244
+ end
201
245
  end
202
246
  end
@@ -114,12 +114,10 @@ module StateMachines
114
114
  # Input validation for string evaluation
115
115
  validate_eval_string(str)
116
116
 
117
- case [block_given?, StateMachines::Transition.pause_supported?]
118
- in [true, true]
119
- eval(str, object.instance_eval { binding }, &block)
120
- in [true, false]
121
- # Support for JRuby and Truffle Ruby, which don't support binding blocks
122
- # Need to check with @headius, if jruby 10 does now.
117
+ # Evaluate the string in the object's context
118
+ if block_given?
119
+ # TruffleRuby and some other implementations need special handling for blocks
120
+ # Create a temporary method to evaluate the string with block support
123
121
  eigen = class << object; self; end
124
122
  eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
125
123
  def __temp_eval_method__(*args, &b)
@@ -129,8 +127,8 @@ module StateMachines
129
127
  result = object.__temp_eval_method__(*args, &block)
130
128
  eigen.send(:remove_method, :__temp_eval_method__)
131
129
  result
132
- in [false, _]
133
- eval(str, object.instance_eval { binding })
130
+ else
131
+ object.instance_eval(str, __FILE__, __LINE__)
134
132
  end
135
133
  else
136
134
  raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
@@ -104,7 +104,7 @@ module StateMachines
104
104
 
105
105
  # Only a certain subset of explicit options are allowed for transition
106
106
  # requirements
107
- StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless) if (options.keys - %i[from to on except_from except_to except_on if unless]).empty?
107
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless, :if_state, :unless_state, :if_all_states, :unless_all_states, :if_any_state, :unless_any_state) if (options.keys - %i[from to on except_from except_to except_on if unless if_state unless_state if_all_states unless_all_states if_any_state unless_any_state]).empty?
108
108
 
109
109
  branches << branch = Branch.new(options.merge(on: name))
110
110
  @known_states |= branch.known_states
@@ -119,12 +119,12 @@ module StateMachines
119
119
  # TODO, simplify
120
120
  # First try the regular event_transition
121
121
  transition = machine.read(object, :event_transition)
122
-
122
+
123
123
  # If not found and we have stored transitions by machine (issue #91)
124
124
  if !transition && (transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions))
125
125
  transition = transitions_by_machine[machine.name]
126
126
  end
127
-
127
+
128
128
  transition || if event_name = machine.read(object, :event)
129
129
  if event = self[event_name.to_sym, :name]
130
130
  event.transition_for(object) || begin
@@ -30,7 +30,7 @@ module StateMachines
30
30
 
31
31
  owner_class.include(StateMachines::AsyncMode::ThreadSafeState)
32
32
  owner_class.include(StateMachines::AsyncMode::AsyncEvents)
33
- self.extend(StateMachines::AsyncMode::AsyncMachine)
33
+ extend(StateMachines::AsyncMode::AsyncMachine)
34
34
 
35
35
  # Extend events to generate async versions
36
36
  events.each do |event|
@@ -31,9 +31,7 @@ module StateMachines
31
31
  machine.initial_state = options[:initial] if options.include?(:initial)
32
32
  machine.owner_class = owner_class
33
33
  # Configure async mode if requested in options
34
- if options.include?(:async)
35
- machine.configure_async_mode!(options[:async])
36
- end
34
+ machine.configure_async_mode!(options[:async]) if options.include?(:async)
37
35
  end
38
36
 
39
37
  # Evaluate DSL
@@ -51,9 +51,7 @@ module StateMachines
51
51
  instance_eval(&) if block_given?
52
52
 
53
53
  # Configure async mode if requested, after owner_class is set and DSL is evaluated
54
- if @async_requested
55
- configure_async_mode!(true)
56
- end
54
+ configure_async_mode!(true) if @async_requested
57
55
 
58
56
  self.initial_state = options[:initial] unless sibling_machines.any?
59
57
  end
@@ -565,8 +565,6 @@ module StateMachines
565
565
  end
566
566
  end
567
567
  else
568
- # Validate string input before eval if method is a string
569
- validate_eval_string(method) if method.is_a?(String)
570
568
  helper_module.class_eval(method, __FILE__, __LINE__)
571
569
  end
572
570
  end
@@ -749,7 +749,6 @@ module StateMachines
749
749
  has_async = object.respond_to?(async_method)
750
750
  has_async_bang = object.respond_to?(async_bang_method)
751
751
 
752
- default_message = "Expected async event methods #{async_method} and #{async_bang_method} to be available for event :#{event}"
753
752
 
754
753
  if defined?(::Minitest)
755
754
  assert has_async, "Missing #{async_method} method"
@@ -30,19 +30,15 @@ module StateMachines
30
30
  # Whether the transition is only existing temporarily for the object
31
31
  attr_writer :transient
32
32
 
33
- # Determines whether the current ruby implementation supports pausing and
34
- # resuming transitions
35
- def self.pause_supported?
36
- %w[ruby maglev].include?(RUBY_ENGINE)
37
- end
38
-
39
33
  # Creates a new, specific transition
40
34
  def initialize(object, machine, event, from_name, to_name, read_state = true) # :nodoc:
41
35
  @object = object
42
36
  @machine = machine
43
37
  @args = []
44
38
  @transient = false
45
- @resume_block = nil
39
+ @paused_fiber = nil
40
+ @resuming = false
41
+ @continuation_block = nil
46
42
 
47
43
  @event = machine.events.fetch(event)
48
44
  @from_state = machine.states.fetch(from_name)
@@ -161,13 +157,13 @@ module StateMachines
161
157
  # transition.perform(Time.now, run_action: false) # => Passes in additional arguments and only sets the state attribute
162
158
  def perform(*args)
163
159
  run_action = case args.last
164
- in true | false
165
- args.pop
166
- in { run_action: }
167
- args.last.delete(:run_action)
168
- else
169
- true
170
- end
160
+ in true | false
161
+ args.pop
162
+ in { run_action: }
163
+ args.last.delete(:run_action)
164
+ else
165
+ true
166
+ end
171
167
 
172
168
  self.args = args
173
169
 
@@ -195,14 +191,32 @@ module StateMachines
195
191
  # callbacks will not have an effect on the result.
196
192
  def run_callbacks(options = {}, &block)
197
193
  options = { before: true, after: true }.merge(options)
198
- @success = false
199
194
 
200
- halted = pausable { before(options[:after], &block) } if options[:before]
195
+ # If we have a paused fiber and we're not trying to resume (after: false),
196
+ # this is an idempotent call on an already-paused transition. Just return true.
197
+ return true if @paused_fiber&.alive? && !options[:after]
198
+
199
+ # Extract pausable options
200
+ pausable_options = options.key?(:fiber) ? { fiber: options[:fiber] } : {}
201
+
202
+ # Check if we're resuming from a pause
203
+ if @paused_fiber&.alive? && options[:after]
204
+ # Resume the paused fiber
205
+ # Don't reset @success when resuming - preserve the state from the pause
206
+ # Store the block for later execution
207
+ @continuation_block = block if block_given?
208
+ halted = pausable(pausable_options) { true }
209
+ else
210
+ @success = false
211
+ # For normal execution (not pause/resume), default to success
212
+ # The action block will override this if needed
213
+ halted = pausable(pausable_options) { before(options[:after], &block) } if options[:before]
214
+ end
201
215
 
202
216
  # After callbacks are only run if:
203
- # * An around callback didn't halt after yielding
217
+ # * An around callback didn't halt after yielding OR the run failed
204
218
  # * They're enabled or the run didn't succeed
205
- after if !(@before_run && halted) && (options[:after] || !@success)
219
+ after if (!(@before_run && halted) || !@success) && (options[:after] || !@success)
206
220
 
207
221
  @before_run
208
222
  end
@@ -266,7 +280,9 @@ module StateMachines
266
280
  # the state has already been persisted
267
281
  def reset
268
282
  @before_run = @persisted = @after_run = false
269
- @paused_block = nil
283
+ @paused_fiber = nil
284
+ @resuming = false
285
+ @continuation_block = nil
270
286
  end
271
287
 
272
288
  # Determines equality of transitions by testing whether the object, states,
@@ -290,6 +306,38 @@ module StateMachines
290
306
  "#<#{self.class} #{%w[attribute event from from_name to to_name].map { |attr| "#{attr}=#{send(attr).inspect}" } * ' '}>"
291
307
  end
292
308
 
309
+ # Checks whether this transition is currently paused.
310
+ # Returns true if there is a paused fiber, false otherwise.
311
+ def paused?
312
+ @paused_fiber&.alive? || false
313
+ end
314
+
315
+ # Checks whether this transition has a paused fiber that can be resumed.
316
+ # Returns true if there is a paused fiber, false otherwise.
317
+ #
318
+ # Note: The actual resuming happens automatically when run_callbacks is called
319
+ # again on a transition with a paused fiber.
320
+ def resumable?
321
+ paused?
322
+ end
323
+
324
+ # Manually resumes the execution of a previously paused callback.
325
+ # Returns true if the transition was successfully resumed and completed,
326
+ # false if there was no paused fiber, and raises an exception if the
327
+ # transition was halted.
328
+ def resume!(&block)
329
+ return false unless paused?
330
+
331
+ # Store continuation block if provided
332
+ @continuation_block = block if block_given?
333
+
334
+ # Run the pausable block which will resume the fiber
335
+ halted = pausable { true }
336
+
337
+ # Return whether the transition completed successfully
338
+ !halted
339
+ end
340
+
293
341
  private
294
342
 
295
343
  # Runs a block that may get paused. If the block doesn't pause, then
@@ -298,20 +346,97 @@ module StateMachines
298
346
  #
299
347
  # This will return true if the given block halts for a reason other than
300
348
  # getting paused.
301
- def pausable
302
- begin
349
+ #
350
+ # Options:
351
+ # * :fiber - Whether to use fiber-based execution (default: true)
352
+ def pausable(options = {})
353
+ # If fiber is disabled, use simple synchronous execution
354
+ if options[:fiber] == false
303
355
  halted = !catch(:halt) do
304
356
  yield
305
357
  true
306
358
  end
307
- rescue StandardError => e
308
- raise unless @resume_block
359
+ return halted
309
360
  end
310
361
 
311
- if @resume_block
312
- @resume_block.call(halted, e)
362
+ if @paused_fiber
363
+ # Resume the paused fiber
364
+ @resuming = true
365
+ begin
366
+ result = @paused_fiber.resume
367
+ rescue StandardError => e
368
+ # Clean up on exception
369
+ @resuming = false
370
+ @paused_fiber = nil
371
+ raise e
372
+ end
373
+ @resuming = false
374
+
375
+ # Handle different result types
376
+ case result
377
+ when Array
378
+ # Exception occurred inside the fiber
379
+ if result[0] == :error
380
+ # Clean up state before re-raising
381
+ @paused_fiber = nil
382
+ raise result[1]
383
+ end
384
+ else
385
+ # Normal flow
386
+ # Check if fiber is still alive after resume
387
+ if @paused_fiber.alive?
388
+ # Still paused, keep the fiber
389
+ true
390
+ else
391
+ # Fiber completed
392
+ @paused_fiber = nil
393
+ result == :halted
394
+ end
395
+ end
313
396
  else
314
- halted
397
+ # Create a new fiber to run the block
398
+ fiber = Fiber.new do
399
+ # Mark that we're inside a pausable fiber
400
+ Thread.current[:state_machine_fiber_pausable] = true
401
+ begin
402
+ halted = !catch(:halt) do
403
+ yield
404
+ true
405
+ end
406
+ halted ? :halted : :completed
407
+ rescue StandardError => e
408
+ # Store the exception for re-raising
409
+ [:error, e]
410
+ ensure
411
+ # Clean up the flag
412
+ Thread.current[:state_machine_fiber_pausable] = false
413
+ end
414
+ end
415
+
416
+ # Run the fiber
417
+ result = fiber.resume
418
+
419
+ # Handle different result types
420
+ case result
421
+ when Array
422
+ # Exception occurred
423
+ if result[0] == :error
424
+ # Clean up state before re-raising
425
+ @paused_fiber = nil
426
+ raise result[1]
427
+ end
428
+ else
429
+ # Normal flow
430
+ # Save if paused
431
+ if fiber.alive?
432
+ @paused_fiber = fiber
433
+ # Return true to indicate paused (treated as halted for flow control)
434
+ true
435
+ else
436
+ # Fiber completed, return whether it was halted
437
+ result == :halted
438
+ end
439
+ end
315
440
  end
316
441
  end
317
442
 
@@ -319,34 +444,22 @@ module StateMachines
319
444
  # around callbacks when the remainder of the callback will be executed at
320
445
  # a later point in time.
321
446
  def pause
322
- raise ArgumentError, 'around_transition callbacks cannot be called in multiple execution contexts in java implementations of Ruby. Use before/after_transitions instead.' unless self.class.pause_supported?
323
-
324
- return if @resume_block
325
-
326
- require 'continuation' unless defined?(callcc)
327
- callcc do |block|
328
- @paused_block = block
329
- throw :halt, true
330
- end
331
- end
447
+ # Don't pause if we're in the middle of resuming
448
+ return if @resuming
332
449
 
333
- # Resumes the execution of a previously paused callback execution. Once
334
- # the paused callbacks complete, the current execution will continue.
335
- def resume
336
- if @paused_block
337
- halted, error = callcc do |block|
338
- @resume_block = block
339
- @paused_block.call
340
- end
450
+ # Only yield if we're actually inside a fiber created by pausable
451
+ # We use a thread-local variable to track this
452
+ return unless Thread.current[:state_machine_fiber_pausable]
341
453
 
342
- @resume_block = @paused_block = nil
454
+ Fiber.yield
343
455
 
344
- raise error if error
456
+ # When we resume from the pause, execute the continuation block if present
457
+ return unless @continuation_block && !@result
345
458
 
346
- !halted
347
- else
348
- true
349
- end
459
+ action = { success: true }.merge(@continuation_block.call)
460
+ @result = action[:result]
461
+ @success = action[:success]
462
+ @continuation_block = nil
350
463
  end
351
464
 
352
465
  # Runs the machine's +before+ callbacks for this transition. Only
@@ -356,36 +469,51 @@ module StateMachines
356
469
  # Once the callbacks are run, they cannot be run again until this transition
357
470
  # is reset.
358
471
  def before(complete = true, index = 0, &block)
359
- unless @before_run
360
- while callback = machine.callbacks[:before][index]
361
- index += 1
472
+ return if @before_run
473
+
474
+ callback = machine.callbacks[:before][index]
362
475
 
476
+ if callback
477
+ # Check if callback matches this transition using branch
478
+ if callback.branch.matches?(object, context)
363
479
  if callback.type == :around
364
480
  # Around callback: need to handle recursively. Execution only gets
365
481
  # paused if:
366
482
  # * The block fails and the callback doesn't run on failures OR
367
483
  # * The block succeeds, but after callbacks are disabled (in which
368
484
  # case a continuation is stored for later execution)
369
- return if catch(:cancel) do
370
- callback.call(object, context, self) do
371
- before(complete, index, &block)
485
+ callback.call(object, context, self) do
486
+ before(complete, index + 1, &block)
487
+
488
+ pause if @success && !complete
372
489
 
373
- pause if @success && !complete
374
- throw :cancel, true unless @success
375
- end
490
+ # If the block failed (success is false), we should halt
491
+ # the around callback from continuing
492
+ throw :halt unless @success
376
493
  end
377
494
  else
378
495
  # Normal before callback
379
496
  callback.call(object, context, self)
497
+ # Continue with next callback
498
+ before(complete, index + 1, &block)
380
499
  end
500
+ else
501
+ # Skip to next callback if it doesn't match
502
+ before(complete, index + 1, &block)
503
+ end
504
+ else
505
+ # No more callbacks, execute the action block if at the end
506
+ if block_given?
507
+ action = { success: true }.merge(yield)
508
+ @result = action[:result]
509
+ @success = action[:success]
510
+ else
511
+ # No action block provided, default to success
512
+ @success = true
381
513
  end
382
514
 
383
515
  @before_run = true
384
516
  end
385
-
386
- action = { success: true }.merge(block_given? ? yield : {})
387
- @result = action[:result]
388
- @success = action[:success]
389
517
  end
390
518
 
391
519
  # Runs the machine's +after+ callbacks for this transition. Only
@@ -404,12 +532,9 @@ module StateMachines
404
532
  def after
405
533
  return if @after_run
406
534
 
407
- # First resume previously paused callbacks
408
- if resume
409
- catch(:halt) do
410
- type = @success ? :after : :failure
411
- machine.callbacks[type].each { |callback| callback.call(object, context, self) }
412
- end
535
+ catch(:halt) do
536
+ type = @success ? :after : :failure
537
+ machine.callbacks[type].each { |callback| callback.call(object, context, self) }
413
538
  end
414
539
 
415
540
  @after_run = true
@@ -14,6 +14,9 @@ module StateMachines
14
14
  # Whether transitions should wrapped around a transaction block
15
15
  attr_reader :use_transactions
16
16
 
17
+ # Options passed to the collection
18
+ attr_reader :options
19
+
17
20
  # Creates a new collection of transitions that can be run in parallel. Each
18
21
  # transition *must* be for a different attribute.
19
22
  #
@@ -26,16 +29,23 @@ module StateMachines
26
29
 
27
30
  # Determine the validity of the transitions as a whole
28
31
  @valid = all?
29
- reject! { |transition| !transition }
32
+ reject!(&:!)
30
33
 
31
- attributes = map { |transition| transition.attribute }.uniq
34
+ attributes = map(&:attribute).uniq
32
35
  raise ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != length
33
36
 
34
- StateMachines::OptionsValidator.assert_valid_keys!(options, :actions, :after, :use_transactions)
37
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :actions, :after, :use_transactions, :fiber)
35
38
  options = { actions: true, after: true, use_transactions: true }.merge(options)
36
39
  @skip_actions = !options[:actions]
37
40
  @skip_after = !options[:after]
38
41
  @use_transactions = options[:use_transactions]
42
+ @options = options
43
+
44
+ # Reset transitions when creating a new collection
45
+ # But preserve paused transitions to allow resuming
46
+ each do |transition|
47
+ transition.reset unless transition.paused?
48
+ end
39
49
  end
40
50
 
41
51
  # Runs each of the collection's transitions in parallel.
@@ -104,7 +114,7 @@ module StateMachines
104
114
  # Gets the list of actions to run. If configured to skip actions, then
105
115
  # this will return an empty collection.
106
116
  def actions
107
- empty? ? [nil] : map { |transition| transition.action }.uniq
117
+ empty? ? [nil] : map(&:action).uniq
108
118
  end
109
119
 
110
120
  # Determines whether an event attribute be used to trigger the transitions
@@ -127,11 +137,21 @@ module StateMachines
127
137
  #
128
138
  # If any transition fails to run its callbacks, :halt will be thrown.
129
139
  def run_callbacks(index = 0, &block)
130
- if transition = self[index]
131
- throw :halt unless transition.run_callbacks(after: !skip_after) do
140
+ if (transition = self[index])
141
+ # Pass through any options that affect callback execution (e.g., fiber: false)
142
+ callback_options = { after: !skip_after }
143
+ callback_options[:fiber] = options[:fiber] if options.key?(:fiber)
144
+
145
+ callback_result = transition.run_callbacks(callback_options) do
132
146
  run_callbacks(index + 1, &block)
133
147
  { result: results[transition.action], success: success? }
134
148
  end
149
+
150
+ # If we're skipping after callbacks and the transition is paused,
151
+ # consider it successful (the pause was intentional)
152
+ @success = true if skip_after && transition.paused?
153
+
154
+ throw :halt unless callback_result
135
155
  else
136
156
  persist
137
157
  run_actions(&block)
@@ -141,7 +161,7 @@ module StateMachines
141
161
  # Transitions the current value of the object's states to those specified by
142
162
  # each transition
143
163
  def persist
144
- each { |transition| transition.persist }
164
+ each(&:persist)
145
165
  end
146
166
 
147
167
  # Runs the actions for each transition. If a block is given method, then it
@@ -163,7 +183,7 @@ module StateMachines
163
183
 
164
184
  # Rolls back changes made to the object's states via each transition
165
185
  def rollback
166
- each { |transition| transition.rollback }
186
+ each(&:rollback)
167
187
  end
168
188
 
169
189
  # Wraps the given block with a rescue handler so that any exceptions that
@@ -202,14 +222,14 @@ module StateMachines
202
222
  # Hooks into running transition callbacks so that event / event transition
203
223
  # attributes can be properly updated
204
224
  def run_callbacks(index = 0)
205
- if index == 0
225
+ if index.zero?
206
226
  # Clears any traces of the event attribute to prevent it from being
207
227
  # evaluated multiple times if actions are nested
208
228
  each do |transition|
209
229
  transition.machine.write(object, :event, nil)
210
230
  transition.machine.write(object, :event_transition, nil)
211
231
  end
212
-
232
+
213
233
  # Clear stored transitions hash for new cycle (issue #91)
214
234
  if !empty? && (obj = first.object)
215
235
  obj.instance_variable_set(:@_state_machine_event_transitions, nil)
@@ -229,9 +249,9 @@ module StateMachines
229
249
  # after callbacks.
230
250
  if skip_after && success?
231
251
  each { |transition| transition.machine.write(object, :event_transition, transition) }
232
-
252
+
233
253
  # Store transitions in a hash by machine name to avoid overwriting (issue #91)
234
- if !empty?
254
+ unless empty?
235
255
  transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions) || {}
236
256
  each { |transition| transitions_by_machine[transition.machine.name] = transition }
237
257
  object.instance_variable_set(:@_state_machine_event_transitions, transitions_by_machine)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StateMachines
4
- VERSION = '0.40.0'
4
+ VERSION = '0.100.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: state_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.0
4
+ version: 0.100.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih