Class: EventState::Machine

Inherits:
EventMachine::Connection
  • Object
show all
Defined in:
lib/event_state/machine.rb

Overview

Base class for state machines driven by EventMachine.

This class provides a domain-specific language (DSL) for declaring the structure of the machine. See the README for examples and the general idea of how this works.

If you are sending ruby objects as messages, see ObjectMachine; it handles serialization (using EventMachine’s ObjectProtocol) and names messages according to their classes (but you can override this).

If you have some other kind of messages, then you should subclass this class directly. Two methods are required:

  1. Override EventMachine’s receive_data method to call #transition_on_recv with the received message.

  2. Override EventMachine’s send_data method to call #transition_on_send with the message to be sent.

Note that #transition_on_recv and #transition_on_send take a message name as well as a message. You have to define the mapping from messages to message names so that the message names correspond with the transitions declared using the DSL (Machine.on_send and Machine.on_recv in particular).

Direct Known Subclasses

ObjectMachine

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.start_stateState (readonly)

Returns the machine enters this state when a new connection is established.

Returns:

  • (State)

    the machine enters this state when a new connection is established



292
293
294
# File 'lib/event_state/machine.rb', line 292

def start_state
  @start_state
end

.statesHash<Symbol, State> (readonly)

Returns map from state names (ruby symbols) to States.

Returns:

  • (Hash<Symbol, State>)

    map from state names (ruby symbols) to States



286
287
288
# File 'lib/event_state/machine.rb', line 286

def states
  @states
end

Instance Attribute Details

#stateState (readonly)

Returns the machine’s current state.

Returns:

  • (State)

    the machine’s current state



375
376
377
# File 'lib/event_state/machine.rb', line 375

def state
  @state
end

Class Method Details

.on_enter(*message_names) {|message| ... } ⇒ nil

Register a block to be called after the machine enters the current state.

The machine changes state in response to a message being sent or received, and you can register an on_enter handler that is specific to the message that caused the change. Or you can render a catch-all block that will be called if no more specific handler was found (see example below).

If a catch-all on_enter block is given for the start_state, it is called from EventMachine’s post_init method. In this case (and only this case), the message passed to the block is nil.

Examples:

state :foo do
  on_enter :my_message do |message|
    # got here due to a :my_message message
  end

  on_enter do
    # got here some other way
  end
end

Parameters:

  • message_names (Array<Symbol>)

    zero or more

Yields:

  • (message)

Yield Parameters:

  • message (Message, nil)

    nil iff this is the start state and the machine has just started up (called from post_init)

Returns:

  • (nil)


158
159
160
161
162
163
164
165
166
167
# File 'lib/event_state/machine.rb', line 158

def on_enter *message_names, &block
  raise "on_enter must be called from a state block" unless @state
  if message_names == []
    raise "on_enter block already given" if @state.default_on_enter
    @state.default_on_enter = block
  else
    save_state_handlers('on_enter', @state.on_enters, message_names,block)
  end
  nil
end

.on_exit(*message_names) {|message| ... } ⇒ nil

Register a block to be called after the machine exits the current state. See on_enter for more information.

Parameters:

  • message_names (Array<Symbol>)

    zero or more

Yields:

  • (message)

Yield Parameters:

  • message (Message)

Returns:

  • (nil)


181
182
183
184
185
186
187
188
189
190
# File 'lib/event_state/machine.rb', line 181

def on_exit *message_names, &block
  raise "on_exit must be called from a state block" unless @state
  if message_names == []
    raise "on_exit block already given" if @state.default_on_exit
    @state.default_on_exit = block
  else
    save_state_handlers('on_exit', @state.on_exits, message_names, block)
  end
  nil
end

.on_recv(*args) ⇒ nil

Declare which state the machine transitions to when one of the given messages is received in this state. See on_send for more information.

Parameters:

  • message_names (Array<Symbol>)

    one or more

  • next_state_name (Symbol)

Returns:

  • (nil)


246
247
248
249
250
251
252
253
# File 'lib/event_state/machine.rb', line 246

def on_recv *args
  next_state_name = args.pop
  message_names = args
  raise "on_recv must be called from a state block" unless @state
  message_names.flatten.each do |name|
    @state.on_recvs[name] = next_state_name
  end
end

.on_send(*args) ⇒ nil

Declare which state the machine transitions to when one of the given messages is sent in this state.

Examples:

state :foo do
  on_enter do
    EM.defer proc { sleep 3 },
             proc { send_message DoneMessage.new(42) }
  end
  on_send DoneMessage, :bar
end

Parameters:

  • message_names (Array<Symbol>)

    one or more

  • next_state_name (Symbol)

Returns:

  • (nil)


211
212
213
214
215
216
217
218
# File 'lib/event_state/machine.rb', line 211

def on_send *args
  next_state_name = args.pop
  message_names = args
  raise "on_send must be called from a state block" unless @state
  message_names.flatten.each do |name|
    @state.on_sends[name] = next_state_name
  end
end

.on_unbind { ... } ⇒ nil

Called when EventMachine calls unbind on the connection while it is in the current state. This may indicate that the connection has been closed or timed out. The default is to take no action.

Yields:

  • called upon unbind

Returns:

  • (nil)


229
230
231
232
233
# File 'lib/event_state/machine.rb', line 229

def on_unbind &block
  raise "on_unbind must be called from a state block" unless @state
  @state.on_unbind = block
  nil
end

Print the state machine in dot (graphviz) format.

By default, the ‘send’ edges are red and ‘receive’ edges are blue, and the start state is indicated by a double border.

Parameters:

  • opts (Hash) (defaults to: {})

    extra options

Options Hash (opts):

  • :io (IO) — default: StringIO.new

    to print to

  • :graph_options (String) — default: ''

    dot graph options

  • :message_name_transform (Proc)

    transform message names

  • :state_name_form (Proc)

    transform state names

  • :recv_edge_style (String) — default: 'color=blue'
  • :send_edge_style (String) — default: 'color=red'

Returns:

  • (IO)

    the :io option



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/event_state/machine.rb', line 330

def print_state_machine_dot opts={}
  io                     = opts[:io] || StringIO.new
  graph_options          = opts[:graph_options] || ''
  message_name_transform = opts[:message_name_transform] || proc {|x| x}
  state_name_transform   = opts[:state_name_transform] || proc {|x| x}
  recv_edge_style        = opts[:recv_edge_style] || 'color=blue'
  send_edge_style        = opts[:send_edge_style] || 'color=red'

  io.puts "digraph #{self.name.inspect} {\n  #{graph_options}"

  io.puts "  #{start_state.name} [peripheries=2];" # double border
  
  transitions.each do |state_name, (kind, message_name), next_state_name|
    s0 = state_name_transform.call(state_name)
    s1 = state_name_transform.call(next_state_name)
    label = message_name_transform.call(message_name)

    style = case kind
            when :recv then
              "#{recv_edge_style},label=\"#{label}\""
            when :send then
              "#{send_edge_style},label=\"#{label}\""
            else 
              raise "unknown kind #{kind}"
            end
    io.puts "  #{s0} -> #{s1} [#{style}];"
  end
  io.puts "}"

  io
end

.protocol { ... } ⇒ nil

Declare the protocol; pass a block to declare the states.

When the block terminates, this method declares any ‘implicit’ states that have been referenced by on_send or on_recv but that have not been declared with state. It also does some basic sanity checking.

There can be multiple protocol blocks declared for one class; it is equivalent to moving all of the definitions to the same block.

Yields:

  • declare the states in the protocol

Returns:

  • (nil)


40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/event_state/machine.rb', line 40

def protocol
  raise "cannot nest protocol blocks" if defined?(@protocol) && @protocol

  @start_state = nil unless defined?(@start_state)
  @states = {}       unless defined?(@states)

  @protocol = true
  @state = nil
  yield
  @protocol = false

  # add implicitly defined states to @states to avoid having to check for
  # nil states while the machine is running
  explicit_states = Set[*@states.keys]
  all_states = Set[*@states.values.map {|state|
    state.on_sends.values + state.on_recvs.values}.flatten]
  implicit_states = all_states - explicit_states
  implicit_states.each do |state_name|
    @states[state_name] = State.new(state_name)
  end
end

.reverse_protocol(base) { ... } ⇒ nil

Exchange sends for receives and receives for sends in the base protocol, and clear all of the on_enter and on_exit handlers. It often happens that a server and a client follow protocols with the same states, but with sends and receives interchanged. This method is intended to help with this case. You can, for example, reverse a server and use the passed block to define new on_enter and on_exit handlers appropriate for the client.

The start state is determined by the first state declared in the given block (not by the protocol being reversed).

Parameters:

  • base (Class)

Yields:

Returns:

  • (nil)


80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/event_state/machine.rb', line 80

def reverse_protocol base, &block
  raise "cannot mirror if already have a protocol" if defined?(@protocol)

  @states = Hash[base.states.map {|state_name, state|
    new_state = EventState::State.new(state_name)
    new_state.on_recvs = state.on_sends.dup
    new_state.on_sends = state.on_recvs.dup
    [state_name, new_state]
  }]

  protocol(&block)
end

.state(state_name) { ... } ⇒ nil

Declare a state; pass a block to configure the state using on_enter, on_send and so on.

By default, the machine’s start state is the first state declared using this method.

Yields:

  • configure the state

Returns:

  • (nil)


104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/event_state/machine.rb', line 104

def state state_name
  raise "must be called from within a protocol block" unless @protocol
  raise "cannot nest calls to state" if @state

  # create new state or edit exiting state
  @state = @states[state_name] || State.new(state_name)

  # configure this state 
  yield

  # need to know the start state
  @start_state = @state unless @start_state

  # index by name for easy lookup
  @states[@state.name] = @state

  # ensure that on_enter etc. aren't callable outside of a state block
  @state = nil
end

.timeout(timeout) { ... } ⇒ nil

Set maximum time (in seconds) that the machine should spend in this state. By default, there is no limit. If you set a timeout without passing a block, the default action is to call close_connection.

Note that the timeout block will not be executed if unbind is called in this state. However, if the timeout block calls close_connection, the state’s on_unbind handler will be called.

Note that the timeout applies to all machines. If you want to set a timeout ‘at run time’ that applies to the machine only while it is in a particular state, see #add_state_timer.

Internally, this uses EventMachine’s add_timer method. It is independent of EventMachine’s comm_inactivity_timeout.

Yields:

  • called once timeout elapses

Returns:

  • (nil)


275
276
277
278
279
280
# File 'lib/event_state/machine.rb', line 275

def timeout timeout, &block
  raise "on_recv must be called from a state block" unless @state
  @state.timeout = timeout
  @state.on_timeout = block if block_given?
  nil
end

.transitionsArray

The complete list of transitions declared in the state machine (an edge list).

Returns:

  • (Array)

    each entry is of the form [state_name, [:send | :recv, message_name], next_state_name]



301
302
303
304
305
306
# File 'lib/event_state/machine.rb', line 301

def transitions
  states.values.map{|state|
    [[state.on_sends, :send], [state.on_recvs, :recv]].map {|pairs, kind|
      pairs.map{|message, next_state_name|
        [state.name, [kind, message], next_state_name]}}}.flatten(2)
end

Instance Method Details

#add_state_timer(timeout) { ... } ⇒ Integer

Add a timer that will be fired only while the machine is in the current state; the timer is cancelled when the machine leaves the current state (either by sending or receiving a message, or when unbind is called).

Internally, this uses EventMachine’s add_timer method.

Parameters:

  • timeout (Numeric)

    in seconds

Yields:

  • handler called after timeout

Returns:

  • (Integer)

    EventMachine timer signature



507
508
509
510
511
512
513
# File 'lib/event_state/machine.rb', line 507

def add_state_timer timeout, &handler
  sig = EM.add_timer(timeout) do
    self.instance_exec(&handler)
  end
  @state_timer_sigs << sig
  sig
end

#post_initnil

Called by EventMachine when a new connection has been established. This calls the on_enter handler for the machine’s start state with a nil message.

Be sure to call super if you override this method, or the on_enter handler for the start state will not be called.

Returns:

  • (nil)


387
388
389
390
391
392
393
# File 'lib/event_state/machine.rb', line 387

def post_init
  @state = self.class.start_state
  @state_timer_sigs = []
  add_state_timer @state.timeout, &@state.on_timeout if @state.timeout
  @state.call_on_enter self, nil, nil
  nil
end

#transition_on_recv(message_name, message) ⇒ nil

Move the state machine from its current state to the successor state that it should be in after receiving the given message, according to the protocol defined using the DSL.

If the received message is not valid according to the protocol, then the protocol error handler is called (see ProtocolError).

The precise order of events is:

  1. the on_exit handler of the current state is called with message

  2. the current state is set to the successor state

  3. the on_enter handler of the new current state is called with message

Parameters:

  • message_name (Object)

    the name for message; this is what relates the message data to the transitions defined with on_send; must be hashable and comparable by value; for example, a symbol, string, number or class makes a good message name

  • message (Object)

    received

Returns:

  • (nil)


432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/event_state/machine.rb', line 432

def transition_on_recv message_name, message
  #puts "#{self.class}: RECV #{message_name}"
  # look up successor state
  next_state_name = @state.on_recvs[message_name]

  # if there is no registered successor state, it's a protocol error
  if next_state_name.nil?
    raise RecvProtocolError.new(self, @state.name, message_name, message)
  else
    transition message_name, message, next_state_name
  end

  nil
end

#transition_on_send(message_name, message) {|message| ... } ⇒ nil

Move the state machine from its current state to the successor state that it should be in after sending the given message, according to the protocol defined using the DSL.

If the message to be sent is not valid according to the protocol, then the protocol error handler is called (see ProtocolError). If the message is valid, then the precise order of events is:

  1. this method yields the message to the supplied block (if any); the intention is that the block is used to actually send the message

  2. the on_exit handler of the current state is called with message

  3. the current state is set to the successor state

  4. the on_enter handler of the new current state is called with message

Parameters:

  • message (Object)

    received

  • message_name (Object)

    the name for message; this is what relates the message data to the transitions defined with on_send; must be hashable and comparable by value; for example, a symbol, string, number or class makes a good message name

Yields:

  • (message)

    should actually send the message, typically using EventMachine’s send_data method

Yield Parameters:

  • message (Object)

    the same message passed to this method

Returns:

  • (nil)


476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/event_state/machine.rb', line 476

def transition_on_send message_name, message
  #puts "#{self.class}: SEND #{message_name}"
  # look up successor state
  next_state_name = @state.on_sends[message_name]

  # if there is no registered successor state, it's a protocol error
  if next_state_name.nil?
    raise SendProtocolError.new(self, @state.name, message_name, message)
  else
    # let the caller send the message before we transition
    yield message if block_given?
    
    transition message_name, message, next_state_name
  end

  nil
end

#unbindnil

Called by EventMachine when a connection is closed. This calls the on_unbind handler for the current state and then cancels all state timers.

Returns:

  • (nil)


402
403
404
405
406
407
# File 'lib/event_state/machine.rb', line 402

def unbind
  #puts "#{self.class} UNBIND"
  handler = @state.on_unbind
  self.instance_exec(&handler) if handler 
  cancel_state_timers
end