├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── LICENSE ├── README.md ├── VERSIONS.md ├── example ├── switch.exs └── switch_with_states.exs ├── lib ├── state_server.ex └── state_server │ ├── invalid_state_error.ex │ ├── invalid_transition_error.ex │ ├── macros.ex │ ├── state.ex │ └── state_graph.ex ├── mix.exs ├── mix.lock └── test ├── actions ├── delayed_transition_test.exs ├── delayed_update_test.exs ├── goto_test.exs └── transition_test.exs ├── assets ├── bad_state_binding.exs ├── bad_state_binding_no_block.exs ├── malformed_graph.exs └── use_without_graph.exs ├── callbacks ├── handle_call_test.exs ├── handle_cast_test.exs ├── handle_continue_test.exs ├── handle_info_test.exs ├── handle_internal_test.exs ├── handle_timeout_basic_test.exs ├── handle_timeout_erlang_test.exs ├── handle_timeout_event_test.exs ├── handle_timeout_state_test.exs ├── handle_transition_test.exs ├── is_terminal_test.exs ├── on_state_entry_test.exs └── terminate_test.exs ├── compile_time_test.exs ├── etc_test.exs ├── examples ├── switch_test.exs └── switch_with_states_test.exs ├── macros ├── generate_defer_test.exs └── generate_handler_test.exs ├── multiverse_test.exs ├── otp └── otp_test.exs ├── regression └── timeout_on_state_entry_test.exs ├── state_graph_test.exs ├── state_module ├── basic_test.exs ├── defer_update_test.exs ├── handle_call_test.exs ├── handle_cast_test.exs ├── handle_continue_test.exs ├── handle_info_test.exs ├── handle_internal_test.exs ├── handle_timeout_test.exs ├── handle_transition_test.exs ├── on_state_entry_test.exs └── terminate_test.exs ├── state_server_test.exs ├── stop_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | container: 11 | image: elixir:1.9.4-slim 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Install Dependencies 16 | run: | 17 | mix local.rebar --force 18 | mix local.hex --force 19 | mix deps.get 20 | - name: Run Tests 21 | run: mix test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | gen_state_server-*.tar 24 | 25 | # elixir language server for vscode 26 | .elixir_ls/ 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Isaac Yonemoto 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StateServer 2 | 3 | ![CI result](https://github.com/ityonemo/state_server/workflows/Elixir%20CI/badge.svg) 4 | 5 | ## Opinionated :gen_statem shim for Elixir 6 | 7 | > A foolish consistency is the hobgoblin of little minds, 8 | > adored by little statemen and philosophers and divines. 9 | > With consistency a great soul has simply nothing to do. 10 | > 11 | > -- Ralph Waldo Emerson 12 | 13 | The unusual callback pattern of `:gen_statem` exists to allow code 14 | organization which we have better ways of achieving in Elixir (versus 15 | erlang). On the other hand we want to make sure users are using the 16 | canonical `:gen_statem` to leverage and prove out its battle-testedness 17 | 18 | This library makes `:gen_statem` more consistent with how Elixir 19 | architects its `GenServer`s. 20 | 21 | There are three major objectives: 22 | 23 | - Fanout the callback handling 24 | - Unify callback return type with that of `GenServer`, and sanitize 25 | - Enforce the use of a programmer-defined state graph. 26 | 27 | ## Installation 28 | 29 | The package can be installed by adding `state_server` to your list of dependencies 30 | in `mix.exs`: 31 | 32 | ```elixir 33 | def deps do 34 | [ 35 | {:state_server, "~> 0.4.7"} 36 | ] 37 | end 38 | ``` 39 | 40 | The docs can be found at [https://hexdocs.pm/state_server](https://hexdocs.pm/state_server). 41 | 42 | ## Example 43 | 44 | ```elixir 45 | defmodule Demo do 46 | use StateServer, on: [flip: :off], 47 | off: [flip: :on] 48 | 49 | def start_link(_), do: StateServer.start_link(__MODULE__, [], name: Demo) 50 | 51 | def init(state), do: {:ok, []} 52 | 53 | def handle_call(:flip, _from, state, data) do 54 | {:reply, data, transition: :flip, update: [state | data], timeout: {:foo, 100}} 55 | end 56 | 57 | def handle_transition(start, tr, data) do 58 | IO.puts("transitioned from #{start} through #{tr}") 59 | :noreply 60 | end 61 | 62 | def handle_timeout(_, _, _) do 63 | IO.puts("timed out!") 64 | :noreply 65 | end 66 | end 67 | 68 | 69 | iex(2)> Demo.start_link(:ok) 70 | {:ok, #PID<0.230.0>} 71 | iex(3)> GenServer.call(Demo, :flip) 72 | transitioned from on through flip 73 | [] 74 | timed out! 75 | iex(4)> GenServer.call(Demo, :flip) 76 | transitioned from off through flip 77 | [:on] 78 | timed out! 79 | iex(5)> GenServer.call(Demo, :flip) 80 | transitioned from on through flip 81 | [:off, :on] 82 | timed out! 83 | ``` 84 | -------------------------------------------------------------------------------- /VERSIONS.md: -------------------------------------------------------------------------------- 1 | ### StateServer Versions 2 | 3 | ## 0.1 4 | 5 | - first implementation 6 | - compatibility with gen_statem style function outputs 7 | - completion of documentation. 8 | 9 | ### 0.1.1 10 | 11 | - actually push latest to master. 12 | - minor documentation touchup 13 | - enable documentation code links 14 | - add licensing document 15 | 16 | ### 0.1.2 17 | 18 | - fix bug where it fails to compile when it's a mix dependency 19 | 20 | ### 0.1.3 21 | 22 | - fix child_spec/2 bug 23 | 24 | ### 0.1.4 25 | 26 | - implementation of timeout on startup 27 | - implemented transition cancellation 28 | - made guards optional callbacks 29 | - other cosmetic changes 30 | 31 | ## 0.2 32 | 33 | - organization of function definitions by state 34 | 35 | ## 0.2.1 36 | 37 | - better support for named timeouts 38 | 39 | ## 0.2.2 40 | 41 | - does child_spec/1 correctly (OTP is hard!) 42 | 43 | ## 0.3.0 44 | 45 | - support for on_state_entry/3 46 | 47 | ## 0.3.1 48 | 49 | - fixed defstate/2 50 | 51 | ## 0.3.2 52 | 53 | - fixed translation of on_entry action clauses 54 | 55 | ## 0.4.0 56 | 57 | - added documentation on the uniqueness of `state_timeout`s 58 | - fixed compilation concurrency issue by `requiring` all external state modules. 59 | - renamed from `is_edge/3` to `is_transition/3` and added `is_transition/2`. 60 | - renamed `is_terminal_transition/2` to `is_terminal/2`. 61 | 62 | ## 0.4.1 63 | 64 | - added capability to add events to the `:defer` directive. 65 | 66 | ## 0.4.2 67 | 68 | - make `on_state_entry/3` be called on init. 69 | 70 | ## 0.4.3 71 | 72 | - support `state_timeout` and `event_timeout` options on init. 73 | 74 | ## 0.4.4 75 | 76 | - deferred calls are passed the updated state. 77 | 78 | ## 0.4.5 79 | 80 | - default to not using a proxy process for timeout calls (consistent with `GenServer`) 81 | 82 | ## 0.4.6 83 | 84 | - repaired error in handle_transition logic so that updates are now reflected in 85 | on_state_entry; also refactored system to use gen_statem's event handling system (thanks to @bnns) 86 | - make documentation on how to use :defer more clear (thanks to @bnns) 87 | - added support for the `terminate/3` callback 88 | - added support for `start/2` and `start/3` in addition to `start_link` 89 | 90 | ## 0.4.7 91 | 92 | - repaired regression where self-transitions didn't trigger on_state_entry 93 | 94 | ## 0.4.9 95 | 96 | - last version before 0.5.0 97 | - deprecate "defer" command in favor of "delegate" (will be removed in 0.5.0) 98 | - add "ignore" macro 99 | 100 | ## 0.4.10 101 | 102 | - add in support for `Multiverses` style testing 103 | 104 | ## 0.5.0 (future) 105 | 106 | - adds a tracer that can output all state machine events. 107 | 108 | ### Unscheduled 109 | 110 | - `on_data_update/4` special callback 111 | - better compatibility with gen_statem modules by providing `handle_event/3` 112 | -------------------------------------------------------------------------------- /example/switch.exs: -------------------------------------------------------------------------------- 1 | defmodule Switch do 2 | 3 | @doc """ 4 | implements a light switch as a state server. In data, it keeps a count of 5 | how many times the state of the light switch has changed. 6 | """ 7 | 8 | use StateServer, off: [flip: :on], 9 | on: [flip: :off] 10 | 11 | @type data :: non_neg_integer 12 | 13 | def start_link, do: StateServer.start_link(__MODULE__, :ok) 14 | 15 | @impl true 16 | def init(:ok), do: {:ok, 0} 17 | 18 | ############################################################## 19 | ## API ENDPOINTS 20 | 21 | @doc """ 22 | returns the state of switch. 23 | """ 24 | @spec state(GenServer.server) :: state 25 | def state(srv), do: GenServer.call(srv, :state) 26 | 27 | @spec state_impl(state) :: StateServer.reply_response 28 | defp state_impl(state) do 29 | {:reply, state} 30 | end 31 | 32 | @doc """ 33 | returns the number of times the switch state has been changed, from either 34 | flip transitions or by setting the switch value 35 | """ 36 | @spec count(GenServer.server) :: non_neg_integer 37 | def count(srv), do: GenServer.call(srv, :count) 38 | 39 | @spec count_impl(non_neg_integer) :: StateServer.reply_response 40 | defp count_impl(count), do: {:reply, count} 41 | 42 | @doc """ 43 | triggers the flip transition. 44 | """ 45 | @spec flip(GenServer.server) :: state 46 | def flip(srv), do: GenServer.call(srv, :flip) 47 | 48 | @spec flip_impl(state, non_neg_integer) :: StateServer.reply_response 49 | defp flip_impl(:on, count) do 50 | {:reply, :off, transition: :flip, update: count + 1} 51 | end 52 | defp flip_impl(:off, count) do 53 | {:reply, :on, transition: :flip, update: count + 1} 54 | end 55 | 56 | @doc """ 57 | sets the state of the switch, without explicitly triggering the flip 58 | transition. Note the use of the builtin `t:state/0` type. 59 | """ 60 | @spec set(GenServer.server, state) :: :ok 61 | def set(srv, new_state), do: GenServer.call(srv, {:set, new_state}) 62 | 63 | @spec set_impl(state, state, data) :: StateServer.reply_response 64 | defp set_impl(state, state, _) do 65 | {:reply, state} 66 | end 67 | defp set_impl(state, new_state, count) do 68 | {:reply, state, goto: new_state, update: count + 1} 69 | end 70 | 71 | ####################################################3 72 | ## callback routing 73 | 74 | @impl true 75 | def handle_call(:state, _from, state, _count) do 76 | state_impl(state) 77 | end 78 | def handle_call(:count, _from, _state, count) do 79 | count_impl(count) 80 | end 81 | def handle_call(:flip, _from, state, count) do 82 | flip_impl(state, count) 83 | end 84 | def handle_call({:set, new_state}, _from, state, count) do 85 | set_impl(state, new_state, count) 86 | end 87 | 88 | # if we are flipping on the switch, then turn it off after 300 ms 89 | # to conserve energy. 90 | @impl true 91 | def handle_transition(state, transition, _count) 92 | when is_transition(state, transition, :on) do 93 | {:noreply, state_timeout: {:conserve, 300}} 94 | end 95 | def handle_transition(_, _, _), do: :noreply 96 | 97 | @impl true 98 | def handle_timeout(:conserve, :on, _count) do 99 | {:noreply, transition: :flip} 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /example/switch_with_states.exs: -------------------------------------------------------------------------------- 1 | defmodule SwitchWithStates do 2 | 3 | @doc """ 4 | implements a light switch as a state server. In data, it keeps a count of 5 | how many times the state of the light switch has changed. 6 | 7 | On transition, it sends to standard error a comment that it has been flipped. 8 | Note that the implementations are different between the two states. 9 | """ 10 | 11 | use StateServer, off: [flip: :on], 12 | on: [flip: :off] 13 | 14 | @type data :: non_neg_integer 15 | 16 | def start_link, do: StateServer.start_link(__MODULE__, :ok) 17 | 18 | @impl true 19 | def init(:ok), do: {:ok, 0} 20 | 21 | def flip(srv), do: StateServer.call(srv, :flip) 22 | def query(srv), do: StateServer.call(srv, :query) 23 | 24 | @impl true 25 | def handle_call(:flip, _from, _state, _count) do 26 | {:reply, :ok, transition: :flip} 27 | end 28 | 29 | delegate :handle_call 30 | # we must delegate the handle_call statement because there are both shared and 31 | # individual implementation of handle_call features. 32 | 33 | defstate Off, for: :off do 34 | @impl true 35 | def handle_transition(:flip, count) do 36 | IO.puts(:stderr, "switch #{inspect self()} flipped on, #{count} times turned on") 37 | {:noreply, update: count + 1} 38 | end 39 | 40 | @impl true 41 | def handle_call(:query, _from, _count) do 42 | {:reply, "state is off"} 43 | end 44 | end 45 | 46 | defstate On, for: :on do 47 | @impl true 48 | def handle_transition(:flip, count) do 49 | IO.puts(:stderr, "switch #{inspect self()} flipped off, #{count} times turned on") 50 | :noreply 51 | end 52 | 53 | @impl true 54 | def handle_call(:query, _from, _count) do 55 | {:reply, "state is on"} 56 | end 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /lib/state_server.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer do 2 | 3 | @state_server_code File.read!("example/switch.exs") 4 | 5 | @moduledoc """ 6 | A wrapper for `:gen_statem` which preserves `GenServer`-like semantics. 7 | 8 | ## Motivation 9 | 10 | The `:gen_statem` event callback is complex, with a confusing set of response 11 | definitions, the documentation isn't that great, the states of the state 12 | machine are a bit too loosey-goosey and not explicitly declared anywhere in a 13 | single referential place in the code; you have to read the result bodies of 14 | several function bodies to understand the state graph. 15 | 16 | `StateServer` changes that. There are three major objectives: 17 | - Fanout the callback handling 18 | - Unify callback return type with that of `GenServer`, and sanitize 19 | - Enforce the use of a programmer-defined state graph. 20 | 21 | ## Defining the state graph 22 | 23 | The state graph is defined at **compile time** using the keyword list in the 24 | `use` statement. This `state_graph` is a keyword list of keyword lists. The 25 | outer keyword list has the state names (atoms) as keys and the inner keyword 26 | lists have transitions (atoms) as keys, and destination states as values. 27 | The first keyword in the state graph is the initial state of the state 28 | machine. **Defining the state graph is required**. 29 | 30 | At compile time, `StateServer` will verify that all of the state graph's 31 | transition destinations exist as declared states; you may need to explicitly 32 | declare that a particular state is terminal by having it key into the empty 33 | list `[]`. 34 | 35 | ### Example 36 | 37 | the state graph for a light switch might look like this: 38 | 39 | ```elixir 40 | use StateServer, on: [flip: :off], 41 | off: [flip: :on] 42 | ``` 43 | 44 | #### 'Magic' things 45 | 46 | The following guards will be defined for you automatically. 47 | - `c:is_terminal/1`: true *iff* the argument is a terminal state. 48 | - `c:is_terminal/2`: true *iff* starting from `&1` through transition `&2` leads to a 49 | terminal state. 50 | - `c:is_transition/2`: true *iff* `&2` is a proper transition of `&1` 51 | - `c:is_transition/3`: true *iff* starting from `&1` through transition `&2` leads to `&3` 52 | 53 | The following types are defined for you automatically. 54 | - `state` which is a union type of all state atoms. 55 | - `transition` which is a union type of all transition atoms. 56 | 57 | The following module attributes are available at compile-time: 58 | - `@state_graph` is the state graph as passed in the `use` statement 59 | - `@initial_state` is the initial state of the state graph. Note that 60 | there are cases when the StateServer itself should not start in that 61 | state, for example if it is being restarted by an OTP supervisor and 62 | should search for its state from some other source of ground truth. 63 | 64 | ## State machine data 65 | 66 | A `StateServer`, like all `:gen_statem`s carry additional data of any term 67 | in addition to the state, to ensure that they can perform all Turing-computable 68 | operations. You are free to make the data parameter whatever you would like. 69 | It is encouraged to declare the `data` type in the module which defines the 70 | typespec of this state machine data. 71 | 72 | ## Callbacks 73 | 74 | The following callbacks are all optional and are how you implement 75 | functionality for your StateServer. 76 | 77 | ### External callbacks: 78 | 79 | - `c:handle_call/4` responds to a message sent via `GenServer.call/3`. 80 | Like `c:GenServer.handle_call/3`, the calling process will block until you 81 | a reply, using either the `{:reply, reply}` tuple, or, if you emit `:noreply`, 82 | a subsequent call to `reply/2` in a continuation. Note that if you do not 83 | reply within the call's expected timeout, the calling process will crash. 84 | 85 | - `c:handle_cast/3` responds to a message sent via `GenServer.cast/2`. 86 | Like `c:GenServer.handle_cast/2`, the calling process will immediately return 87 | and this is effectively a `fire and forget` operation with no backpressure 88 | response. 89 | 90 | - `c:handle_info/3` responds to a message sent via `send/2`. Typically this 91 | should be used to trap system messages that result from a message source 92 | that has registered the active StateServer process as a message sink, such 93 | as network packets or `:nodeup`/`:nodedown` messages (among others). 94 | 95 | ### Internal callbacks 96 | 97 | - `c:handle_internal/3` responds to internal *events* which have been sent 98 | forward in time using the `{:internal, payload}` setting. This is 99 | `:gen_statem`'s primary method of doing continuations. If you have code 100 | that you think will need to be compared against or migrate to a 101 | `:gen_statem`, you should use this semantic. 102 | 103 | - `c:handle_continue/3` responds to internal *events* which have been sent 104 | forward in time using the `{:continue, payload}` setting. This is `GenServer`'s 105 | primary method of performing continuations. If you have code that you 106 | think will need to be compared against or migrate to a `GenServer`, you should 107 | use this form. A typical use of this callback is to handle a long-running 108 | task that needs to be triggered after initialization. Because `start_link/2` 109 | will timeout, if `StateMachine`, then you should these tasks using the continue 110 | callback. 111 | 112 | - `c:handle_timeout/3` handles all timeout events. See the [timeout section](#module-timeouts) 113 | for more information 114 | 115 | - `c:handle_transition/3` is triggered whenever you change states using the 116 | `{:transition, transition}` event. Note that it's **not** triggered by a 117 | `{:goto, state}` event. You may find the `c:is_transition/3` callback guard to 118 | be useful for discriminating which transitions you care about. 119 | 120 | ### Special callbacks 121 | 122 | - `c:on_state_entry/3` will be triggered for the starting state (whether as a default or 123 | as set by a `goto:` parameter in `c:init/1`), and when any event causes the state machine 124 | to change state or repeat state 125 | 126 | ## Callback responses 127 | 128 | - `c:handle_call/4` typically issues a **reply** response. A reply response takes the 129 | one of two forms, `{:reply, reply}` or `{:reply, reply, event_list}` It may also 130 | take the **noreply** form, with a delegated reply at some other time. 131 | - all of the callback responses may issue a **noreply** response, which takes one of 132 | two forms, `:noreply` or `{:noreply, event_list}` 133 | 134 | ### The event list 135 | 136 | The event list consists of one of several forms: 137 | ```elixir 138 | {:transition, transition} # sends the state machine through the transition 139 | {:update, new_data} # updates the data portion of the state machine 140 | 141 | {:goto, new_state} # changes the state machine state without a transition 142 | {:internal, payload} # sends an internal event 143 | {:continue, payload} # sends a continuation 144 | 145 | {:event_timeout, {payload, time}} # sends an event timeout with a payload 146 | {:event_timeout, time} # sends an event timeout without a payload 147 | {:state_timeout, {payload, time}} # sends a state timeout with a payload 148 | {:state_timeout, time} # sends a state timeout without a payload 149 | {:timeout, {name, payload, time}} # sends a plain timeout with a name, and a payload 150 | {:timeout, {name, time}} # sends a plain timeout with a name, but no payload 151 | {:timeout, time} # sends a plain timeout without a payload 152 | :noop # does nothing 153 | ``` 154 | 155 | **transition** and **update** events are special. If they are at the head of the event 156 | list, (and in that order) they will be handled atomically in the current function call; 157 | if they are not at the head of the event list, separate internal events will be 158 | generated, and they will be executed as separate calls in their event order. 159 | 160 | Typically, these should be represented in the event response as part of an Elixir 161 | keyword list, for example: 162 | 163 | ```elixir 164 | {:noreply, transition: :flip, internal: {:add, 3}, state_timeout: 250} 165 | ``` 166 | 167 | You may also generally provide events as tuples that are expected by 168 | `:gen_statem`, for example: `{:next_event, :internal, {:foo, "bar"}}`, but 169 | note that if you do so Elixir's keyword sugar will not be supported. 170 | 171 | ### Transition vs. goto 172 | 173 | **Transitions** represent the main business logic of your state machine. They come 174 | with an optional transition handler, so that you can write code that will be ensured 175 | to run on all state transitions with the same name, instead of requiring these to be 176 | in the code body of your event. You **should** be using transitions everywhere. 177 | 178 | However, there are some cases when you will want to skip straight to a state without 179 | traversing the state graph. Here are some cases where you will want to do that: 180 | 181 | - If you want to start at a state *other than* the head state, depending on environment 182 | at the start 183 | - If you want to do a unit test and skip straight to some state that you're testing. 184 | - If your gen_statem has crashed, and you need to restart it in a state that isn't the 185 | default initial state. 186 | 187 | ## Timeouts 188 | 189 | `StateServer` state machines respect three types of timeouts: 190 | 191 | - `:event_timeout`. These are cancelled when any *internal* OR *external* 192 | event hits the genserver. Typically, an event_timeout definition should 193 | be the last term in the event list, otherwise the succeeding internal 194 | event will cancel the timeout. 195 | - `:state_timeout`. These are cancelled when the state of the state machine 196 | changes. **NB** a state machine may only have one state timeout active 197 | at any given time. 198 | - `:timeout`. These are not cancelled, unless you reset their value to 199 | `:infinity`. 200 | 201 | In general, if you need to name your timeouts, you should include the "name" 202 | of the timeout in the "payload" section, as the first element in a tuple; 203 | you will then be able to pattern match this in your `c:handle_timeout/3` 204 | headers. If you do not include a payload, then they will be explicitly sent 205 | a `nil` value. 206 | 207 | ## Organizing your code 208 | 209 | If you would like to organize your implementations by state, consider using 210 | the `StateServer.State` behaviour pattern. 211 | 212 | ## Example basic implementation: 213 | 214 | ```elixir 215 | #{@state_server_code} 216 | ``` 217 | """ 218 | 219 | @behaviour :gen_statem 220 | 221 | require StateServer.Macros 222 | 223 | alias StateServer.InvalidStateError 224 | alias StateServer.InvalidTransitionError 225 | alias StateServer.Macros 226 | alias StateServer.StateGraph 227 | 228 | @typedoc """ 229 | events which can be put on the state machine's event queue. 230 | 231 | these are largely the same as `t::gen_statem.event_type/0` but have been 232 | reformatted to be more user-friendly. 233 | """ 234 | @type event :: 235 | {:transition, atom} | {:goto, atom} | {:update, term} | {:internal, term} | {:continue, term} | 236 | {:event_timeout, {term, non_neg_integer}} | {:event_timeout, non_neg_integer} | 237 | {:state_timeout, {term, non_neg_integer}} | {:state_timeout, non_neg_integer} | 238 | {:timeout, {term, non_neg_integer}} | {:timeout, non_neg_integer} | 239 | :noop | :gen_statem.event_type 240 | 241 | @typedoc false 242 | @type from :: {pid, tag :: term} 243 | 244 | @typedoc "handler output when there's a response" 245 | @type reply_response :: {:reply, term, [event]} | {:reply, term} 246 | 247 | @typedoc "handler output when there isn't a response" 248 | @type noreply_response :: {:noreply, [event]} | :noreply 249 | 250 | @typedoc "handler output when you want to delegate to a state module" 251 | @type delegate_response :: {:delegate, [event]} | :delegate | defer_response 252 | 253 | @typedoc deprecated: "0.4.9" 254 | @typedoc "handler output when you want to delegate to a state module" 255 | @type defer_response :: {:defer, [event]} | :defer 256 | 257 | @typedoc """ 258 | handler output when the state machine should stop altogether. The value in 259 | `new_data` will be transferred as the data segment for `c:terminate/3`, so you 260 | may instrument important information there. 261 | """ 262 | @type stop_response :: 263 | {:stop, reason :: term} | {:stop, reason :: term, new_data :: term} 264 | 265 | @typedoc """ 266 | handler output for `c:handle_call/4` which performs a stop action with a reply. 267 | If you would prefer stopping in an alternate form, you may enlist the help of 268 | `reply/2`. 269 | """ 270 | @type stop_reply_response :: 271 | {:stop, reason :: term, reply :: term, new_data :: term} 272 | 273 | @type timeout_payload :: {name :: atom, payload :: term} | (name :: atom) | (payload :: term) 274 | 275 | @doc """ 276 | starts the state machine, similar to `c:GenServer.init/1` 277 | 278 | **NB** the expected response of `c:init/1` is `{:ok, data}` which does not 279 | include the initial state. The initial state is set as the first key in the 280 | `:state_graph` parameter of the `use StateServer` directive. If you must 281 | initialize the state to something else, use the `{:ok, data, goto: state}` 282 | response. 283 | 284 | You may also respond with the usual `c:GenServer.init/1` responses, such as: 285 | 286 | - `:ignore` 287 | - `{:stop, reason}` 288 | 289 | You can also initialize and instrument one of several keyword parameters. 290 | For example, you may issue `{:internal, term}` or `{:continue, term}` to 291 | send an internal message as part of a startup continuation. You may 292 | send `{:timeout, {term, timeout}}` to send a delayed continuation; this 293 | is particularly useful to kick off a message loop. 294 | 295 | Any of these keywords may be preceded by `{:goto, state}` which will 296 | set the initial state, which is useful for resurrecting a supervised 297 | state machine into a state without a transition. 298 | 299 | ### Example 300 | 301 | ```elixir 302 | def init(log) do 303 | # uses both the goto and the timeout directives to either initialize 304 | # a fresh state machine or resurrect from a log. In both cases, 305 | # sets up a ping loop to perform some task. 306 | case retrieve_log(log) do 307 | nil -> 308 | {:ok, default_value, timeout: {:ping, 50}} 309 | {previous_state, value} -> 310 | {:ok, value, goto: previous_state, timeout: {:ping, 50}} 311 | end 312 | end 313 | 314 | # for reference, what that ping loop might look like. 315 | def handle_timeout(:ping, _state, _data) do 316 | do_ping(:ping) 317 | {:noreply, timeout: {:ping, 50}} 318 | end 319 | ``` 320 | """ 321 | @callback init(any) :: 322 | {:ok, initial_data::term} | 323 | {:ok, initial_data::term, internal: term} | 324 | {:ok, initial_data::term, continue: term} | 325 | {:ok, initial_data::term, timeout: {term, timeout}} | 326 | {:ok, initial_data::term, goto: atom} | 327 | {:ok, initial_data::term, goto: atom, internal: term} | 328 | {:ok, initial_data::term, goto: atom, continue: term} | 329 | {:ok, initial_data::term, goto: atom, timeout: {term, timeout}} | 330 | {:ok, initial_data::term, goto: atom, timeout: timeout} | 331 | {:ok, initial_data::term, goto: atom, state_timeout: {term, timeout}} | 332 | {:ok, initial_data::term, goto: atom, state_timeout: timeout} | 333 | {:ok, initial_data::term, goto: atom, event_timeout: {term, timeout}} | 334 | {:ok, initial_data::term, goto: atom, event_timeout: timeout} | 335 | :ignore | {:stop, reason :: any} 336 | 337 | @doc """ 338 | handles messages sent to the StateMachine using `StateServer.call/3` 339 | """ 340 | @callback handle_call(term, from, state :: atom, data :: term) :: 341 | reply_response | noreply_response | stop_response | stop_reply_response | delegate_response 342 | 343 | @doc """ 344 | handles messages sent to the StateMachine using `StateServer.cast/2` 345 | """ 346 | @callback handle_cast(term, state :: atom, data :: term) :: 347 | noreply_response | stop_response | delegate_response 348 | 349 | @doc """ 350 | handles messages sent by `send/2` or other system message generators. 351 | """ 352 | @callback handle_info(term, state :: atom, data :: term) :: 353 | noreply_response | stop_response | delegate_response 354 | 355 | @doc """ 356 | handles events sent by the `{:internal, payload}` event response. 357 | """ 358 | @callback handle_internal(term, state :: atom, data :: term) :: 359 | noreply_response | stop_response | delegate_response 360 | 361 | @doc """ 362 | handles events sent by the `{:continue, payload}` event response. 363 | 364 | **NB** a continuation is simply an `:internal` event with a reserved word 365 | tag attached. 366 | """ 367 | @callback handle_continue(term, state :: atom, data :: term) :: 368 | noreply_response | stop_response | delegate_response 369 | 370 | @doc """ 371 | triggered when a set timeout event has timed out. See [timeouts](#module-timeouts) 372 | """ 373 | @callback handle_timeout(payload::timeout_payload, state :: atom, data :: term) :: 374 | noreply_response | stop_response | delegate_response 375 | 376 | @doc """ 377 | triggered when a state change has been initiated via a `{:transition, transition}` 378 | event. 379 | 380 | should emit `:noreply`, or `{:noreply, extra_actions}` to handle the normal case 381 | when the transition should proceed. If the transition should be cancelled, 382 | emit `:cancel` or `{:cancel, extra_actions}`. 383 | 384 | NB: you may want to use the `c:is_terminal/2` or the `c:is_transition/3` 385 | callback defguards here. 386 | """ 387 | @callback handle_transition(state :: atom, transition :: atom, data :: term) :: 388 | noreply_response | stop_response | delegate_response | :cancel 389 | 390 | @doc """ 391 | triggered when the process is about to be terminated. See: 392 | `c::gen_statem.terminate/3` 393 | """ 394 | @callback terminate(reason :: term, state :: atom, data :: term) :: any 395 | 396 | @typedoc """ 397 | on_state_entry function outputs 398 | 399 | only a subset of the available handler responses should be queued from 400 | a triggered `on_state_entry/3` event. 401 | """ 402 | @type on_state_entry_event :: 403 | {:update, term} | {:internal, term} | {:continue, term} | 404 | {:event_timeout, {term, non_neg_integer}} | {:event_timeout, non_neg_integer} | 405 | {:state_timeout, {term, non_neg_integer}} | {:state_timeout, non_neg_integer} | 406 | {:timeout, {term, non_neg_integer}} | {:timeout, non_neg_integer} 407 | 408 | @type on_state_entry_response :: 409 | :noreply | {:noreply, [on_state_entry_event]} 410 | 411 | @doc """ 412 | triggered on initialization or just prior to entering a state. 413 | 414 | If entering a state is done with a `:goto` statement or a `:gen_statem` 415 | state change, `transition` will be `nil`. 416 | 417 | Note that at this point the state change should not be cancelled. If you 418 | need to cancel a transition, use `c:handle_transition/3` with the `:cancel` 419 | return value. 420 | 421 | response should be :noreply or a :noreply tuple with a restricted set of 422 | events which can be enqueued onto the events list: 423 | 424 | - `:update` 425 | - `:internal` 426 | - `:continue` 427 | - `:event_timeout` 428 | - `:state_timeout` 429 | - `:timeout` 430 | 431 | Like the other callbacks, you may call :delegate here to delegate to the state machines. 432 | """ 433 | @callback on_state_entry(transition :: atom, state :: atom, data :: term) :: 434 | on_state_entry_response | :delegate 435 | 436 | @doc """ 437 | an autogenerated guard which can be used to check if a state is terminal 438 | """ 439 | @macrocallback is_terminal(state::atom) :: Macro.t 440 | 441 | @doc """ 442 | an autogenerated guard which can be used to check if a state and transition 443 | will lead to a terminal state. 444 | """ 445 | @macrocallback is_terminal(state::atom, transition::atom) :: Macro.t 446 | 447 | @doc """ 448 | an autogenerated guard which can be used to check if a transition is valid for 449 | a state 450 | """ 451 | @macrocallback is_transition(state::atom, transition::atom) :: Macro.t 452 | 453 | @doc """ 454 | an autogenerated guard which can be used to check if a state and transition 455 | will lead to any state. 456 | """ 457 | @macrocallback is_transition(state::atom, transition::atom, dest::atom) :: Macro.t 458 | 459 | @optional_callbacks [handle_call: 4, handle_cast: 3, handle_info: 3, 460 | handle_internal: 3, handle_continue: 3, handle_timeout: 3, handle_transition: 3, 461 | on_state_entry: 3, terminate: 3] 462 | 463 | @macro_callbacks [:is_terminal, :is_transition] 464 | 465 | @typep callbacks :: :handle_call | :handle_cast | :handle_info | :handle_internal | 466 | :handle_continue | :handle_timeout | :handle_transition 467 | 468 | # internal data type 469 | @typep data :: %{ 470 | required(:module) => module, 471 | required(:data) => term, 472 | required(callbacks) => function 473 | } 474 | 475 | defmacro __using__(state_graph) do 476 | env = __CALLER__ 477 | 478 | # we will want the module name for some documentation 479 | module_name = (env.module |> Module.split |> tl |> Enum.join(".")) 480 | 481 | ([] == state_graph) && raise ArgumentError, "StateServer must have a state_graph parameter." 482 | 483 | # pull the state_graph and validate it. 484 | unless StateGraph.valid?(state_graph) do 485 | raise %CompileError{file: env.file, line: env.line, description: "state_graph sent to StateServer is malformed"} 486 | end 487 | 488 | # populate values that will be used in the autogenerated guards 489 | terminal_states = StateGraph.terminal_states(state_graph) 490 | terminal_transitions = StateGraph.terminal_transitions(state_graph) 491 | all_transitions = StateGraph.all_transitions(state_graph) 492 | edges = StateGraph.edges(state_graph) 493 | 494 | # generate value for our @initial_state attribute 495 | initial_state = StateGraph.start(state_graph) 496 | 497 | # create AST for autogenerated @type statements 498 | state_typelist = state_graph 499 | |> StateGraph.states 500 | |> StateGraph.atoms_to_typelist 501 | 502 | transition_typelist = state_graph 503 | |> StateGraph.transitions 504 | |> StateGraph.atoms_to_typelist 505 | 506 | quote do 507 | import StateServer, only: [reply: 2, defstate: 3, defstate: 2, delegate: 1, defer: 1] 508 | 509 | @behaviour StateServer 510 | 511 | @type state :: unquote(state_typelist) 512 | @type transition :: unquote(transition_typelist) 513 | 514 | @doc """ 515 | true *iff* going the specified state is terminal in `#{unquote(module_name)}` 516 | """ 517 | @impl true 518 | defguard is_terminal(state) when state in unquote(terminal_states) 519 | 520 | @doc """ 521 | true *iff* going from state to transition leads to a terminal state 522 | for `#{unquote(module_name)}` 523 | """ 524 | @impl true 525 | defguard is_terminal(state, transition) 526 | when {state, transition} in unquote(terminal_transitions) 527 | 528 | @doc """ 529 | true *iff* transition is a valid transition for the given state in 530 | `#{unquote(module_name)}` 531 | """ 532 | @impl true 533 | defguard is_transition(state, transition) 534 | when {state, transition} in unquote(all_transitions) 535 | 536 | @doc """ 537 | true *iff* (state -> transition -> destination) is a proper 538 | edge of the state graph for `#{unquote(module_name)}` 539 | """ 540 | @impl true 541 | defguard is_transition(state, transition, destination) 542 | when {state, {transition, destination}} in unquote(edges) 543 | 544 | @state_graph unquote(state_graph) 545 | 546 | @doc false 547 | @spec __state_graph__() :: StateServer.StateGraph.t 548 | def __state_graph__, do: @state_graph 549 | 550 | @doc false 551 | @spec __transition__(state, transition) :: state 552 | def __transition__(state, transition) do 553 | StateGraph.transition(@state_graph, state, transition) 554 | end 555 | 556 | # provides a way for you to make your own overrideable 557 | # child_specs. 558 | @doc false 559 | def child_spec(init_arg, overrides) do 560 | Supervisor.child_spec(%{ 561 | id: __MODULE__, 562 | start: {__MODULE__, :start_link, [init_arg]} 563 | }, overrides) 564 | end 565 | 566 | # provide an overridable default child_spec implementation. 567 | @doc false 568 | def child_spec(init_arg) do 569 | %{ 570 | id: __MODULE__, 571 | start: {__MODULE__, :start_link, [init_arg]} 572 | } 573 | end 574 | defoverridable child_spec: 1 575 | 576 | # keep track of state_modules 577 | Module.register_attribute(__MODULE__, :state_modules, accumulate: true, persist: true) 578 | 579 | @before_compile StateServer 580 | 581 | # make initial state value 582 | @initial_state unquote(initial_state) 583 | end 584 | end 585 | 586 | @typedoc false 587 | @type server :: :gen_statem.server_ref 588 | 589 | @typedoc false 590 | @type start_option :: :gen_statem.options | {:name, atom} 591 | 592 | @spec start(module, term, [start_option]) :: :gen_statem.start_ret 593 | @doc "like `start_link/3`, but without process linking" 594 | def start(module, initializer, options \\ []) do 595 | do_start(:nolink, module, initializer, options) 596 | end 597 | 598 | @spec start_link(module, term, [start_option]) :: :gen_statem.start_ret 599 | @doc """ 600 | like `GenServer.start_link/3`, but starts StateServer instead. 601 | 602 | ## options 603 | - `:forward_callers` when `true`, causes the StateServer to adopt the `:"$callers"` chain 604 | of the process which executed `start_link/3`. 605 | """ 606 | def start_link(module, initializer, options \\ []) do 607 | do_start(:link, module, initializer, options) 608 | end 609 | 610 | defp do_start(link, module, initializer, options) do 611 | init_arg = %{generate_selector(module) | data: initializer} 612 | 613 | callers = if options[:forward_callers] do 614 | [callers: [self() | Process.get(:"$callers", [])]] 615 | else 616 | [] 617 | end 618 | 619 | case Keyword.pop(options, :name) do 620 | {nil, opts} -> 621 | :gen.start(__MODULE__, link, __MODULE__, init_arg, opts ++ callers) 622 | 623 | {atom, opts} when is_atom(atom) -> 624 | :gen.start(__MODULE__, link, {:local, atom}, __MODULE__, init_arg, opts ++ callers) 625 | 626 | {{:global, _term} = tuple, opts} -> 627 | :gen.start(__MODULE__, link, tuple, __MODULE__, init_arg, opts ++ callers) 628 | 629 | {{:via, via_module, _term} = tuple, opts} when is_atom(via_module) -> 630 | :gen.start(__MODULE__, link, tuple, __MODULE__, init_arg, opts ++ callers) 631 | 632 | {other, _} -> 633 | raise ArgumentError, """ 634 | expected :name option to be one of the following: 635 | * nil 636 | * atom 637 | * {:global, term} 638 | * {:via, module, term} 639 | Got: #{inspect(other)} 640 | """ 641 | end 642 | end 643 | 644 | def init_it(starter, self_param, name, __MODULE__, args, options!) do 645 | callers = options![:callers] 646 | if callers, do: Process.put(:"$callers", callers) 647 | options! = Keyword.drop(options!, [:name, :callers, :forward_callers]) 648 | :gen_statem.init_it(starter, self_param, name, __MODULE__, args, options!) 649 | end 650 | 651 | @typep init_result :: :gen_statem.init_result(atom) 652 | 653 | # an empty version of the the state_server internal datatype. This is what 654 | # state_server keeps under the hood, it surfaces "data" to the client module 655 | # as encapsulated by the system. 656 | # 657 | # still needs to be populated with hook functions. 658 | # in the future, this might become a struct. 659 | @empty_init %{module: nil, data: nil, transition: nil} 660 | 661 | @impl true 662 | @spec init(data) :: init_result 663 | def init(init_data = %{module: module}) do 664 | default_state = StateGraph.start(module.__state_graph__()) 665 | 666 | init_data.data 667 | |> module.init() 668 | |> parse_init(default_state, init_data) 669 | end 670 | 671 | defp parse_init({:ok, data}, state, data_wrap) do 672 | {:ok, state, %{data_wrap | data: data}} 673 | end 674 | defp parse_init({:ok, data, continue: payload}, state, data_wrap) do 675 | {:ok, state, %{data_wrap | data: data}, 676 | {:next_event, :internal, {:"$continue", payload}}} 677 | end 678 | defp parse_init({:ok, data, internal: payload}, state, data_wrap) do 679 | {:ok, state, %{data_wrap | data: data}, 680 | {:next_event, :internal, payload}} 681 | end 682 | defp parse_init({:ok, data, timeout: {name, payload, time}}, state, data_wrap) do 683 | {:ok, state, %{data_wrap | data: data}, 684 | {{:timeout, name}, time, payload}} 685 | end 686 | defp parse_init({:ok, data, timeout: {payload, time}}, state, data_wrap) do 687 | {:ok, state, %{data_wrap | data: data}, 688 | {{:timeout, nil}, time, payload}} 689 | end 690 | defp parse_init({:ok, data, timeout: time}, state, data_wrap) do 691 | {:ok, state, %{data_wrap | data: data}, 692 | {{:timeout, nil}, time, nil}} 693 | end 694 | defp parse_init({:ok, data, event_timeout: {payload, time}}, state, data_wrap) do 695 | {:ok, state, %{data_wrap | data: data}, 696 | {:timeout, time, {:"$event_timeout", payload}}} 697 | end 698 | defp parse_init({:ok, data, event_timeout: time}, state, data_wrap) do 699 | {:ok, state, %{data_wrap | data: data}, 700 | {:timeout, time, nil}} 701 | end 702 | defp parse_init({:ok, data, state_timeout: {payload, time}}, state, data_wrap) do 703 | {:ok, state, %{data_wrap | data: data}, 704 | {:state_timeout, time, payload}} 705 | end 706 | defp parse_init({:ok, data, state_timeout: time}, state, data_wrap) do 707 | {:ok, state, %{data_wrap | data: data}, 708 | {:state_timeout, time, nil}} 709 | end 710 | defp parse_init({:ok, data, goto: state}, _, data_wrap) do 711 | parse_init({:ok, data}, state, data_wrap) 712 | end 713 | defp parse_init({:ok, data, [goto: state] ++ rest}, _, data_wrap) do 714 | parse_init({:ok, data, rest}, state, data_wrap) 715 | end 716 | defp parse_init(any, _, _), do: any 717 | 718 | @impl true 719 | @spec callback_mode() :: [:handle_event_function | :state_enter] 720 | def callback_mode, do: [:handle_event_function, :state_enter] 721 | 722 | @spec do_event_conversion([event]) :: [:gen_statem.event_type] 723 | defp do_event_conversion([]), do: [] 724 | defp do_event_conversion([{:internal, x} | rest]), do: [{:next_event, :internal, x} | do_event_conversion(rest)] 725 | defp do_event_conversion([{:continue, continuation} | rest]), do: [{:next_event, :internal, {:"$continue", continuation}} | do_event_conversion(rest)] 726 | defp do_event_conversion([{:event_timeout, {payload, time}} | rest]), do: [{:timeout, time, {:"$event_timeout", payload}} | do_event_conversion(rest)] 727 | defp do_event_conversion([{:event_timeout, time} | rest]), do: [{:timeout, time, nil} | do_event_conversion(rest)] 728 | defp do_event_conversion([{:state_timeout, {payload, time}} | rest]), do: [{:state_timeout, time, payload} | do_event_conversion(rest)] 729 | defp do_event_conversion([{:state_timeout, time} | rest]), do: [{:state_timeout, time, nil} | do_event_conversion(rest)] 730 | defp do_event_conversion([{:timeout, {name, payload, time}} | rest]), do: [{{:timeout, name}, time, payload} | do_event_conversion(rest)] 731 | defp do_event_conversion([{:timeout, {name, time}} | rest]), do: [{{:timeout, name}, time, nil} | do_event_conversion(rest)] 732 | defp do_event_conversion([{:timeout, time} | rest]), do: [{{:timeout, nil}, time, nil} | do_event_conversion(rest)] 733 | defp do_event_conversion([{:transition, tr} | rest]), do: [{:next_event, :internal, {:"$transition", tr}} | do_event_conversion(rest)] 734 | defp do_event_conversion([{:update, data} | rest]), do: [{:next_event, :internal, {:"$update", data}} | do_event_conversion(rest)] 735 | defp do_event_conversion([{:goto, state} | rest]), do: [{:next_event, :internal, {:"$goto", state}} | do_event_conversion(rest)] 736 | defp do_event_conversion([:noop | rest]), do: do_event_conversion(rest) 737 | defp do_event_conversion([any | rest]), do: [any | do_event_conversion(rest)] 738 | 739 | @typep internal_event_result :: :gen_statem.event_handler_result(atom) 740 | @typep internal_data :: %{data: any, module: module} 741 | 742 | import StateServer.Macros, only: [do_delegate_translation: 5, do_delegate_translation: 6] 743 | 744 | defp do_transition(state, tr, data = %{module: module}, actions) do 745 | 746 | next_state = module.__transition__(state, tr) 747 | 748 | unless next_state do 749 | raise InvalidTransitionError, "transition #{tr} does not exist in #{module}" 750 | end 751 | 752 | # cache the transition so that we can present it to on_state_entry 753 | data_tr = %{data | transition: tr} 754 | 755 | state 756 | |> data.handle_transition.(tr, data.data) 757 | |> do_delegate_translation(:handle_transition, state, tr, data) 758 | |> case do 759 | :cancel -> 760 | {:keep_state, data_tr, do_event_conversion(actions)} 761 | 762 | {:cancel, extra_actions} -> 763 | {:keep_state, data_tr, do_event_conversion(actions ++ extra_actions)} 764 | 765 | :noreply -> 766 | {:next_state, next_state, data_tr, do_event_conversion(actions)} 767 | 768 | # update events at the beginning of the queue are privileged and are triggered 769 | # before changisg state. 770 | 771 | {:noreply, [{:update, new_data} | other_actions]} -> 772 | {:next_state, next_state, %{data_tr | data: new_data}, do_event_conversion(actions ++ other_actions)} 773 | 774 | {:noreply, extra_actions} -> 775 | {:next_state, next_state, data_tr, do_event_conversion(actions ++ extra_actions)} 776 | end 777 | |> do_collapse_next_state(state, next_state) 778 | end 779 | 780 | defp do_collapse_next_state({:next_state, _, new_data, events}, state, state) do 781 | {:repeat_state, new_data, events} 782 | end 783 | defp do_collapse_next_state(any, _, _), do: any 784 | 785 | defp do_reply_translation(msg, from, state, data) do 786 | case msg do 787 | {:reply, reply} -> 788 | {:keep_state_and_data, [{:reply, from, reply}]} 789 | 790 | {:reply, reply, [{:transition, tr}, {:update, new_data} | actions]} -> 791 | do_transition(state, tr, %{data | data: new_data}, 792 | [{:reply, from, reply} | actions]) 793 | 794 | {:reply, reply, [{:transition, tr} | actions]} -> 795 | do_transition(state, tr, data, [{:reply, from, reply} | actions]) 796 | 797 | {:reply, reply, [{:update, new_data} | actions]} -> 798 | {:keep_state, %{data | data: new_data}, 799 | [{:reply, from, reply} | do_event_conversion(actions)]} 800 | 801 | {:reply, reply, actions} -> 802 | {:keep_state_and_data, 803 | [{:reply, from, reply} | do_event_conversion(actions)]} 804 | 805 | {:stop, reason, reply, new_data} -> 806 | reply(from, reply) 807 | 808 | # we need to pass this as a 4-term argument with private data 809 | # because the noreply forms can also respond with stop with 810 | # three parameters, this prevents the data from being overwritten 811 | # into the interior twice. 812 | 813 | {:stop, reason, :"$replied", %{data | data: new_data}} 814 | 815 | other_msg -> other_msg 816 | end 817 | end 818 | 819 | defp do_noreply_translation(msg, state, data) do 820 | case msg do 821 | :noreply -> :keep_state_and_data 822 | 823 | {:noreply, [{:transition, tr}, {:update, new_data} | actions]} -> 824 | do_transition(state, tr, %{data | data: new_data}, actions) 825 | 826 | {:noreply, [{:transition, tr} | actions]} -> 827 | do_transition(state, tr, data, actions) 828 | 829 | {:noreply, [{:update, new_data} | actions]} -> 830 | {:keep_state, %{data | data: new_data}, do_event_conversion(actions)} 831 | 832 | {:noreply, actions} -> 833 | {:keep_state_and_data, do_event_conversion(actions)} 834 | 835 | {:stop, reason} -> 836 | {:stop, reason, data} 837 | 838 | {:stop, reason, new_data} -> 839 | {:stop, reason, %{data | data: new_data}} 840 | 841 | # trap the 4-term form from the "reply" mode. 842 | {:stop, reason, :"$replied", new_data} -> 843 | {:stop, reason, new_data} 844 | 845 | other_msg -> other_msg 846 | end 847 | end 848 | 849 | defp do_on_state_entry_translation(msg, data) do 850 | # similar to above, but: ignore "transition" and "update" directives at the head 851 | # and also reset the transition to nil. 852 | 853 | data_tr = %{data | transition: nil} 854 | 855 | msg 856 | |> case do 857 | :noreply -> 858 | {:keep_state, data_tr} 859 | 860 | {:noreply, [{:update, new_data} | actions]} -> 861 | {:keep_state, %{data_tr | data: new_data}, do_event_conversion(actions)} 862 | 863 | {:noreply, actions} -> 864 | {:keep_state, data_tr, do_event_conversion(actions)} 865 | end 866 | end 867 | 868 | @impl true 869 | @spec handle_event(event, any, atom, internal_data) :: internal_event_result 870 | def handle_event({:call, from}, :"$introspect", state, data) do 871 | # for debugging purposes. 872 | reply(from, Map.put(data, :state, state)) 873 | :keep_state_and_data 874 | end 875 | def handle_event({:call, from}, content, state, data) do 876 | content 877 | |> data.handle_call.(from, state, data.data) 878 | |> do_delegate_translation(:handle_call, content, from, state, data) 879 | |> do_reply_translation(from, state, data) 880 | |> do_noreply_translation(state, data) 881 | end 882 | def handle_event(:info, content, state, data) do 883 | content 884 | |> data.handle_info.(state, data.data) 885 | |> do_delegate_translation(:handle_info, content, state, data) 886 | |> do_noreply_translation(state, data) 887 | end 888 | def handle_event(:cast, content, state, data) do 889 | content 890 | |> data.handle_cast.(state, data.data) 891 | |> do_delegate_translation(:handle_cast, content, state, data) 892 | |> do_noreply_translation(state, data) 893 | end 894 | def handle_event(:internal, {:"$transition", transition}, state, data) do 895 | do_transition(state, transition, data, []) 896 | end 897 | def handle_event(:internal, {:"$goto", state}, _old_state, data = %{module: module}) do 898 | unless Keyword.has_key?(module.__state_graph__, state) do 899 | raise InvalidStateError, "#{state} not in states for #{module}" 900 | end 901 | {:next_state, state, data} 902 | end 903 | def handle_event(:internal, {:"$update", new_data}, _state, data) do 904 | {:keep_state, %{data | data: new_data}, []} 905 | end 906 | def handle_event(:internal, {:"$continue", continuation}, state, data) do 907 | continuation 908 | |> data.handle_continue.(state, data.data) 909 | |> do_delegate_translation(:handle_continue, continuation, state, data) 910 | |> do_noreply_translation(state, data) 911 | end 912 | def handle_event(:internal, content, state, data) do 913 | content 914 | |> data.handle_internal.(state, data.data) 915 | |> do_delegate_translation(:handle_internal, content, state, data) 916 | |> do_noreply_translation(state, data) 917 | end 918 | def handle_event(:timeout, time, state, data) when 919 | is_integer(time) or is_nil(time) do 920 | nil 921 | |> data.handle_timeout.(state, data.data) 922 | |> do_delegate_translation(:handle_timeout, nil, state, data) 923 | |> do_noreply_translation(state, data) 924 | end 925 | def handle_event(:timeout, {:"$event_timeout", payload}, state, data) do 926 | payload 927 | |> data.handle_timeout.(state, data.data) 928 | |> do_delegate_translation(:handle_timeout, payload, state, data) 929 | |> do_noreply_translation(state, data) 930 | end 931 | def handle_event(:timeout, payload, state, data) do 932 | payload 933 | |> data.handle_timeout.(state, data.data) 934 | |> do_delegate_translation(:handle_timeout, payload, state, data) 935 | |> do_noreply_translation(state, data) 936 | end 937 | def handle_event(:state_timeout, payload, state, data) do 938 | payload 939 | |> data.handle_timeout.(state, data.data) 940 | |> do_delegate_translation(:handle_timeout, payload, state, data) 941 | |> do_noreply_translation(state, data) 942 | end 943 | def handle_event({:timeout, nil}, payload, state, data) do 944 | payload 945 | |> data.handle_timeout.(state, data.data) 946 | |> do_delegate_translation(:handle_timeout, payload, state, data) 947 | |> do_noreply_translation(state, data) 948 | end 949 | def handle_event({:timeout, name}, nil, state, data) do 950 | name 951 | |> data.handle_timeout.(state, data.data) 952 | |> do_delegate_translation(:handle_timeout, name, state, data) 953 | |> do_noreply_translation(state, data) 954 | end 955 | def handle_event({:timeout, name}, payload, state, data) do 956 | {name, payload} 957 | |> data.handle_timeout.(state, data.data) 958 | |> do_delegate_translation(:handle_timeout, {name, payload}, state, data) 959 | |> do_noreply_translation(state, data) 960 | end 961 | def handle_event(:enter, _pre, post, data = %{on_state_entry: on_state_entry, transition: transition}) do 962 | transition 963 | |> on_state_entry.(post, data.data) 964 | |> do_delegate_translation(:on_state_entry, transition, post, data) 965 | |> do_on_state_entry_translation(data) 966 | end 967 | 968 | @impl true 969 | @spec terminate(any, atom, data) :: any 970 | def terminate(reason, state, %{module: module, data: data}) do 971 | with s_modules when not is_nil(s_modules) <- module.__info__(:attributes)[:state_modules], 972 | submodule when not is_nil(submodule) <- s_modules[state], 973 | true <- function_exported?(submodule, :terminate, 2) do 974 | submodule.terminate(reason, data) 975 | else 976 | _ -> 977 | function_exported?(module, :terminate, 3) && module.terminate(reason, state, data) 978 | end 979 | end 980 | 981 | ############################################################################# 982 | ## GenServer wrappers. 983 | 984 | # these should all be inlined. 985 | @compile {:inline, call: 3, cast: 2, reply: 2, code_change: 4, format_status: 2} 986 | 987 | @spec call(server, any, timeout) :: term 988 | @doc " 989 | should be identical to `GenServer.call/3` 990 | 991 | **NB**: this behavior is consistent with the GenServer call but NOT the 992 | `:gen_statem.call/3`, which spawns a proxy process. StateServer 993 | chooses the GenServer call to maintain consistency across developer 994 | expectations. If you need `:gen_statem`-like behavior, you can manually 995 | call `:gen_statem.call/3` passing the pid or reference and it should work 996 | as expected. 997 | " 998 | def call(server, request, timeout \\ 5000) do 999 | GenServer.call(server, request, timeout) 1000 | end 1001 | 1002 | @spec cast(server, any) :: :ok 1003 | @doc "should be identical to `GenServer.cast/2`" 1004 | def cast(server, request), do: :gen_statem.cast(server, request) 1005 | 1006 | @spec reply(from, any) :: :ok 1007 | @doc "should be identical to `GenServer.reply/2`" 1008 | def reply(from, response), do: :gen_statem.reply(from, response) 1009 | 1010 | @impl true 1011 | def code_change(vsn, state, data = %{module: module}, extra) do 1012 | case module.code_change(vsn, state, data.data, extra) do 1013 | {:ok, new_state, new_data} -> 1014 | {:ok, new_state, %{data | data: new_data}} 1015 | failure -> failure 1016 | end 1017 | end 1018 | 1019 | # checks if a module implements a certain functional callback, 1020 | # if it does, then add the appropriate lambda into the map. 1021 | # if it doesn't, but has a state shim, then use that. Otherwise, 1022 | # fall back on the default which appears in this module. 1023 | @spec add_callback(map, module, {atom, non_neg_integer}) :: map 1024 | defp add_callback(selector, module, {fun, arity}) do 1025 | shim = Macros.state_shim_for(fun) 1026 | target = cond do 1027 | function_exported?(module, fun, arity) -> 1028 | :erlang.make_fun(module, fun, arity) 1029 | function_exported?(module, shim, arity) -> 1030 | :erlang.make_fun(module, shim, arity) 1031 | true -> 1032 | :erlang.make_fun(__MODULE__, fun, arity) 1033 | end 1034 | Map.put(selector, fun, target) 1035 | end 1036 | 1037 | # does a pass over all of the optional callbacks, loading those lambdas 1038 | # into the module struct. Also loads the module into the selector. 1039 | # should only be run once, at init() time 1040 | @spec generate_selector(module) :: data 1041 | defp generate_selector(module) do 1042 | @optional_callbacks 1043 | |> Enum.flat_map(&(&1)) # note that optional callbacks is an accumulating attribute 1044 | |> Enum.reject(fn {fun, _} -> (fun in [:terminate | @macro_callbacks]) end) #ignore is_ functions. 1045 | |> Enum.reduce(%{@empty_init | module: module}, &add_callback(&2, module, &1)) 1046 | end 1047 | 1048 | ###################################################################### 1049 | ## default implementation of behaviour functions 1050 | 1051 | StateServer.Macros.default_handler handle_call: 4 1052 | StateServer.Macros.default_handler handle_cast: 3 1053 | StateServer.Macros.default_handler handle_continue: 3 1054 | StateServer.Macros.default_handler handle_internal: 3 1055 | StateServer.Macros.default_handler handle_timeout: 3 1056 | 1057 | @doc false 1058 | @spec handle_info(term, atom, term) :: :noreply 1059 | def handle_info(msg, _state, _data) do 1060 | proc = 1061 | case Process.info(self(), :registered_name) do 1062 | {_, []} -> self() 1063 | {_, name} -> name 1064 | end 1065 | 1066 | pattern = ~c"~p ~p received unexpected message in handle_info/3: ~p~n" 1067 | :error_logger.error_msg(pattern, [__MODULE__, proc, msg]) 1068 | 1069 | :noreply 1070 | end 1071 | 1072 | @doc false 1073 | @spec handle_transition(atom, atom, term) :: :noreply 1074 | def handle_transition(_state, _transition, _data), do: :noreply 1075 | 1076 | @doc false 1077 | @spec on_state_entry(atom, atom, term) :: :noreply 1078 | def on_state_entry(_state, _transition, _data), do: :noreply 1079 | 1080 | @impl true 1081 | def format_status(status, [pdict, state, data = %{module: module}]) do 1082 | if function_exported?(module, :format_status, 2) do 1083 | module.format_status(status, [pdict, state, data.data]) 1084 | else 1085 | format_status_default(status, data.data) 1086 | end 1087 | end 1088 | 1089 | defp format_status_default(:terminate, data), do: data.data 1090 | defp format_status_default(_, data), do: [{:data, [{"State", data.data}]}] 1091 | 1092 | ############################################################################### 1093 | ## State modules 1094 | 1095 | @doc """ 1096 | Defines a state module to organize your code internally. 1097 | 1098 | Keep in mind that the arities of all of the callbacks are less one since 1099 | the associated state is bound in the parent module. 1100 | 1101 | Example: 1102 | 1103 | ```elixir 1104 | defstate On, for: :on do 1105 | @impl true 1106 | def handle_call(_, _, _) do 1107 | ... 1108 | end 1109 | end 1110 | ``` 1111 | """ 1112 | defmacro defstate(module_ast = {:__aliases__, _, [module_alias]}, [for: state], code) do 1113 | module_name = Module.concat(__CALLER__.module, module_alias) 1114 | code! = inject_behaviour(code) 1115 | quote do 1116 | @state_modules {unquote(state), unquote(module_name)} 1117 | defmodule unquote(module_ast), unquote(code!) 1118 | end 1119 | end 1120 | 1121 | @doc """ 1122 | Like `defstate/3` but lets you define your module externally. 1123 | 1124 | Example: 1125 | 1126 | ```elixir 1127 | defstate Some.Other.Module, for: :on 1128 | ``` 1129 | """ 1130 | defmacro defstate(module, [for: state]) do 1131 | module_name = Macro.expand(module, __CALLER__) 1132 | quote do 1133 | require unquote(module_name) # the module needs to be loaded to avoid strange compilation race conditions. 1134 | @state_modules {unquote(state), unquote(module_name)} 1135 | end 1136 | end 1137 | 1138 | defp inject_behaviour([do: {:__block__, [], codelines}]) do 1139 | [do: {:__block__, [], [quote do 1140 | @behaviour StateServer.State 1141 | import StateServer.Macros, only: [ignore: 1] 1142 | end | codelines] 1143 | }] 1144 | end 1145 | defp inject_behaviour([do: one_line]) do 1146 | [do: quote do 1147 | @behaviour StateServer.State 1148 | import StateServer.Macros, only: [ignore: 1] 1149 | unquote(one_line) 1150 | end] 1151 | end 1152 | 1153 | defmacro __before_compile__(_) do 1154 | module = __CALLER__.module 1155 | 1156 | state_graph = Module.get_attribute(module, :state_graph) 1157 | state_modules = Module.get_attribute(module, :state_modules) 1158 | 1159 | # verify that our body modules are okay. 1160 | Enum.each(Keyword.keys(state_modules), fn state -> 1161 | unless Keyword.has_key?(state_graph, state) do 1162 | raise ArgumentError, "you attempted to bind a module to nonexistent state #{state}" 1163 | end 1164 | 1165 | behaviours = state_modules[state].__info__(:attributes)[:behaviour] 1166 | 1167 | unless behaviours && (StateServer.State in behaviours) do 1168 | raise CompileError, description: "the module #{state_modules[state]} doesn't implement the StateServer.State behaviour." 1169 | end 1170 | end) 1171 | 1172 | shims = @optional_callbacks 1173 | |> Enum.flat_map(&(&1)) 1174 | |> Enum.reject(fn {fun, _} -> fun in @macro_callbacks end) 1175 | |> Enum.map(&make_shim(&1, state_modules)) 1176 | 1177 | quote do 1178 | unquote_splicing(shims) 1179 | end 1180 | end 1181 | 1182 | defp make_shim({fun, arity}, state_modules) do 1183 | shim_parts = Enum.map(state_modules, fn {state, module} -> 1184 | if function_exported?(module, fun, arity - 1) do 1185 | make_function_for(module, fun, state) 1186 | end 1187 | end) 1188 | quote do 1189 | unquote_splicing(shim_parts) 1190 | end 1191 | end 1192 | 1193 | # handle_call is exceptional since it is a /4 function 1194 | defp make_function_for(module, :handle_call, state) do 1195 | quote do 1196 | @doc false 1197 | def __handle_call_shim__(msg, from, unquote(state), data) do 1198 | unquote(module).handle_call(msg, from, data) 1199 | end 1200 | end 1201 | end 1202 | # handle transition has an unusal parameter order. 1203 | defp make_function_for(module, :handle_transition, state) do 1204 | quote do 1205 | @doc false 1206 | 1207 | def __handle_transition_shim__(unquote(state), transition, data) do 1208 | unquote(module).handle_transition(transition, data) 1209 | end 1210 | end 1211 | end 1212 | # works for handle_cast/3, handle_info/3, handle_internal/3, handle_continue/3, handle_timeout/3, 1213 | # on_state_entry/3, and terminate/3 1214 | defp make_function_for(module, fun, state) 1215 | when fun in [:handle_cast, :handle_info, :handle_internal, 1216 | :handle_continue, :handle_timeout, :on_state_entry, :terminate] do 1217 | shim_fn_name = Macros.state_shim_for(fun) 1218 | quote do 1219 | @doc false 1220 | def unquote(shim_fn_name)(msg, unquote(state), data) do 1221 | unquote(module).unquote(fun)(msg, data) 1222 | end 1223 | end 1224 | end 1225 | 1226 | @doc """ 1227 | a shortcut which lets you trap all other cases and send them to be 1228 | handled by individual state modules. 1229 | 1230 | ```elixir 1231 | delegate :handle_call 1232 | ``` 1233 | 1234 | is equivalent to 1235 | 1236 | ```elixir 1237 | def handle_call(_, _, _, _), do: :delegate 1238 | ``` 1239 | """ 1240 | defmacro delegate(:handle_call) do 1241 | quote do 1242 | def handle_call(_, _, _, _), do: :delegate 1243 | end 1244 | end 1245 | defmacro delegate(fun) when fun in 1246 | [:handle_cast, :handle_info, :handle_internal, :handle_continue, 1247 | :handle_timeout, :handle_transition, :on_state_entry] do 1248 | quote do 1249 | def unquote(fun)(_, _, _), do: :delegate 1250 | end 1251 | end 1252 | 1253 | @deprecated "Use delegate/1 instead" 1254 | defmacro defer({:handle_call, _, _}) do 1255 | quote do 1256 | def handle_call(_, _, _, _), do: :defer 1257 | end 1258 | end 1259 | defmacro defer({fun, _, _}) when fun in 1260 | [:handle_cast, :handle_info, :handle_internal, :handle_continue, 1261 | :handle_timeout, :handle_transition, :on_state_entry] do 1262 | quote do 1263 | def unquote(fun)(_, _, _), do: :defer 1264 | end 1265 | end 1266 | 1267 | # a debugging tool 1268 | @doc false 1269 | @spec __introspect__(GenServer.server) :: map 1270 | def __introspect__(srv), do: call(srv, :"$introspect") 1271 | 1272 | end 1273 | -------------------------------------------------------------------------------- /lib/state_server/invalid_state_error.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer.InvalidStateError do 2 | 3 | @moduledoc "raised when you pass a funny atom to the `{:goto, _}` event" 4 | 5 | defexception [:message] 6 | end 7 | -------------------------------------------------------------------------------- /lib/state_server/invalid_transition_error.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer.InvalidTransitionError do 2 | 3 | @moduledoc "raised when you pass a funny atom to the `{:transition, _}` event" 4 | 5 | defexception [:message] 6 | end 7 | -------------------------------------------------------------------------------- /lib/state_server/macros.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer.Macros do 2 | 3 | @moduledoc false 4 | 5 | defmacro default_handler([{name, arity}]) do 6 | generate_handler(name, arity) 7 | end 8 | 9 | @spec generate_handler(atom, non_neg_integer) :: Macro.t 10 | def generate_handler(name, arity) do 11 | params = Enum.map(1..arity, fn _ -> {:_, [], Elixir} end) 12 | 13 | # block comes from GenServer implementation 14 | block = default_failure_code(name, arity) 15 | 16 | handler_fn = {:def, 17 | [context: __MODULE__, import: Kernel], 18 | [{name, [context: __MODULE__], params}, [do: block]]} 19 | 20 | quote do 21 | @doc false 22 | unquote(handler_fn) 23 | end 24 | end 25 | 26 | defp default_failure_code(name, arity) do 27 | 28 | fun = "#{name}/#{arity}" 29 | 30 | prefix = "attempted to call #{fun} for StateServer " 31 | postfix = " but no #{fun} clause was provided" 32 | 33 | quote do 34 | proc = case Process.info(self(), :registered_name) do 35 | {_, []} -> self() 36 | {_, name} -> name 37 | end 38 | 39 | case :erlang.phash2(1, 1) do 40 | 0 -> 41 | raise unquote(prefix) <> inspect(proc) <> unquote(postfix) 42 | 1 -> 43 | {:stop, {:EXIT, "call error"}} 44 | end 45 | end 46 | end 47 | 48 | defmacro do_delegate_translation(prev, :handle_call, payload, from, state, data) do 49 | generate_handle_call_delegate_translation(prev, payload, from, state, data) 50 | end 51 | 52 | @spec generate_handle_call_delegate_translation(Macro.t, Macro.t, Macro.t, Macro.t, Macro.t) :: Macro.t 53 | def generate_handle_call_delegate_translation(prev, payload, from, state, data) do 54 | default_handler_code = default_failure_code(:handle_call, 4) 55 | quote do 56 | unquote(prev) 57 | |> case do 58 | de when de in [:defer, :delegate] -> 59 | if function_exported?(unquote(data).module, :__handle_call_shim__, 4) do 60 | unquote(data).module.__handle_call_shim__(unquote(payload), 61 | unquote(from), 62 | unquote(state), 63 | unquote(data).data) 64 | else 65 | unquote(default_handler_code) 66 | end 67 | {de, events = [{type, _}, {:update, new_data} | _]} 68 | when type in [:goto, :transition] and de in [:defer, :delegate]-> 69 | if function_exported?(unquote(data).module, :__handle_call_shim__, 4) do 70 | unquote(data).module.__handle_call_shim__(unquote(payload), 71 | unquote(from), 72 | unquote(state), 73 | new_data) 74 | |> StateServer.Macros.prepend_events(events) 75 | else 76 | unquote(default_handler_code) 77 | end 78 | {de, events = [{:update, new_data} | _]} when de in [:defer, :delegate]-> 79 | if function_exported?(unquote(data).module, :__handle_call_shim__, 4) do 80 | unquote(data).module.__handle_call_shim__(unquote(payload), 81 | unquote(from), 82 | unquote(state), 83 | new_data) 84 | |> StateServer.Macros.prepend_events(events) 85 | else 86 | unquote(default_handler_code) 87 | end 88 | {de, events} when de in [:defer, :delegate]-> 89 | if function_exported?(unquote(data).module, :__handle_call_shim__, 4) do 90 | unquote(data).module.__handle_call_shim__(unquote(payload), 91 | unquote(from), 92 | unquote(state), 93 | unquote(data).data) 94 | |> StateServer.Macros.prepend_events(events) 95 | else 96 | unquote(default_handler_code) 97 | end 98 | any -> any 99 | end 100 | end 101 | end 102 | 103 | defmacro do_delegate_translation(prev, fun, payload, state, data) do 104 | generate_delegate_translation(prev, fun, payload, state, data) 105 | end 106 | 107 | @spec generate_delegate_translation(Macro.t, atom, Macro.t, Macro.t, Macro.t) :: Macro.t 108 | def generate_delegate_translation(prev, fun, payload, state, data) do 109 | default_handler_code = default_failure_code(fun, 3) 110 | shim_fn = state_shim_for(fun) 111 | quote do 112 | unquote(prev) 113 | |> case do 114 | de when de in [:defer, :delegate] -> 115 | if function_exported?(unquote(data).module, unquote(shim_fn), 3) do 116 | unquote(data).module.unquote(shim_fn)(unquote(payload), unquote(state), unquote(data).data) 117 | else 118 | unquote(default_handler_code) 119 | end 120 | {de, events = [{type, _}, {:update, new_state} | _]} 121 | when type in [:goto, :transition] and de in [:defer, :delegate] -> 122 | if function_exported?(unquote(data).module, unquote(shim_fn), 3) do 123 | unquote(data).module.unquote(shim_fn)( 124 | unquote(payload), 125 | unquote(state), 126 | new_state) 127 | |> StateServer.Macros.prepend_events(events) 128 | else 129 | unquote(default_handler_code) 130 | end 131 | {de, events = [{:update, new_state} | _]} when de in [:defer, :delegate] -> 132 | if function_exported?(unquote(data).module, unquote(shim_fn), 3) do 133 | unquote(data).module.unquote(shim_fn)( 134 | unquote(payload), 135 | unquote(state), 136 | new_state) 137 | |> StateServer.Macros.prepend_events(events) 138 | else 139 | unquote(default_handler_code) 140 | end 141 | {de, events} when de in [:defer, :delegate] -> 142 | if function_exported?(unquote(data).module, unquote(shim_fn), 3) do 143 | unquote(data).module.unquote(shim_fn)(unquote(payload), unquote(state), unquote(data).data) 144 | |> StateServer.Macros.prepend_events(events) 145 | else 146 | unquote(default_handler_code) 147 | end 148 | any -> any 149 | end 150 | end 151 | end 152 | 153 | @handlers [:handle_cast, :handle_continue, :handle_info, :handle_internal, 154 | :handle_timeout, :handle_transition, :on_state_entry, :terminate] 155 | 156 | defmacro ignore(handler) when handler in @handlers do 157 | quote do 158 | def unquote(handler)(_, _), do: :noreply 159 | end 160 | end 161 | 162 | @spec state_shim_for(atom) :: atom 163 | def state_shim_for(fun) do 164 | String.to_atom("__" <> Atom.to_string(fun) <> "_shim__") 165 | end 166 | 167 | def prepend_events({:reply, term}, events), do: {:reply, term, events} 168 | def prepend_events({:reply, term, called_events}, events), do: {:reply, term, events ++ called_events} 169 | def prepend_events(:noreply, events), do: {:noreply, events} 170 | def prepend_events({:noreply, called_events}, events), do: {:noreply, events ++ called_events} 171 | 172 | end 173 | -------------------------------------------------------------------------------- /lib/state_server/state.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer.State do 2 | 3 | @state_server_with_states_code File.read!("example/switch_with_states.exs") 4 | 5 | @moduledoc """ 6 | A behaviour that lets you organize code for your `StateServer` states. 7 | 8 | ## Organization 9 | 10 | When you define your `StateServer`, the `StateServer` module gives you the 11 | opportunity to define **state modules**. These are typically (but not 12 | necessarily) submodules scoped under the main `StateServer` module. In 13 | this way, your code for handling events can be neatly organized by 14 | state. In some (but not all) cases, this may be the most appropriate 15 | way to keep your state machine codebase sane. 16 | 17 | ### Defining the state module. 18 | 19 | the basic syntax for defining a state module is as follows: 20 | 21 | ```elixir 22 | defstate MyModule, for: :state do 23 | # ... code goes here ... 24 | 25 | def handle_call(:increment, _from, data) do 26 | {:reply, :ok, update: data + 1} 27 | end 28 | end 29 | ``` 30 | 31 | note that the callback directives defined in this module are identical 32 | to those of `StateServer`, except that they are missing the `state` 33 | argument. 34 | 35 | ### External state modules 36 | 37 | You might want to use an external module to handle event processing for 38 | one your state machine. Reasons might include: 39 | 40 | - to enable code reuse between state machines 41 | - if your codebase is getting too long and you would like to put state 42 | modules in different files. 43 | 44 | If you choose to do so, there is a **short form** `defstate` call, which is 45 | as follows: 46 | 47 | ```elixir 48 | defstate ExternalModule, for: :state 49 | ``` 50 | 51 | Be sure to mark your `ExternalModule` as having the `StateServer.State` behaviour. 52 | 53 | ### Precedence and delegate statements 54 | 55 | Note that `handle_\*` functions written directly in the body of the 56 | `StateServer` take precedence over any functions written as a part of a state 57 | module. In the case where there are competing function calls, your handler 58 | functions written *in the body* of the `StateServer` may emit `:delegate` as a 59 | result, which will punt the processing of the event to the state modules. 60 | 61 | ```elixir 62 | # make sure query calls happen regardless of state 63 | def handle_call(:query, _from, _state, data) do 64 | {:reply, {state, data}} 65 | end 66 | # for all other call payloads, send to the state modules 67 | def handle_call(_, _, _, _) do 68 | :delegate 69 | end 70 | 71 | defstate Start, for: :start do 72 | def handle_call(...) do... 73 | ``` 74 | 75 | since this is a common pattern, we provide a `delegate` macro which is 76 | equivalent to the above: 77 | 78 | ```elixir 79 | # make sure query calls happen regardless of state 80 | def handle_call(:query, _from, _state, data) do 81 | {:reply, {state, data}} 82 | end 83 | # for all other call payloads, send to the state modules 84 | delegate :handle_call 85 | ``` 86 | 87 | #### Important 88 | 89 | If you handle an event via **any** instance of a handler function block in 90 | the main `StateServer` module, and return a `:reply` or `:noreply`, it 91 | will **not** be handled by the `State` module, you must explicitly 92 | specify `:delegate` to be handled by `State` modules. 93 | 94 | If there are **no** instances of the handler function, then handling will 95 | default to the `State` modules without using the `delegate` macro. 96 | 97 | #### delegate with common events 98 | 99 | If you would like to perform analysis on the inbound data, generating events 100 | *and* delegate to the individual states for further state-specific event 101 | processing, you may do so with the `{:delegate, events}` result type. For 102 | example, the following code: 103 | 104 | ```elixir 105 | # perform some common processing 106 | def handle_call({:query, payload}, _from, _state, data) do 107 | common_events = generate_common_events_from(payload) 108 | {:delegate, common_events} 109 | end 110 | 111 | defstate Start, for: :start do 112 | def handle_call({:query, payload}, _from, data) do 113 | # ...some code here... 114 | {:reply, result, start_events} 115 | end 116 | end 117 | ``` 118 | 119 | Will result in the event stream `common_events ++ start_events` emitted 120 | when `{:query, payload}` is called to the state server, with the following 121 | exception: 122 | 123 | - If you have an `{:update, }` in the first position, or in the 124 | second position with a `{:goto, }`, or `{:transition, }` 125 | in the first position, the update event will be reflected in the 126 | delegated state machine call. 127 | 128 | #### ignore/1 129 | 130 | inside of a `defstate` module you may use the `ignore/1` macro, which 131 | used as follows: 132 | 133 | ``` 134 | ignore :handle_cast 135 | ``` 136 | 137 | and inserts the following stanza: 138 | 139 | ``` 140 | def handle_cast(_, _), do: :noreply 141 | ``` 142 | 143 | this works for `handle_cast`, `handle_info`, `handle_continue`, 144 | `handle_internal`, `handle_transition`, `on_state_entry`, and `terminate`. 145 | 146 | ### Termination rules 147 | 148 | If a State module implements the `c:terminate/2` callback, then it will be 149 | called on termination. If it does not, termination will follow the parent 150 | StateServer's `c:StateServer.terminate/3` if it exists. Otherwise, no 151 | action will be taken on terminate. 152 | 153 | ## Example 154 | 155 | The following code should produce a "light switch" state server that 156 | announces when it's been flipped. 157 | 158 | ```elixir 159 | #{@state_server_with_states_code} 160 | ``` 161 | """ 162 | 163 | @typedoc false 164 | @type from :: StateServer.from 165 | 166 | @typedoc false 167 | @type reply_response :: StateServer.reply_response 168 | 169 | @typedoc false 170 | @type noreply_response :: StateServer.noreply_response 171 | 172 | @typedoc false 173 | @type stop_response :: StateServer.stop_response 174 | 175 | @callback handle_call(term, from, data :: term) :: reply_response | noreply_response | stop_response 176 | @callback handle_cast(term, data :: term) :: noreply_response | stop_response 177 | @callback handle_continue(term, data :: term) :: noreply_response | stop_response 178 | @callback handle_info(term, data :: term) :: noreply_response | stop_response 179 | @callback handle_internal(term, data :: term) :: noreply_response | stop_response 180 | @callback handle_timeout(term, data :: term) :: noreply_response | stop_response 181 | @callback handle_transition(transition :: atom, data :: term) :: noreply_response | stop_response | :cancel 182 | @callback on_state_entry(transition :: atom, data :: term) :: StateServer.on_state_entry_response 183 | @callback terminate(reason :: term, data :: term) :: term 184 | 185 | @optional_callbacks [handle_call: 3, handle_cast: 2, handle_continue: 2, 186 | handle_info: 2, handle_internal: 2, handle_timeout: 2, handle_transition: 2, 187 | on_state_entry: 2, terminate: 2] 188 | 189 | end 190 | -------------------------------------------------------------------------------- /lib/state_server/state_graph.ex: -------------------------------------------------------------------------------- 1 | defmodule StateServer.StateGraph do 2 | @moduledoc """ 3 | tools for dealing with stategraphs. 4 | 5 | State graphs take the form of a keyword list of keyword lists, wherein the 6 | outer list is a comprehensive list of the states, and the inner lists are 7 | keywords list of all the transitions, with the values of the keyword list 8 | as the destination states. 9 | """ 10 | 11 | @type t :: keyword(keyword(atom)) 12 | 13 | @doc """ 14 | checks the validity of the state graph. 15 | 16 | Should only be called when we build the state graph. 17 | 18 | A state graph is valid if and only if all of the following are true: 19 | 20 | 0. the graph is a keyword of keywords. 21 | 1. there is at least one state. 22 | 2. there are no duplicate state definitions. 23 | 3. there are no duplicate transition definitions emanating from any given state. 24 | 4. all of the destinations of the transitions are states. 25 | """ 26 | @spec valid?(t) :: boolean 27 | def valid?([]), do: false 28 | def valid?(stategraph) when is_list(stategraph) do 29 | # check to make sure everything is a keyword of keywords. 30 | Enum.each(stategraph, fn 31 | {state, transitions} when is_atom(state) -> 32 | Enum.each(transitions, fn 33 | {transition, destination} when 34 | is_atom(transition) and is_atom(destination) -> 35 | :ok 36 | _ -> throw :invalid 37 | end) 38 | _ -> throw :invalid 39 | end) 40 | 41 | state_names = states(stategraph) 42 | # check to make sure the states are unique. 43 | state_names == Enum.uniq(state_names) || throw :invalid 44 | 45 | stategraph 46 | |> Keyword.values 47 | |> Enum.all?(fn state_transitions -> 48 | transition_names = Keyword.keys(state_transitions) 49 | 50 | # check to make sure the transition names are unique for each state's transition set. 51 | transition_names == Enum.uniq(transition_names) || throw :invalid 52 | 53 | # check to make sure that the transition destinations are valid. 54 | state_transitions 55 | |> Keyword.values 56 | |> Enum.all?(&(&1 in state_names)) 57 | end) 58 | 59 | catch 60 | :invalid -> false 61 | end 62 | def valid?(_), do: false 63 | 64 | @doc """ 65 | returns the starting state from the state graph. 66 | ```elixir 67 | iex> StateServer.StateGraph.start([start: [t1: :state1], state1: [t2: :state2], state2: []]) 68 | :start 69 | ``` 70 | """ 71 | @spec start(t) :: atom 72 | def start([{v, _} | _]), do: v 73 | 74 | @doc """ 75 | lists all states in the state graph. The first element in this list will 76 | always be the initial state. 77 | 78 | ### Example 79 | ```elixir 80 | iex> StateServer.StateGraph.states([start: [t1: :state1], state1: [t2: :state2], state2: [t2: :state2]]) 81 | [:start, :state1, :state2] 82 | ``` 83 | """ 84 | @spec states(t) :: [atom] 85 | def states(stategraph), do: Keyword.keys(stategraph) 86 | 87 | @doc """ 88 | lists all transitions in the state graph. 89 | 90 | ### Example 91 | ```elixir 92 | iex> StateServer.StateGraph.transitions([start: [t1: :state1], state1: [t2: :state2], state2: [t2: :state2]]) 93 | [:t1, :t2] 94 | ``` 95 | """ 96 | @spec transitions(t) :: [atom] 97 | def transitions(stategraph) do 98 | stategraph 99 | |> Keyword.values 100 | |> Enum.flat_map(&Keyword.keys/1) 101 | |> Enum.uniq 102 | end 103 | 104 | @doc """ 105 | lists all transitions emanating from a given state. 106 | 107 | ### Example 108 | ```elixir 109 | iex> StateServer.StateGraph.transitions([start: [t1: :state1, t2: :state2], state1: [], state2: []], :start) 110 | [:t1, :t2] 111 | ``` 112 | """ 113 | @spec transitions(t, atom) :: [atom] 114 | def transitions(stategraph, state), do: Keyword.keys(stategraph[state]) 115 | 116 | @doc """ 117 | lists all state/transition pairs. Used to generate the `c:StateServer.is_transition/2` guard. 118 | 119 | ### Example 120 | ```elixir 121 | iex> StateServer.StateGraph.all_transitions([start: [t1: :state1, t2: :state2], state1: [], state2: [t2: :state1]]) 122 | [start: :t1, start: :t2, state2: :t2] 123 | ``` 124 | """ 125 | @spec all_transitions(t) :: keyword 126 | def all_transitions(stategraph) do 127 | stategraph 128 | |> Enum.flat_map(fn 129 | {st, trs} -> Enum.map(trs, fn {tr, _dest} -> {st, tr} end) 130 | end) 131 | end 132 | 133 | @doc """ 134 | outputs the destination state given a source state and a transition. 135 | 136 | ### Example 137 | ```elixir 138 | iex> StateServer.StateGraph.transition([start: [t1: :state1, t2: :state2], state1: [], state2: []], :start, :t1) 139 | :state1 140 | ``` 141 | """ 142 | @spec transition(t, start::atom, transition::atom) :: atom 143 | def transition(stategraph, start, transition) do 144 | stategraph 145 | |> Keyword.get(start) 146 | |> Keyword.get(transition) 147 | end 148 | 149 | @doc """ 150 | outputs a list of terminal states of the graph. Used to generate the 151 | `c:StateServer.is_terminal/1` guard. 152 | 153 | ```elixir 154 | iex> StateServer.StateGraph.terminal_states(start: [t1: :state1, t2: :state2], state1: [], state2: []) 155 | [:state1, :state2] 156 | ``` 157 | """ 158 | @spec terminal_states(t) :: [atom] 159 | def terminal_states(stategraph) do 160 | Enum.flat_map(stategraph, fn 161 | {state, []} -> [state] 162 | _ -> [] 163 | end) 164 | end 165 | 166 | @doc """ 167 | outputs a list of edges of the graph. Used to generate the `c:StateServer.is_transition/3` guard. 168 | 169 | ```elixir 170 | iex> StateServer.StateGraph.edges(start: [t1: :state1, t2: :state2], state1: [t3: :start], state2: []) 171 | [start: {:t1, :state1}, start: {:t2, :state2}, state1: {:t3, :start}] 172 | ``` 173 | """ 174 | @spec edges(t) :: keyword({atom, atom}) 175 | def edges(state_graph) do 176 | Enum.flat_map(state_graph, fn 177 | {_, []} -> [] 178 | {state, transitions} -> 179 | Enum.flat_map(transitions, fn 180 | {transition, dest} -> 181 | [{state, {transition, dest}}] 182 | end) 183 | _ -> [] 184 | end) 185 | end 186 | 187 | @doc """ 188 | outputs a list of terminal {state, transition} tuples of the graph. Used to generate the 189 | `c:StateServer.is_terminal/2` guard. 190 | 191 | ```elixir 192 | iex> StateServer.StateGraph.terminal_transitions(start: [t1: :state1, t2: :state2], state1: [], state2: []) 193 | [start: :t1, start: :t2] 194 | ``` 195 | """ 196 | @spec terminal_transitions(t) :: keyword(atom) 197 | def terminal_transitions(stategraph) do 198 | t_states = terminal_states(stategraph) 199 | Enum.flat_map(stategraph, &transitions_for_state(&1, t_states)) 200 | end 201 | 202 | @spec transitions_for_state({atom, keyword(atom)}, [atom]) :: keyword(atom) 203 | defp transitions_for_state({state, trs}, t_states) do 204 | Enum.flat_map(trs, fn {tr, dest} -> 205 | if dest in t_states, do: [{state, tr}], else: [] 206 | end) 207 | end 208 | 209 | @doc """ 210 | converts a list of atoms to a type which is the union of the atom literals 211 | """ 212 | @spec atoms_to_typelist([atom]) :: Macro.t 213 | def atoms_to_typelist([]), do: nil 214 | def atoms_to_typelist([state]), do: state 215 | def atoms_to_typelist([state1, state2]), do: {:|, [], [state1, state2]} 216 | def atoms_to_typelist([state | rest]), do: {:|, [], [state, atoms_to_typelist(rest)]} 217 | end 218 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServer.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :state_server, 7 | version: "0.4.10", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | test_coverage: [tool: ExCoveralls], 12 | package: [ 13 | description: "half gen_server, half gen_statem, all state machine", 14 | licenses: ["MIT"], 15 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* VERSIONS* example), 16 | links: %{"GitHub" => "https://github.com/ityonemo/state_server"} 17 | ], 18 | source_url: "https://github.com/ityonemo/state_server/", 19 | preferred_cli_env: [ 20 | coveralls: :test, 21 | "coveralls.detail": :test, 22 | "coveralls.post": :test, 23 | "coveralls.html": :test], 24 | docs: [main: "StateServer", extras: ["README.md"]] 25 | ] 26 | end 27 | 28 | def application do 29 | [ 30 | extra_applications: [:logger] 31 | ] 32 | end 33 | 34 | defp deps do 35 | [ 36 | {:credo, "~> 1.1", only: [:dev, :test], runtime: false}, 37 | {:ex_doc, "~> 0.34", only: :dev, runtime: false}, 38 | {:excoveralls, "~> 0.11", only: [:dev, :test]}, 39 | {:dialyxir, "~> 1.4", only: :dev, runtime: false}, 40 | {:multiverses, "~> 0.4", only: :test, runtime: false} 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.8", "9722ba1681e973025908d542ec3d95db5f9c549251ba5b028e251ad8c24ab8c5", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb9e87cc64f152f3ed1c6e325e7b894dea8f5ef2e41123bd864e3cd5ceb44968"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.4", "fb3ce8741edeaea59c9ae84d5cec75da00fa89fe401c72d6e047d11a61f65f70", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cd6111e8017ccd563e65621a4d9a4a1c5cd333df30cebc7face8029cacb4eff6"}, 5 | "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 9 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 10 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 15 | "multiverses": {:hex, :multiverses, "0.11.0", "91386790fbebed7e8815e2a18abbec814ad74e93c0999015e35e1fe5a30e5ad9", [:mix], [], "hexpm", "57013b3049ddc6900c01c1fb855439bd5535599aad8b09bc6256dcd43ffdc9ad"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/actions/delayed_transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.DelayedTransitionTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call(:go, _from, _state, fun) when is_function(fun, 0), do: fun.() 18 | 19 | @impl true 20 | def handle_internal({:wait, test_pid}, _state, _data) do 21 | send(test_pid, :waiting) 22 | receive do :release -> :ok end 23 | :noreply 24 | end 25 | end 26 | 27 | test "delayed transition functions are respected" do 28 | test_pid = self() 29 | 30 | # in this test we provide a transition action, but it's not at the head 31 | # of the function. 32 | 33 | {:ok, srv} = Instrumented.start_link(fn -> 34 | {:reply, "foo", internal: {:wait, test_pid}, transition: :tr} 35 | end) 36 | 37 | assert {:start, f} = Instrumented.state(srv) 38 | assert "foo" = StateServer.call(srv, :go) 39 | 40 | receive do :waiting -> send(srv, :release) end 41 | 42 | assert {:end, ^f} = Instrumented.state(srv) 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /test/actions/delayed_update_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.DelayedUpdateTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call(:go, _from, _state, fun) when is_function(fun, 0), do: fun.() 18 | 19 | @impl true 20 | def handle_internal({:wait, test_pid}, _state, _data) do 21 | send(test_pid, :waiting) 22 | receive do :release -> :ok end 23 | :noreply 24 | end 25 | end 26 | 27 | test "delayed update functions are respected" do 28 | 29 | test_pid = self() 30 | 31 | # in this test we provide a transition action, but it's not at the head 32 | # of the function. 33 | 34 | {:ok, srv} = Instrumented.start_link(fn -> 35 | {:reply, "foo", internal: {:wait, test_pid}, update: "foo"} 36 | end) 37 | 38 | assert {:start, f} = Instrumented.state(srv) 39 | assert "foo" = StateServer.call(srv, :go) 40 | 41 | receive do :waiting -> send(srv, :release) end 42 | 43 | assert {:start, "foo"} = Instrumented.state(srv) 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /test/actions/goto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.GotoTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link, do: StateServer.start_link(__MODULE__, :ok) 9 | 10 | @impl true 11 | def init(any), do: {:ok, any} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call({:goto, new_state}, _from, _state, _data) do 18 | {:reply, "foo", goto: new_state} 19 | end 20 | end 21 | 22 | test "goto can be used to change states" do 23 | {:ok, srv} = Instrumented.start_link() 24 | 25 | assert {:start, :ok} == Instrumented.state(srv) 26 | 27 | assert "foo" == StateServer.call(srv, {:goto, :end}) 28 | 29 | assert {:end, :ok} == Instrumented.state(srv) 30 | end 31 | 32 | test "going to a bad state is not allowed" do 33 | test_pid = self() 34 | 35 | # spawn the state server to avoid linking on crash. 36 | spawn(fn -> 37 | {:ok, pid} = Instrumented.start_link() 38 | send(test_pid, pid) 39 | end) 40 | 41 | srv = receive do pid -> pid end 42 | 43 | assert {:start, :ok} == Instrumented.state(srv) 44 | 45 | # issue an invalid state 46 | 47 | StateServer.call(srv, {:goto, :erehwon}) 48 | 49 | Process.sleep(100) 50 | 51 | refute Process.alive?(srv) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/actions/transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.TransitionTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link, do: StateServer.start_link(__MODULE__, :ok) 9 | 10 | @impl true 11 | def init(any), do: {:ok, any} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call({:transition, transition}, _from, _state, _data) do 18 | {:reply, "foo", [:noop, transition: transition]} 19 | end 20 | end 21 | 22 | test "transition can be used to change states" do 23 | {:ok, srv} = Instrumented.start_link() 24 | 25 | assert {:start, :ok} == Instrumented.state(srv) 26 | 27 | assert "foo" == StateServer.call(srv, {:transition, :tr}) 28 | 29 | assert {:end, :ok} == Instrumented.state(srv) 30 | end 31 | 32 | test "going via a bad transition is not allowed" do 33 | test_pid = self() 34 | 35 | # spawn the state server to avoid linking on crash. 36 | spawn(fn -> 37 | {:ok, pid} = Instrumented.start_link() 38 | send(test_pid, pid) 39 | end) 40 | 41 | srv = receive do pid -> pid end 42 | 43 | assert {:start, :ok} == Instrumented.state(srv) 44 | 45 | # issue an invalid transition 46 | StateServer.call(srv, {:transition, :undefined}) 47 | 48 | Process.sleep(100) 49 | 50 | refute Process.alive?(srv) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/assets/bad_state_binding.exs: -------------------------------------------------------------------------------- 1 | defmodule BadStateBinding do 2 | use StateServer, foo: [] 3 | 4 | defstate Bar, for: :bar do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/assets/bad_state_binding_no_block.exs: -------------------------------------------------------------------------------- 1 | defmodule External do 2 | end 3 | 4 | defmodule BadStateBindingNoBlock do 5 | use StateServer, foo: [] 6 | 7 | defstate External, for: :bar 8 | end 9 | -------------------------------------------------------------------------------- /test/assets/malformed_graph.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.MalformedGraph do 2 | use StateServer, "foo" 3 | end 4 | -------------------------------------------------------------------------------- /test/assets/use_without_graph.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.UseWithoutGraph do 2 | use StateServer 3 | end 4 | -------------------------------------------------------------------------------- /test/callbacks/handle_call_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleCallTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call(:go, _from, _state, fun) when is_function(fun, 0), do: fun.() 18 | def handle_call(:go, from, _state, fun) when is_function(fun, 1), do: fun.(from) 19 | 20 | @impl true 21 | def handle_info({:do_reply, from}, _state, _val) do 22 | reply(from, "foo") 23 | :noreply 24 | end 25 | def handle_info({:do_classic_reply, from}, _state, _val) do 26 | {:noreply, [{:reply, from, "foo"}]} 27 | end 28 | end 29 | 30 | describe "instrumenting handle_call with reply" do 31 | test "works with static/idempotent" do 32 | {:ok, srv} = Instrumented.start_link(fn -> {:reply, "foo"} end) 33 | assert {:start, f} = Instrumented.state(srv) 34 | assert "foo" = StateServer.call(srv, :go) 35 | assert {:start, ^f} = Instrumented.state(srv) 36 | end 37 | 38 | test "works with static/update" do 39 | {:ok, srv} = Instrumented.start_link(fn -> {:reply, "foo", update: "bar"} end) 40 | assert {:start, f} = Instrumented.state(srv) 41 | assert "foo" = StateServer.call(srv, :go) 42 | assert {:start, "bar"} = Instrumented.state(srv) 43 | end 44 | 45 | test "works with transition/idempotent" do 46 | {:ok, srv} = Instrumented.start_link(fn -> {:reply, "foo", transition: :tr} end) 47 | assert {:start, f} = Instrumented.state(srv) 48 | assert "foo" = StateServer.call(srv, :go) 49 | assert {:end, ^f} = Instrumented.state(srv) 50 | end 51 | 52 | test "works with transition/update" do 53 | {:ok, srv} = Instrumented.start_link(fn -> {:reply, "foo", transition: :tr, update: "bar"} end) 54 | assert {:start, f} = Instrumented.state(srv) 55 | assert "foo" = StateServer.call(srv, :go) 56 | assert {:end, "bar"} = Instrumented.state(srv) 57 | end 58 | end 59 | 60 | describe "instrumenting handle_call with a classic erlang reply" do 61 | test "works with noreply form" do 62 | {:ok, srv} = Instrumented.start_link(fn from -> {:noreply, [{:reply, from, "foo"}]} end) 63 | assert {:start, f} = Instrumented.state(srv) 64 | assert "foo" = StateServer.call(srv, :go) 65 | assert {:start, ^f} = Instrumented.state(srv) 66 | end 67 | 68 | test "works with classic statem form" do 69 | {:ok, srv} = Instrumented.start_link(fn from -> {:keep_state_and_data, [{:reply, from, "foo"}]} end) 70 | assert {:start, f} = Instrumented.state(srv) 71 | assert "foo" = StateServer.call(srv, :go) 72 | assert {:start, ^f} = Instrumented.state(srv) 73 | end 74 | 75 | test "works when delegated" do 76 | {:ok, srv} = Instrumented.start_link(fn from -> 77 | Process.send_after(self(), {:do_classic_reply, from}, 0) 78 | :noreply 79 | end) 80 | assert {:start, f} = Instrumented.state(srv) 81 | assert "foo" = StateServer.call(srv, :go) 82 | assert {:start, ^f} = Instrumented.state(srv) 83 | end 84 | end 85 | 86 | describe "instrumenting handle_call with delegated reply using Process.send_after" do 87 | test "works with static/idempotent" do 88 | {:ok, srv} = Instrumented.start_link(fn from -> 89 | Process.send_after(self(), {:do_reply, from}, 0) 90 | :noreply 91 | end) 92 | assert {:start, f} = Instrumented.state(srv) 93 | assert "foo" = StateServer.call(srv, :go) 94 | assert {:start, ^f} = Instrumented.state(srv) 95 | end 96 | 97 | test "works with static/update" do 98 | {:ok, srv} = Instrumented.start_link(fn from -> 99 | Process.send_after(self(), {:do_reply, from}, 0) 100 | {:noreply, update: "bar"} 101 | end) 102 | assert {:start, f} = Instrumented.state(srv) 103 | assert "foo" = StateServer.call(srv, :go) 104 | assert {:start, "bar"} = Instrumented.state(srv) 105 | end 106 | 107 | test "works with transition/idempotent" do 108 | {:ok, srv} = Instrumented.start_link(fn from -> 109 | Process.send_after(self(), {:do_reply, from}, 0) 110 | {:noreply, transition: :tr} 111 | end) 112 | assert {:start, f} = Instrumented.state(srv) 113 | assert "foo" = StateServer.call(srv, :go) 114 | assert {:end, ^f} = Instrumented.state(srv) 115 | end 116 | 117 | test "works with transition/update" do 118 | {:ok, srv} = Instrumented.start_link(fn from -> 119 | Process.send_after(self(), {:do_reply, from}, 0) 120 | {:noreply, transition: :tr, update: "bar"} 121 | end) 122 | assert {:start, f} = Instrumented.state(srv) 123 | assert "foo" = StateServer.call(srv, :go) 124 | assert {:end, "bar"} = Instrumented.state(srv) 125 | end 126 | end 127 | 128 | defmodule UnInstrumented do 129 | use StateServer, [start: [tr: :end], end: []] 130 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 131 | 132 | @impl true 133 | def init(_), do: {:ok, :ok} 134 | end 135 | 136 | describe "tests against uninstrumented code" do 137 | test "should throw a runtime error" do 138 | Process.flag(:trap_exit, true) 139 | {:ok, srv} = UnInstrumented.start_link(:ok) 140 | emsg = catch_exit(StateServer.call(srv, :foo)) 141 | assert {{%RuntimeError{message: msg}, _}, _} = emsg 142 | assert msg =~ "handle_call/4" 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /test/callbacks/handle_cast_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleCastTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | 18 | @impl true 19 | def handle_cast(:go, _state, fun) when is_function(fun, 0), do: fun.() 20 | end 21 | 22 | describe "instrumenting handle_info" do 23 | test "works with static/idempotent" do 24 | {:ok, srv} = Instrumented.start_link(fn -> :noreply end) 25 | assert {:start, f} = Instrumented.state(srv) 26 | StateServer.cast(srv, :go) 27 | assert {:start, ^f} = Instrumented.state(srv) 28 | end 29 | 30 | test "works with static/update" do 31 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, update: "bar"} end) 32 | assert {:start, f} = Instrumented.state(srv) 33 | StateServer.cast(srv, :go) 34 | assert {:start, "bar"} = Instrumented.state(srv) 35 | end 36 | 37 | test "works with transition/idempotent" do 38 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, transition: :tr} end) 39 | assert {:start, f} = Instrumented.state(srv) 40 | StateServer.cast(srv, :go) 41 | assert {:end, ^f} = Instrumented.state(srv) 42 | end 43 | 44 | test "works with transition/update" do 45 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, transition: :tr, update: "bar"} end) 46 | assert {:start, f} = Instrumented.state(srv) 47 | StateServer.cast(srv, :go) 48 | assert {:end, "bar"} = Instrumented.state(srv) 49 | end 50 | end 51 | 52 | defmodule UnInstrumented do 53 | use StateServer, [start: [tr: :end], end: []] 54 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 55 | 56 | @impl true 57 | def init(_), do: {:ok, :ok} 58 | end 59 | 60 | describe "tests against uninstrumented code" do 61 | test "should throw a runtime error" do 62 | Process.flag(:trap_exit, true) 63 | {:ok, srv} = UnInstrumented.start_link(:ok) 64 | StateServer.cast(srv, :foo) 65 | assert_receive {:EXIT, ^srv, {%RuntimeError{message: msg}, _}} 66 | assert msg =~ "handle_cast/3" 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/callbacks/handle_continue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleContinueTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: fun.() 12 | 13 | @impl true 14 | def handle_continue(:continuation, _state, fun), do: fun.() 15 | 16 | @impl true 17 | def handle_call(:call, _from, _state, _fun) do 18 | {:reply, :ok, continue: :continuation} 19 | end 20 | end 21 | 22 | describe "instrumenting handle_continue" do 23 | test "works from the init function" do 24 | test_pid = self() 25 | 26 | internal_function = fn -> 27 | receive do :unblock -> :ok end 28 | send(test_pid, :initialized) 29 | end 30 | 31 | {:ok, pid} = Instrumented.start_link(fn -> 32 | {:ok, internal_function, continue: :continuation} 33 | end) 34 | 35 | refute_receive :initialized 36 | send(pid, :unblock) 37 | assert_receive :initialized 38 | end 39 | 40 | test "works from a generic call function" do 41 | test_pid = self() 42 | 43 | internal_function = fn -> 44 | receive do :unblock -> :ok end 45 | send(test_pid, :continued) 46 | end 47 | 48 | {:ok, pid} = Instrumented.start_link(fn -> 49 | {:ok, internal_function} 50 | end) 51 | 52 | assert :ok == GenServer.call(pid, :call) 53 | 54 | refute_receive :continued 55 | send(pid, :unblock) 56 | assert_receive :continued 57 | end 58 | end 59 | 60 | defmodule UnInstrumented do 61 | use StateServer, [start: [tr: :end], end: []] 62 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 63 | 64 | @impl true 65 | def init(_), do: {:ok, :ok} 66 | 67 | @impl true 68 | def handle_call(:go, _, _, _) do 69 | {:reply, :ok, continue: "foo"} 70 | end 71 | end 72 | 73 | describe "tests against uninstrumented code" do 74 | test "should throw a runtime error" do 75 | Process.flag(:trap_exit, true) 76 | {:ok, srv} = UnInstrumented.start_link(:ok) 77 | StateServer.call(srv, :go) 78 | assert_receive {:EXIT, ^srv, {%RuntimeError{message: msg}, _}} 79 | assert msg =~ "handle_continue/3" 80 | end 81 | end 82 | 83 | end 84 | -------------------------------------------------------------------------------- /test/callbacks/handle_info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleInfoTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | 18 | @impl true 19 | def handle_info(:go, _state, fun) when is_function(fun, 0), do: fun.() 20 | 21 | end 22 | 23 | describe "instrumenting handle_info" do 24 | test "works with static/idempotent" do 25 | {:ok, srv} = Instrumented.start_link(fn -> :noreply end) 26 | assert {:start, f} = Instrumented.state(srv) 27 | send(srv, :go) 28 | assert {:start, ^f} = Instrumented.state(srv) 29 | end 30 | 31 | test "works with static/update" do 32 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, update: "bar"} end) 33 | assert {:start, f} = Instrumented.state(srv) 34 | send(srv, :go) 35 | assert {:start, "bar"} = Instrumented.state(srv) 36 | end 37 | 38 | test "works with transition/idempotent" do 39 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, transition: :tr} end) 40 | assert {:start, f} = Instrumented.state(srv) 41 | send(srv, :go) 42 | assert {:end, ^f} = Instrumented.state(srv) 43 | end 44 | 45 | test "works with transition/update" do 46 | {:ok, srv} = Instrumented.start_link(fn -> {:noreply, transition: :tr, update: "bar"} end) 47 | assert {:start, f} = Instrumented.state(srv) 48 | send(srv, :go) 49 | assert {:end, "bar"} = Instrumented.state(srv) 50 | end 51 | end 52 | 53 | defmodule UnInstrumented do 54 | use StateServer, [start: [tr: :end], end: []] 55 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 56 | 57 | @impl true 58 | def init(_), do: {:ok, :ok} 59 | end 60 | 61 | describe "tests against uninstrumented code" do 62 | import ExUnit.CaptureLog 63 | 64 | test "should send an error to the log" do 65 | {:ok, srv} = UnInstrumented.start_link(:ok) 66 | assert capture_log(fn -> 67 | send(srv, "msg") 68 | Process.sleep(100) 69 | end) =~ "StateServer #{inspect srv} received unexpected message in handle_info/3" 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/callbacks/handle_internal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleInternalTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | 15 | @impl true 16 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 17 | def handle_call(:go, _from, _state, _data) do 18 | {:reply, "foo", [internal: :foo]} 19 | end 20 | 21 | @impl true 22 | def handle_internal(:foo, _state, fun), do: fun.() 23 | end 24 | 25 | describe "instrumenting handle_internal" do 26 | test "works with static/idempotent" do 27 | test_pid = self() 28 | 29 | {:ok, srv} = Instrumented.start_link(fn -> 30 | send(test_pid, :foobar) 31 | :noreply 32 | end) 33 | 34 | assert {:start, f} = Instrumented.state(srv) 35 | assert "foo" = StateServer.call(srv, :go) 36 | assert_receive :foobar 37 | assert {:start, ^f} = Instrumented.state(srv) 38 | end 39 | 40 | test "works with static/update" do 41 | test_pid = self() 42 | 43 | {:ok, srv} = Instrumented.start_link(fn -> 44 | send(test_pid, :foobar) 45 | {:noreply, update: "bar"} 46 | end) 47 | 48 | assert {:start, f} = Instrumented.state(srv) 49 | assert "foo" = StateServer.call(srv, :go) 50 | assert {:start, "bar"} = Instrumented.state(srv) 51 | end 52 | 53 | test "works with transition/idempotent" do 54 | test_pid = self() 55 | 56 | {:ok, srv} = Instrumented.start_link(fn -> 57 | send(test_pid, :foobar) 58 | {:noreply, transition: :tr} 59 | end) 60 | 61 | assert {:start, f} = Instrumented.state(srv) 62 | assert "foo" = StateServer.call(srv, :go) 63 | assert {:end, ^f} = Instrumented.state(srv) 64 | end 65 | 66 | test "works with transition/update" do 67 | test_pid = self() 68 | 69 | {:ok, srv} = Instrumented.start_link(fn -> 70 | send(test_pid, :foobar) 71 | {:noreply, transition: :tr, update: "bar"} 72 | end) 73 | 74 | assert {:start, f} = Instrumented.state(srv) 75 | assert "foo" = StateServer.call(srv, :go) 76 | assert {:end, "bar"} = Instrumented.state(srv) 77 | end 78 | end 79 | 80 | defmodule UnInstrumented do 81 | use StateServer, [start: [tr: :end], end: []] 82 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 83 | 84 | @impl true 85 | def init(_), do: {:ok, :ok} 86 | 87 | @impl true 88 | def handle_call(:go, _, _, _) do 89 | {:reply, :ok, internal: "foo"} 90 | end 91 | end 92 | 93 | describe "tests against uninstrumented code" do 94 | test "should throw a runtime error" do 95 | Process.flag(:trap_exit, true) 96 | {:ok, srv} = UnInstrumented.start_link(:ok) 97 | StateServer.call(srv, :go) 98 | assert_receive {:EXIT, ^srv, {%RuntimeError{message: msg}, _}} 99 | assert msg =~ "handle_internal/3" 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/callbacks/handle_timeout_basic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleTimeoutBasicTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | def named_timeout(srv, time \\ 0), do: StateServer.call(srv, {:named_timeout, time}) 15 | def named_timeout_payload(srv, time \\ 0, payload) do 16 | StateServer.call(srv, {:named_timeout, payload, time}) 17 | end 18 | def unnamed_timeout(srv), do: StateServer.call(srv, :unnamed_timeout) 19 | 20 | @impl true 21 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 22 | def handle_call({:named_timeout, time}, _from, _state, _data) do 23 | {:reply, "foo", [timeout: {:bar, time}]} 24 | end 25 | def handle_call({:named_timeout, payload, time}, _from, _state, _data) do 26 | {:reply, "foo", [timeout: {:bar, payload, time}]} 27 | end 28 | def handle_call(:unnamed_timeout, _from, _state, _data) do 29 | {:reply, "foo", [timeout: 10]} 30 | end 31 | 32 | @impl true 33 | def handle_timeout(value, _state, fun), do: fun.(value) 34 | end 35 | 36 | describe "instrumenting handle_timeout and triggering with unnamed_timeout" do 37 | test "works, submitting nil" do 38 | test_pid = self() 39 | 40 | {:ok, srv} = Instrumented.start_link(fn value -> 41 | send(test_pid, {:foo, value}) 42 | {:noreply, update: "bar"} 43 | end) 44 | 45 | Instrumented.unnamed_timeout(srv) 46 | 47 | assert_receive {:foo, nil} 48 | end 49 | 50 | test "is not interrupted" do 51 | test_pid = self() 52 | 53 | {:ok, srv} = Instrumented.start_link(fn value -> 54 | send(test_pid, {:foo, value}) 55 | {:noreply, update: "bar"} 56 | end) 57 | 58 | Instrumented.unnamed_timeout(srv) 59 | 60 | assert {:start, _} = Instrumented.state(srv) 61 | 62 | assert_receive {:foo, nil} 63 | 64 | assert {:start, "bar"} == Instrumented.state(srv) 65 | end 66 | end 67 | 68 | describe "instrumenting handle_timeout and triggering with timeout and no payload" do 69 | test "works with static/update" do 70 | test_pid = self() 71 | 72 | {:ok, srv} = Instrumented.start_link(fn value -> 73 | send(test_pid, {:foo, value}) 74 | {:noreply, update: "bar"} 75 | end) 76 | 77 | assert {:start, f} = Instrumented.state(srv) 78 | assert "foo" = Instrumented.named_timeout(srv) 79 | assert_receive {:foo, :bar} 80 | assert {:start, "bar"} = Instrumented.state(srv) 81 | end 82 | 83 | test "works with transition/idempotent" do 84 | test_pid = self() 85 | 86 | {:ok, srv} = Instrumented.start_link(fn value -> 87 | send(test_pid, {:foo, value}) 88 | {:noreply, transition: :tr} 89 | end) 90 | 91 | assert {:start, f} = Instrumented.state(srv) 92 | assert "foo" = Instrumented.named_timeout(srv) 93 | assert_receive {:foo, :bar} 94 | assert {:end, ^f} = Instrumented.state(srv) 95 | end 96 | 97 | test "works with delayed transition/idempotent" do 98 | test_pid = self() 99 | 100 | {:ok, srv} = Instrumented.start_link(fn value -> 101 | send(test_pid, {:foo, value}) 102 | {:noreply, transition: :tr} 103 | end) 104 | 105 | assert {:start, f} = Instrumented.state(srv) 106 | assert "foo" = Instrumented.named_timeout(srv, 10) 107 | 108 | # calls don't interrupt the named timeout 109 | Process.sleep(5) 110 | assert {:start, ^f} = Instrumented.state(srv) 111 | Process.sleep(10) 112 | assert {:end, ^f} = Instrumented.state(srv) 113 | 114 | # let's be sure that we have gotten the expected response 115 | assert_receive {:foo, :bar} 116 | end 117 | end 118 | 119 | describe "instrumenting handle_timeout and triggering with timeout and payload" do 120 | test "works with static/update" do 121 | test_pid = self() 122 | 123 | {:ok, srv} = Instrumented.start_link(fn value -> 124 | send(test_pid, {:foo, value}) 125 | {:noreply, update: "bar"} 126 | end) 127 | 128 | assert {:start, f} = Instrumented.state(srv) 129 | assert "foo" = Instrumented.named_timeout_payload(srv, :payload) 130 | assert_receive {:foo, {:bar, :payload}} 131 | assert {:start, "bar"} = Instrumented.state(srv) 132 | end 133 | 134 | test "works with transition/idempotent" do 135 | test_pid = self() 136 | 137 | {:ok, srv} = Instrumented.start_link(fn value -> 138 | send(test_pid, {:foo, value}) 139 | {:noreply, transition: :tr} 140 | end) 141 | 142 | assert {:start, f} = Instrumented.state(srv) 143 | assert "foo" = Instrumented.named_timeout_payload(srv, :payload) 144 | assert_receive {:foo, {:bar, :payload}} 145 | assert {:end, ^f} = Instrumented.state(srv) 146 | end 147 | 148 | test "works with delayed transition/idempotent" do 149 | test_pid = self() 150 | 151 | {:ok, srv} = Instrumented.start_link(fn value -> 152 | send(test_pid, {:foo, value}) 153 | {:noreply, transition: :tr} 154 | end) 155 | 156 | assert {:start, f} = Instrumented.state(srv) 157 | assert "foo" = Instrumented.named_timeout_payload(srv, 10, :payload) 158 | 159 | # calls don't interrupt the named timeout 160 | Process.sleep(5) 161 | assert {:start, ^f} = Instrumented.state(srv) 162 | Process.sleep(10) 163 | assert {:end, ^f} = Instrumented.state(srv) 164 | 165 | # let's be sure that we have gotten the expected response 166 | assert_receive {:foo, {:bar, :payload}} 167 | end 168 | end 169 | 170 | defmodule UnInstrumented do 171 | use StateServer, [start: [tr: :end], end: []] 172 | def start_link(_), do: StateServer.start_link(__MODULE__, :ok) 173 | 174 | @impl true 175 | def init(_), do: {:ok, :ok} 176 | 177 | @impl true 178 | def handle_call(:go, _, _, _) do 179 | {:reply, :ok, timeout: 50} 180 | end 181 | end 182 | 183 | describe "tests against uninstrumented code" do 184 | test "should throw a runtime error" do 185 | Process.flag(:trap_exit, true) 186 | {:ok, srv} = UnInstrumented.start_link(:ok) 187 | StateServer.call(srv, :go) 188 | assert_receive {:EXIT, ^srv, {%RuntimeError{message: msg}, _}} 189 | assert msg =~ "handle_timeout/3" 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /test/callbacks/handle_timeout_erlang_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleTimeoutErlangTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | def timeout(srv, sig), do: StateServer.call(srv, {:timeout, sig}) 15 | 16 | @impl true 17 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 18 | def handle_call({:timeout, signature}, from, _state, _data) do 19 | {:keep_state_and_data, [{:reply, from, :ok}, signature]} 20 | end 21 | 22 | @impl true 23 | def handle_timeout(value, _state, fun), do: fun.(value) 24 | end 25 | 26 | describe "instrumenting handle_timeout and triggering with erlang event_timeout" do 27 | test "works, with naked timeout, submitting nil" do 28 | test_pid = self() 29 | 30 | {:ok, srv} = Instrumented.start_link(fn value -> 31 | send(test_pid, {:foo, value}) 32 | {:noreply, update: "bar"} 33 | end) 34 | 35 | Instrumented.timeout(srv, 10) 36 | 37 | assert_receive {:foo, nil} 38 | end 39 | 40 | test "works with event timeout, and payload" do 41 | test_pid = self() 42 | 43 | {:ok, srv} = Instrumented.start_link(fn value -> 44 | send(test_pid, {:foo, value}) 45 | {:noreply, update: "bar"} 46 | end) 47 | 48 | Instrumented.timeout(srv, {:timeout, 10, "bar"}) 49 | 50 | assert_receive {:foo, "bar"} 51 | end 52 | end 53 | 54 | describe "instrumenting handle_timeout and triggering with erlang named_timeout" do 55 | test "works with named timeout, with payload" do 56 | test_pid = self() 57 | 58 | {:ok, srv} = Instrumented.start_link(fn value -> 59 | send(test_pid, {:foo, value}) 60 | {:noreply, update: "bar"} 61 | end) 62 | 63 | Instrumented.timeout(srv, {{:timeout, :foo}, 10, "bar"}) 64 | 65 | assert_receive {:foo, {:foo, "bar"}} 66 | end 67 | end 68 | 69 | describe "instrumenting handle_timeout and triggering with erlang state_timeout" do 70 | test "works with state timeout, with payload" do 71 | test_pid = self() 72 | 73 | {:ok, srv} = Instrumented.start_link(fn value -> 74 | send(test_pid, {:foo, value}) 75 | {:noreply, update: "bar"} 76 | end) 77 | 78 | Instrumented.timeout(srv, {:state_timeout, 10, "bar"}) 79 | 80 | assert_receive {:foo, "bar"} 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/callbacks/handle_timeout_event_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleTimeoutEventTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | def event_timeout(srv, time \\ 0) do 15 | StateServer.call(srv, {:event_timeout, time}) 16 | end 17 | def event_timeout_payload(srv, time \\ 0, payload) do 18 | StateServer.call(srv, {:event_timeout, time, payload}) 19 | end 20 | 21 | @impl true 22 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 23 | def handle_call({:event_timeout, time}, _from, _state, _data) do 24 | {:reply, "foo", [event_timeout: time]} 25 | end 26 | def handle_call({:event_timeout, time, payload}, _from, _state, _data) do 27 | {:reply, "foo", [event_timeout: {payload, time}]} 28 | end 29 | 30 | @impl true 31 | def handle_timeout(value, _state, fun), do: fun.(value) 32 | end 33 | 34 | describe "instrumenting handle_timeout and triggering with event_timeout" do 35 | test "works with static/idempotent" do 36 | test_pid = self() 37 | 38 | {:ok, srv} = Instrumented.start_link(fn value -> 39 | send(test_pid, {:foo, value}) 40 | :noreply 41 | end) 42 | 43 | assert {:start, f} = Instrumented.state(srv) 44 | assert "foo" = Instrumented.event_timeout(srv) 45 | assert_receive {:foo, nil} 46 | assert {:start, ^f} = Instrumented.state(srv) 47 | end 48 | 49 | test "works with static/update" do 50 | test_pid = self() 51 | 52 | {:ok, srv} = Instrumented.start_link(fn value -> 53 | send(test_pid, {:foo, value}) 54 | {:noreply, update: "bar"} 55 | end) 56 | 57 | assert {:start, f} = Instrumented.state(srv) 58 | assert "foo" = Instrumented.event_timeout(srv) 59 | assert_receive {:foo, nil} 60 | assert {:start, "bar"} = Instrumented.state(srv) 61 | end 62 | 63 | test "works with transition/idempotent" do 64 | test_pid = self() 65 | 66 | {:ok, srv} = Instrumented.start_link(fn value -> 67 | send(test_pid, {:foo, value}) 68 | {:noreply, transition: :tr} 69 | end) 70 | 71 | assert {:start, f} = Instrumented.state(srv) 72 | assert "foo" = Instrumented.event_timeout(srv) 73 | assert_receive {:foo, nil} 74 | assert {:end, ^f} = Instrumented.state(srv) 75 | end 76 | 77 | test "works with delayed transition/idempotent, interruptible" do 78 | test_pid = self() 79 | 80 | {:ok, srv} = Instrumented.start_link(fn value -> 81 | send(test_pid, {:foo, value}) 82 | {:noreply, transition: :tr} 83 | end) 84 | 85 | assert {:start, f} = Instrumented.state(srv) 86 | assert "foo" = Instrumented.event_timeout(srv, 10) 87 | 88 | # interrupt the event timeout 89 | Process.sleep(5) 90 | assert {:start, ^f} = Instrumented.state(srv) 91 | Process.sleep(10) 92 | assert {:start, ^f} = Instrumented.state(srv) 93 | 94 | # let's be sure that we don't get the expected response 95 | refute_receive _ 96 | 97 | assert "foo" = Instrumented.event_timeout(srv, 10) 98 | assert_receive {:foo, nil} 99 | end 100 | 101 | test "works with transition/update" do 102 | test_pid = self() 103 | 104 | {:ok, srv} = Instrumented.start_link(fn value -> 105 | send(test_pid, {:foo, value}) 106 | {:noreply, transition: :tr, update: "bar"} 107 | end) 108 | 109 | assert {:start, f} = Instrumented.state(srv) 110 | assert "foo" = Instrumented.event_timeout(srv) 111 | assert_receive {:foo, nil} 112 | assert {:end, "bar"} = Instrumented.state(srv) 113 | end 114 | end 115 | 116 | describe "instrumenting handle_timeout and triggering with event_timeout and payload" do 117 | test "works with static/idempotent" do 118 | test_pid = self() 119 | 120 | {:ok, srv} = Instrumented.start_link(fn value -> 121 | send(test_pid, {:foo, value}) 122 | :noreply 123 | end) 124 | 125 | assert {:start, f} = Instrumented.state(srv) 126 | assert "foo" = Instrumented.event_timeout_payload(srv, :payload) 127 | assert_receive {:foo, :payload} 128 | assert {:start, ^f} = Instrumented.state(srv) 129 | end 130 | 131 | test "works with static/update" do 132 | test_pid = self() 133 | 134 | {:ok, srv} = Instrumented.start_link(fn value -> 135 | send(test_pid, {:foo, value}) 136 | {:noreply, update: "bar"} 137 | end) 138 | 139 | assert {:start, f} = Instrumented.state(srv) 140 | assert "foo" = Instrumented.event_timeout_payload(srv, :payload) 141 | assert_receive {:foo, :payload} 142 | assert {:start, "bar"} = Instrumented.state(srv) 143 | end 144 | 145 | test "works with transition/idempotent" do 146 | test_pid = self() 147 | 148 | {:ok, srv} = Instrumented.start_link(fn value -> 149 | send(test_pid, {:foo, value}) 150 | {:noreply, transition: :tr} 151 | end) 152 | 153 | assert {:start, f} = Instrumented.state(srv) 154 | assert "foo" = Instrumented.event_timeout_payload(srv, :payload) 155 | assert_receive {:foo, :payload} 156 | assert {:end, ^f} = Instrumented.state(srv) 157 | end 158 | 159 | test "works with delayed transition/idempotent" do 160 | test_pid = self() 161 | 162 | {:ok, srv} = Instrumented.start_link(fn value -> 163 | send(test_pid, {:foo, value}) 164 | {:noreply, transition: :tr} 165 | end) 166 | 167 | assert {:start, f} = Instrumented.state(srv) 168 | assert "foo" = Instrumented.event_timeout_payload(srv, 10, :payload) 169 | 170 | # interrupt the event timeout 171 | Process.sleep(5) 172 | assert {:start, ^f} = Instrumented.state(srv) 173 | Process.sleep(10) 174 | assert {:start, ^f} = Instrumented.state(srv) 175 | 176 | # let's be sure that we don't get the expected response 177 | refute_receive _ 178 | 179 | assert "foo" = Instrumented.event_timeout_payload(srv, 10, :payload) 180 | assert_receive {:foo, :payload} 181 | end 182 | 183 | test "works with transition/update" do 184 | test_pid = self() 185 | 186 | {:ok, srv} = Instrumented.start_link(fn value -> 187 | send(test_pid, {:foo, value}) 188 | {:noreply, transition: :tr, update: "bar"} 189 | end) 190 | 191 | assert {:start, f} = Instrumented.state(srv) 192 | assert "foo" = Instrumented.event_timeout_payload(srv, :payload) 193 | assert_receive {:foo, :payload} 194 | assert {:end, "bar"} = Instrumented.state(srv) 195 | end 196 | end 197 | 198 | end 199 | -------------------------------------------------------------------------------- /test/callbacks/handle_timeout_state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleTimeoutStateTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 9 | 10 | @impl true 11 | def init(fun), do: {:ok, fun} 12 | 13 | def state(srv), do: StateServer.call(srv, :state) 14 | def state_timeout(srv, time \\ 0), do: StateServer.call(srv, {:state_timeout, time}) 15 | def state_timeout_payload(srv, time \\ 0, payload) do 16 | StateServer.call(srv, {:state_timeout, payload, time}) 17 | end 18 | 19 | @impl true 20 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 21 | def handle_call({:state_timeout, time}, _from, _state, _data) do 22 | {:reply, "foo", state_timeout: time} 23 | end 24 | def handle_call({:state_timeout, payload, time}, _from, _state, _data) do 25 | {:reply, "foo", state_timeout: {payload, time}} 26 | end 27 | def handle_call({:transition, tr}, _from, _state, _data) do 28 | {:reply, :ok, transition: tr} 29 | end 30 | 31 | @impl true 32 | def handle_timeout(value, _state, fun), do: fun.(value) 33 | end 34 | 35 | describe "instrumenting handle_timeout and triggering with state_timeout" do 36 | test "works with static/idempotent" do 37 | test_pid = self() 38 | 39 | {:ok, srv} = Instrumented.start_link(fn value -> 40 | send(test_pid, {:foo, value}) 41 | :noreply 42 | end) 43 | 44 | assert {:start, f} = Instrumented.state(srv) 45 | assert "foo" = Instrumented.state_timeout(srv) 46 | assert_receive {:foo, nil} 47 | assert {:start, ^f} = Instrumented.state(srv) 48 | end 49 | 50 | test "works with static/update" do 51 | test_pid = self() 52 | 53 | {:ok, srv} = Instrumented.start_link(fn value -> 54 | send(test_pid, {:foo, value}) 55 | {:noreply, update: "bar"} 56 | end) 57 | 58 | assert {:start, f} = Instrumented.state(srv) 59 | assert "foo" = Instrumented.state_timeout(srv) 60 | assert_receive {:foo, nil} 61 | assert {:start, "bar"} = Instrumented.state(srv) 62 | end 63 | 64 | test "works with transition/idempotent" do 65 | test_pid = self() 66 | 67 | {:ok, srv} = Instrumented.start_link(fn value -> 68 | send(test_pid, {:foo, value}) 69 | {:noreply, transition: :tr} 70 | end) 71 | 72 | assert {:start, f} = Instrumented.state(srv) 73 | assert "foo" = Instrumented.state_timeout(srv) 74 | assert_receive {:foo, nil} 75 | assert {:end, ^f} = Instrumented.state(srv) 76 | end 77 | 78 | test "works with transition/update" do 79 | test_pid = self() 80 | 81 | {:ok, srv} = Instrumented.start_link(fn value -> 82 | send(test_pid, {:foo, value}) 83 | {:noreply, transition: :tr, update: "bar"} 84 | end) 85 | 86 | assert {:start, f} = Instrumented.state(srv) 87 | assert "foo" = Instrumented.state_timeout(srv) 88 | assert_receive {:foo, nil} 89 | assert {:end, "bar"} = Instrumented.state(srv) 90 | end 91 | 92 | test "works with delayed transition/idempotent" do 93 | test_pid = self() 94 | 95 | {:ok, srv} = Instrumented.start_link(fn value -> 96 | send(test_pid, {:foo, value}) 97 | {:noreply, update: "bar"} 98 | end) 99 | 100 | assert {:start, f} = Instrumented.state(srv) 101 | assert "foo" = Instrumented.state_timeout(srv, 10) 102 | 103 | # calls don't interrupt the named timeout 104 | Process.sleep(5) 105 | assert {:start, ^f} = Instrumented.state(srv) 106 | Process.sleep(10) 107 | assert {:start, "bar"} = Instrumented.state(srv) 108 | 109 | # let's be sure that we have gotten the expected response 110 | assert_receive {:foo, nil} 111 | end 112 | 113 | test "is interruptible with state change" do 114 | test_pid = self() 115 | 116 | {:ok, srv} = Instrumented.start_link(fn value -> 117 | send(test_pid, {:foo, value}) 118 | {:noreply, update: "bar"} 119 | end) 120 | 121 | assert {:start, f} = Instrumented.state(srv) 122 | assert "foo" = Instrumented.state_timeout(srv, 10) 123 | 124 | Process.sleep(5) 125 | # state changes can interrupt the timeout 126 | StateServer.call(srv, {:transition, :tr}) 127 | 128 | Process.sleep(10) 129 | assert {:end, ^f} = Instrumented.state(srv) 130 | 131 | # let's be sure that we have never gotten the expected response 132 | refute_receive {:foo, _} 133 | end 134 | end 135 | 136 | describe "instrumenting handle_timeout and triggering with state_timeout and payload" do 137 | test "works with static/update" do 138 | test_pid = self() 139 | 140 | {:ok, srv} = Instrumented.start_link(fn value -> 141 | send(test_pid, {:foo, value}) 142 | {:noreply, update: "bar"} 143 | end) 144 | 145 | assert {:start, f} = Instrumented.state(srv) 146 | assert "foo" = Instrumented.state_timeout_payload(srv, :payload) 147 | assert_receive {:foo, :payload} 148 | assert {:start, "bar"} = Instrumented.state(srv) 149 | end 150 | 151 | test "works with transition/idempotent" do 152 | test_pid = self() 153 | 154 | {:ok, srv} = Instrumented.start_link(fn value -> 155 | send(test_pid, {:foo, value}) 156 | {:noreply, transition: :tr} 157 | end) 158 | 159 | assert {:start, f} = Instrumented.state(srv) 160 | assert "foo" = Instrumented.state_timeout_payload(srv, :payload) 161 | assert_receive {:foo, :payload} 162 | assert {:end, ^f} = Instrumented.state(srv) 163 | end 164 | 165 | test "works with delayed transition/idempotent" do 166 | test_pid = self() 167 | 168 | {:ok, srv} = Instrumented.start_link(fn value -> 169 | send(test_pid, {:foo, value}) 170 | {:noreply, update: "bar"} 171 | end) 172 | 173 | assert {:start, f} = Instrumented.state(srv) 174 | assert "foo" = Instrumented.state_timeout_payload(srv, 10, :payload) 175 | 176 | # calls don't interrupt the named timeout 177 | Process.sleep(5) 178 | assert {:start, ^f} = Instrumented.state(srv) 179 | Process.sleep(10) 180 | assert {:start, "bar"} = Instrumented.state(srv) 181 | 182 | # let's be sure that we have gotten the expected response 183 | assert_receive {:foo, :payload} 184 | end 185 | 186 | test "state changes can interrupt the timeout" do 187 | test_pid = self() 188 | 189 | {:ok, srv} = Instrumented.start_link(fn value -> 190 | send(test_pid, {:foo, value}) 191 | {:noreply, update: "bar"} 192 | end) 193 | 194 | assert {:start, f} = Instrumented.state(srv) 195 | assert "foo" = Instrumented.state_timeout_payload(srv, 10, :payload) 196 | 197 | # state changes can interrupt the timeout. 198 | Process.sleep(5) 199 | StateServer.call(srv, {:transition, :tr}) 200 | 201 | Process.sleep(10) 202 | assert {:end, ^f} = Instrumented.state(srv) 203 | 204 | # let's be sure that we have not gotten the expected response 205 | refute_receive {:foo, _} 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /test/callbacks/handle_transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.HandleTransitionTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | 7 | use StateServer, [start: [tr: :end], end: []] 8 | 9 | def start_link(fun), do: StateServer.start_link(__MODULE__, fun) 10 | 11 | @impl true 12 | def init(fun), do: {:ok, fun} 13 | 14 | def state(srv), do: StateServer.call(srv, :state) 15 | 16 | @impl true 17 | def handle_transition(start_state, transition, fun) do 18 | fun.(start_state, transition) 19 | end 20 | 21 | @impl true 22 | def handle_call(:state, _from, state, data), do: {:reply, {state, data}} 23 | def handle_call({:transition, tr}, _from, _state, _fun) do 24 | {:reply, "foo", transition: tr} 25 | end 26 | def handle_call({:transition_update, tr}, _from, _state, fun) do 27 | {:reply, "foo", transition: tr, update: fun} 28 | end 29 | def handle_call(:goto, _from, _state, _fun) do 30 | {:reply, "foo", goto: :end, update: "bar"} 31 | end 32 | 33 | @impl true 34 | def handle_cast({:transition, tr}, _state, _fun) do 35 | {:noreply, transition: tr} 36 | end 37 | def handle_cast({:transition_update, tr}, _state, fun) do 38 | {:noreply, transition: tr, update: fun} 39 | end 40 | def handle_cast({:delay, who}, _state, _data) do 41 | {:noreply, internal: {:delay, who}, transition: :tr} 42 | end 43 | 44 | @impl true 45 | def handle_internal({:delay, who}, _state, _data) do 46 | send(who, :delegation) 47 | receive do :delegated -> :noreply end 48 | end 49 | end 50 | 51 | test "a transition event triggers the transition" do 52 | test_pid = self() 53 | 54 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 55 | send(test_pid, {:reply, state, tr}) 56 | :noreply 57 | end) 58 | 59 | assert {:start, f} = Instrumented.state(srv) 60 | 61 | StateServer.cast(srv, {:transition, :tr}) 62 | 63 | assert_receive {:reply, :start, :tr} 64 | end 65 | 66 | test "a transition event with an update triggers the transition" do 67 | test_pid = self() 68 | 69 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 70 | send(test_pid, {:reply, state, tr}) 71 | :noreply 72 | end) 73 | 74 | assert {:start, _f} = Instrumented.state(srv) 75 | 76 | StateServer.cast(srv, {:transition_update, :tr}) 77 | 78 | assert_receive {:reply, :start, :tr} 79 | end 80 | 81 | test "a transition event in a call triggers the transition" do 82 | test_pid = self() 83 | 84 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 85 | send(test_pid, {:reply, state, tr}) 86 | :noreply 87 | end) 88 | 89 | assert {:start, _f} = Instrumented.state(srv) 90 | 91 | assert "foo" == StateServer.call(srv, {:transition, :tr}) 92 | 93 | assert_receive {:reply, :start, :tr} 94 | end 95 | 96 | test "a transition event in a call with an update triggers the transition" do 97 | test_pid = self() 98 | 99 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 100 | send(test_pid, {:reply, state, tr}) 101 | :noreply 102 | end) 103 | 104 | assert {:start, _f} = Instrumented.state(srv) 105 | 106 | assert "foo" == StateServer.call(srv, {:transition_update, :tr}) 107 | 108 | assert_receive {:reply, :start, :tr} 109 | end 110 | 111 | test "a delegated transition event triggers the transition" do 112 | test_pid = self() 113 | 114 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 115 | send(test_pid, {:reply, state, tr}) 116 | :noreply 117 | end) 118 | 119 | assert {:start, _f} = Instrumented.state(srv) 120 | 121 | StateServer.cast(srv, {:delay, test_pid}) 122 | receive do :delegation -> send(srv, :delegated) end 123 | 124 | assert_receive {:reply, :start, :tr} 125 | end 126 | 127 | test "a transition event can contain instructions in the payload" do 128 | test_pid = self() 129 | 130 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 131 | send(test_pid, {:reply, state, tr}) 132 | {:noreply, update: "foo"} 133 | end) 134 | 135 | assert {:start, _f} = Instrumented.state(srv) 136 | 137 | StateServer.cast(srv, {:transition, :tr}) 138 | 139 | assert_receive {:reply, :start, :tr} 140 | 141 | assert {:end, "foo"} == Instrumented.state(srv) 142 | end 143 | 144 | test "a transition event can be cancelled" do 145 | test_pid = self() 146 | 147 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 148 | send(test_pid, {:reply, state, tr}) 149 | :cancel 150 | end) 151 | 152 | assert {:start, f} = Instrumented.state(srv) 153 | 154 | StateServer.cast(srv, {:delay, test_pid}) 155 | receive do :delegation -> send(srv, :delegated) end 156 | 157 | assert_receive {:reply, :start, :tr} 158 | 159 | assert {:start, ^f} = Instrumented.state(srv) 160 | end 161 | 162 | test "a transition cancellation can contain instructions in the payload" do 163 | test_pid = self() 164 | 165 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 166 | send(test_pid, {:reply, state, tr}) 167 | {:cancel, update: "foo"} 168 | end) 169 | 170 | assert {:start, _f} = Instrumented.state(srv) 171 | 172 | StateServer.cast(srv, {:transition, :tr}) 173 | 174 | assert_receive {:reply, :start, :tr} 175 | 176 | assert {:start, "foo"} == Instrumented.state(srv) 177 | end 178 | 179 | test "a goto statement doesn't trigger transitioning" do 180 | test_pid = self() 181 | 182 | {:ok, srv} = Instrumented.start_link(fn state, tr -> 183 | send(test_pid, {:reply, state, tr}) 184 | end) 185 | 186 | assert {:start, _f} = Instrumented.state(srv) 187 | 188 | assert "foo" == StateServer.call(srv, :goto) 189 | 190 | assert {:end, "bar"} == Instrumented.state(srv) 191 | 192 | refute_receive {:reply, _, _} 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/callbacks/is_terminal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.IsTerminalTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Module do 6 | use StateServer, [start: [tr1: :end, tr2: :start], end: []] 7 | 8 | def test_terminal(state) when is_terminal(state), do: :terminal 9 | def test_terminal(_state), do: :not_terminal 10 | 11 | def test_terminal(state, transition) when is_terminal(state, transition), do: :terminal 12 | def test_terminal(_state, _transition), do: :not_terminal 13 | 14 | @impl true 15 | def init(_), do: {:ok, :ok} 16 | end 17 | 18 | test "is_terminal/1 can guard successfully for states" do 19 | assert :not_terminal == Module.test_terminal(:start) 20 | assert :terminal == Module.test_terminal(:end) 21 | end 22 | 23 | test "is_terminal/2 can guard successfully for state/transitions" do 24 | assert :not_terminal == Module.test_terminal(:start, :tr2) 25 | assert :terminal == Module.test_terminal(:start, :tr1) 26 | end 27 | 28 | test "is_terminal/1 works externally" do 29 | import Module 30 | 31 | refute is_terminal(:start) 32 | assert is_terminal(:end) 33 | end 34 | 35 | test "is_terminal/2 works externally" do 36 | import Module 37 | 38 | refute is_terminal(:start, :tr2) 39 | assert is_terminal(:start, :tr1) 40 | end 41 | 42 | end 43 | -------------------------------------------------------------------------------- /test/callbacks/on_state_entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.OnStateEntryTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Module do 6 | use StateServer, [start: [tr1: :end, tr2: :end, tr3: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data} 12 | 13 | @impl true 14 | def handle_transition(_, :tr3, data) do 15 | {:noreply, update: Map.put(data, :updated, true)} 16 | end 17 | def handle_transition(_, _, _), do: :noreply 18 | 19 | @impl true 20 | def on_state_entry(nil, :end, %{pid: pid}) do 21 | send(pid, :end_via_goto) 22 | :noreply 23 | end 24 | def on_state_entry(_, :end, %{pid: pid, updated: true}) do 25 | send(pid, :transition_did_update) 26 | :noreply 27 | end 28 | def on_state_entry(trans, :end, %{pid: pid}) do 29 | send(pid, {:end_via_transition, trans}) 30 | :noreply 31 | end 32 | def on_state_entry(_, _, _), do: :noreply 33 | 34 | @impl true 35 | def handle_cast(actions, _state, _data) do 36 | {:noreply, actions} 37 | end 38 | end 39 | 40 | describe "when making state changes" do 41 | test "a goto passes through on_state_entry with no transition declared." do 42 | {:ok, srv} = Module.start_link(%{pid: self()}) 43 | GenServer.cast(srv, goto: :end) 44 | 45 | assert_receive :end_via_goto 46 | end 47 | 48 | test "a transition passes through on_state_entry with its transition declared (1)." do 49 | {:ok, srv} = Module.start_link(%{pid: self()}) 50 | 51 | GenServer.cast(srv, transition: :tr1) 52 | 53 | assert_receive {:end_via_transition, :tr1} 54 | end 55 | 56 | test "a transition passes through on_state_entry with its transition declared (2)." do 57 | {:ok, srv} = Module.start_link(%{pid: self()}) 58 | 59 | GenServer.cast(srv, transition: :tr2) 60 | 61 | assert_receive {:end_via_transition, :tr2} 62 | end 63 | 64 | test "a transition will correctly update for on_state_entry" do 65 | {:ok, srv} = Module.start_link(%{pid: self()}) 66 | 67 | GenServer.cast(srv, transition: :tr3) 68 | 69 | assert_receive :transition_did_update 70 | end 71 | end 72 | 73 | defmodule EntryModule do 74 | use StateServer, start: [], unreachable: [] 75 | 76 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 77 | 78 | @impl true 79 | def init(data = %{goto: where}), do: {:ok, data, goto: where} 80 | def init(data), do: {:ok, data} 81 | 82 | @impl true 83 | def on_state_entry(tr, who, %{pid: pid}) do 84 | send(pid, {:entry, tr, who}) 85 | :noreply 86 | end 87 | 88 | @impl true 89 | def handle_cast(actions, _state, _data) do 90 | {:noreply, actions} 91 | end 92 | end 93 | 94 | describe "on state_server initialization" do 95 | test "on_state_entry is with the starting state and nil as the transition" do 96 | EntryModule.start_link(%{pid: self()}) 97 | 98 | assert_receive {:entry, nil, :start} 99 | end 100 | 101 | test "on_state_entry is called with the goto state" do 102 | EntryModule.start_link(%{pid: self(), goto: :unreachable}) 103 | assert_receive {:entry, nil, :unreachable} 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/callbacks/terminate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Callbacks.TerminateTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start(resp_pid), do: StateServer.start(__MODULE__, resp_pid) 9 | 10 | @impl true 11 | def init(resp_pid) do 12 | {:ok, resp_pid} 13 | end 14 | 15 | @impl true 16 | def handle_call(:tr, _from, _state, _data) do 17 | {:reply, nil, transition: :tr} 18 | end 19 | def handle_call(:stop, from, _state, resp_pid) do 20 | reply(from, nil) 21 | {:stop, :normal, resp_pid} 22 | end 23 | 24 | @impl true 25 | def terminate(_reason, state, resp_pid) do 26 | send(resp_pid, {:terminating_from, state}) 27 | :this_is_ignored 28 | end 29 | end 30 | 31 | describe "instrumenting terminate" do 32 | test "works from the initial state" do 33 | {:ok, srv} = Instrumented.start(self()) 34 | StateServer.call(srv, :stop) 35 | assert_receive {:terminating_from, :start} 36 | refute Process.alive?(srv) 37 | end 38 | 39 | test "works from another state state" do 40 | {:ok, srv} = Instrumented.start(self()) 41 | StateServer.call(srv, :tr) 42 | StateServer.call(srv, :stop) 43 | assert_receive {:terminating_from, :end} 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/compile_time_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.CompileTimeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StateServer.StateGraph 5 | 6 | test "not defining your state_graph causes a compile time error" do 7 | assert_raise ArgumentError, fn -> Code.require_file("test/assets/use_without_graph.exs") end 8 | end 9 | 10 | test "assigning state_graph causes a compile time error" do 11 | assert_raise CompileError, fn -> Code.require_file("test/assets/malformed_graph.exs") end 12 | end 13 | 14 | defmodule GraphFunction do 15 | use StateServer, [foo: [bar: :foo]] 16 | 17 | @impl true 18 | def init(_), do: {:ok, :ok} 19 | end 20 | 21 | test "__state_graph__/0 is correctly assigned at compile time" do 22 | assert [foo: [bar: :foo]] == GraphFunction.__state_graph__() 23 | end 24 | 25 | test "state typelists are generated correctly" do 26 | singleton_state = StateGraph.atoms_to_typelist([:foo]) 27 | q1 = quote do @type state :: :foo end 28 | q2 = quote do @type state :: unquote(singleton_state) end 29 | 30 | assert q1 == q2 31 | 32 | two_states = StateGraph.atoms_to_typelist([:foo, :bar]) 33 | q3 = quote do @type state :: :foo | :bar end 34 | q4 = quote do @type state :: unquote(two_states) end 35 | 36 | assert q3 == q4 37 | 38 | three_states = StateGraph.atoms_to_typelist([:foo, :bar, :baz]) 39 | q5 = quote do @type state :: :foo | :bar | :baz end 40 | q6 = quote do @type state :: unquote(three_states) end 41 | 42 | assert q5 == q6 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/etc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.EtcTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | @moduletag :calltests 6 | 7 | describe "when making calls" do 8 | # positive controls: GenServer.call and :gen_statem.call 9 | test "GenServer.call has a normal pid ref" do 10 | 11 | inner_pid = spawn(fn -> 12 | from = {from_pid, _ref} = receive do {:"$gen_call", from, :call} -> from end 13 | GenServer.reply(from, from_pid) 14 | end) 15 | 16 | from_pid = GenServer.call(inner_pid, :call) 17 | 18 | assert self() == from_pid 19 | end 20 | 21 | test "GenServer.call with timeout has a normal pid ref" do 22 | 23 | inner_pid = spawn(fn -> 24 | from = {from_pid, _ref} = receive do {:"$gen_call", from, :call} -> from end 25 | GenServer.reply(from, from_pid) 26 | end) 27 | 28 | from_pid = GenServer.call(inner_pid, :call, 5000) 29 | 30 | assert self() == from_pid 31 | end 32 | 33 | test ":gen_statem.call with timeout has an abnormal pid ref" do 34 | 35 | inner_pid = spawn(fn -> 36 | from = {from_pid, _ref} = receive do {:"$gen_call", from, :call} -> from end 37 | :gen_statem.reply(from, from_pid) 38 | end) 39 | 40 | from_pid = :gen_statem.call(inner_pid, :call, 5000) 41 | 42 | refute self() == from_pid 43 | end 44 | 45 | test ":gen_statem.call has a normal pid ref" do 46 | 47 | inner_pid = spawn(fn -> 48 | from = {from_pid, _ref} = receive do {:"$gen_call", from, :call} -> from end 49 | :gen_statem.reply(from, from_pid) 50 | end) 51 | 52 | from_pid = :gen_statem.call(inner_pid, :call) 53 | 54 | assert self() == from_pid 55 | end 56 | 57 | test "StateServer.call without a timeout has a normal pid ref" do 58 | 59 | inner_pid = spawn(fn -> 60 | from = {from_pid, _ref} = receive do {:"$gen_call", from, :call} -> from end 61 | StateServer.reply(from, from_pid) 62 | end) 63 | 64 | from_pid = StateServer.call(inner_pid, :call) 65 | 66 | assert self() == from_pid 67 | end 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /test/examples/switch_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("example/switch.exs") 2 | 3 | defmodule StateServerTest.SwitchTest do 4 | 5 | use ExUnit.Case, async: true 6 | 7 | test "switch initialization works" do 8 | {:ok, srv} = Switch.start_link 9 | 10 | assert :off == Switch.state(srv) 11 | assert 0 == Switch.count(srv) 12 | end 13 | 14 | test "switch state can be flipped" do 15 | {:ok, srv} = Switch.start_link 16 | 17 | Switch.flip(srv) 18 | assert :on == Switch.state(srv) 19 | assert 1 == Switch.count(srv) 20 | end 21 | 22 | test "flipping switch states triggers flipback" do 23 | {:ok, srv} = Switch.start_link 24 | 25 | Switch.flip(srv) 26 | 27 | Process.sleep(100) 28 | assert :on == Switch.state(srv) 29 | 30 | Process.sleep(250) 31 | assert :off == Switch.state(srv) 32 | end 33 | 34 | test "setting switch does not trigger flipback" do 35 | {:ok, srv} = Switch.start_link 36 | 37 | Switch.set(srv, :on) 38 | assert :on == Switch.state(srv) 39 | 40 | Process.sleep(350) 41 | assert :on == Switch.state(srv) 42 | assert 1 == Switch.count(srv) 43 | end 44 | 45 | test "setting switch to self does not increment count" do 46 | {:ok, srv} = Switch.start_link 47 | 48 | Switch.set(srv, :off) 49 | 50 | assert :off == Switch.state(srv) 51 | assert 0 == Switch.count(srv) 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/examples/switch_with_states_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("example/switch_with_states.exs") 2 | 3 | defmodule StateServerTest.SwitchWithStatesTest do 4 | 5 | use ExUnit.Case 6 | import ExUnit.CaptureIO 7 | 8 | test "SwitchWithStates announces flips" do 9 | {:ok, srv} = SwitchWithStates.start_link 10 | 11 | assert "state is off" == SwitchWithStates.query(srv) 12 | 13 | assert capture_io(:stderr, fn -> 14 | SwitchWithStates.flip(srv) 15 | Process.sleep(100) 16 | end) =~ "flipped on" 17 | 18 | assert "state is on" == SwitchWithStates.query(srv) 19 | 20 | assert capture_io(:stderr, fn -> 21 | SwitchWithStates.flip(srv) 22 | # IO is async, so we must wait 23 | Process.sleep(100) 24 | end) =~ "flipped off" 25 | 26 | assert "state is off" == SwitchWithStates.query(srv) 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /test/macros/generate_defer_test.exs: -------------------------------------------------------------------------------- 1 | #defmodule StateServerTest.Macros.GenerateDeferTest do 2 | # use ExUnit.Case, async: true 3 | # 4 | # alias StateServer.Macros 5 | # 6 | # test "generate_defer for handle_call snapshot" do 7 | # assert (~S""" 8 | # foo |> case do 9 | # :defer -> 10 | # if function_exported?(data.module, :__handle_call_shim__, 4) do 11 | # data.module.__handle_call_shim__(payload, from, state, data.data) 12 | # else 13 | # proc = case Process.info(self(), :registered_name) do 14 | # {_, []} -> 15 | # self() 16 | # {_, name} -> 17 | # name 18 | # end 19 | # case :erlang.phash2(1, 1) do 20 | # 0 -> 21 | # raise("attempted to call handle_call/4 for StateServer " <> inspect(proc) <> " but no handle_call/4 clause was provided") 22 | # 1 -> 23 | # {:stop, {:EXIT, "call error"}} 24 | # end 25 | # end 26 | # {:defer, events} -> 27 | # if function_exported?(data.module, :__handle_call_shim__, 4) do 28 | # data.module.__handle_call_shim__(payload, from, state, data.data) 29 | # |> StateServer.Macros.prepend_events(events) 30 | # else 31 | # proc = case Process.info(self(), :registered_name) do 32 | # {_, []} -> 33 | # self() 34 | # {_, name} -> 35 | # name 36 | # end 37 | # case :erlang.phash2(1, 1) do 38 | # 0 -> 39 | # raise("attempted to call handle_call/4 for StateServer " <> 40 | # inspect(proc) <> " but no handle_call/4 clause was provided") 41 | # 1 -> 42 | # {:stop, {:EXIT, "call error"}} 43 | # end 44 | # end 45 | # any -> 46 | # any 47 | # end 48 | # """ 49 | # |> Code.string_to_quoted! 50 | # |> Macro.to_string) == ( 51 | # Macro.to_string(Macros.generate_handle_call_defer_translation( 52 | # {:foo, [line: 1], nil}, 53 | # {:payload, [line: 1], nil}, 54 | # {:from, [line: 1], nil}, 55 | # {:state, [line: 1], nil}, 56 | # {:data, [line: 1], nil}))) 57 | # end 58 | # 59 | # test "generate_defer for arbitrary handler snapshot" do 60 | # assert (~S""" 61 | # foo |> case do 62 | # :defer -> 63 | # if function_exported?(data.module, :__handle_foo_shim__, 3) do 64 | # data.module.__handle_foo_shim__(payload, state, data.data) 65 | # else 66 | # proc = case Process.info(self(), :registered_name) do 67 | # {_, []} -> 68 | # self() 69 | # {_, name} -> 70 | # name 71 | # end 72 | # case :erlang.phash2(1, 1) do 73 | # 0 -> 74 | # raise("attempted to call handle_foo/3 for StateServer " <> 75 | # inspect(proc) <> " but no handle_foo/3 clause was provided") 76 | # 1 -> 77 | # {:stop, {:EXIT, "call error"}} 78 | # end 79 | # end 80 | # {:defer, events} -> 81 | # if function_exported?(data.module, :__handle_foo_shim__, 3) do 82 | # data.module.__handle_foo_shim__(payload, state, data.data) 83 | # |> StateServer.Macros.prepend_events(events) 84 | # else 85 | # proc = case Process.info(self(), :registered_name) do 86 | # {_, []} -> 87 | # self() 88 | # {_, name} -> 89 | # name 90 | # end 91 | # case :erlang.phash2(1, 1) do 92 | # 0 -> 93 | # raise("attempted to call handle_foo/3 for StateServer " <> 94 | # inspect(proc) <> " but no handle_foo/3 clause was provided") 95 | # 1 -> 96 | # {:stop, {:EXIT, "call error"}} 97 | # end 98 | # end 99 | # any -> 100 | # any 101 | # end 102 | # """ 103 | # |> Code.string_to_quoted! 104 | # |> Macro.to_string) == ( 105 | # Macro.to_string(Macros.generate_defer_translation( 106 | # {:foo, [line: 1], nil}, 107 | # :handle_foo, 108 | # {:payload, [line: 1], nil}, 109 | # {:state, [line: 1], nil}, 110 | # {:data, [line: 1], nil}))) 111 | # end 112 | #end 113 | # 114 | -------------------------------------------------------------------------------- /test/macros/generate_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Macros.GenerateHandlerTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StateServer.Macros 5 | 6 | test "generate_handler snapshot" do 7 | assert (~S""" 8 | @doc false 9 | def handle_foo(_, _) do 10 | proc = case Process.info(self(), :registered_name) do 11 | {_, []} -> self() 12 | {_, name} -> name 13 | end 14 | 15 | case :erlang.phash2(1, 1) do 16 | 0 -> 17 | raise "attempted to call handle_foo/2 for StateServer " <> inspect(proc) <> " but no handle_foo/2 clause was provided" 18 | 1 -> 19 | {:stop, {:EXIT, "call error"}} 20 | end 21 | end 22 | """ 23 | |> Code.string_to_quoted! 24 | |> Macro.to_string) == ( 25 | Macro.to_string(Macros.generate_handler(:handle_foo, 2))) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/multiverse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.MultiverseTest do 2 | 3 | # tests to make sure we can imbue StateServer with 4 | # the ability to forward its caller. 5 | 6 | use Multiverses, with: DynamicSupervisor 7 | 8 | defmodule TestServer do 9 | use StateServer, on: [] 10 | 11 | def start_link(opts) do 12 | StateServer.start_link(__MODULE__, nil, opts) 13 | end 14 | 15 | @impl true 16 | def init(state), do: {:ok, state} 17 | 18 | @impl true 19 | def handle_call(:callers, _, _state, _data) do 20 | {:reply, Process.get(:"$callers")} 21 | end 22 | end 23 | 24 | use ExUnit.Case, async: true 25 | 26 | test "basic state_server caller functionality" do 27 | {:ok, srv} = TestServer.start_link(forward_callers: true) 28 | assert [self()] == GenServer.call(srv, :callers) 29 | end 30 | 31 | test "dynamically supervised StateServer gets correct caller" do 32 | {:ok, sup} = DynamicSupervisor.start_link(strategy: :one_for_one) 33 | 34 | {:ok, child} = DynamicSupervisor.start_child(sup, {TestServer, [forward_callers: true]}) 35 | 36 | Process.sleep(20) 37 | assert self() in GenServer.call(child, :callers) 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /test/otp/otp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.OtpTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | # tests to see that OTP functionality is honored by our StateServer. 6 | 7 | defmodule TestServer do 8 | use StateServer, foo: [] 9 | 10 | def start_link(opts), do: StateServer.start_link(__MODULE__, :ok, opts) 11 | 12 | @impl true 13 | def init(data), do: {:ok, data} 14 | 15 | def get_state(srv), do: GenServer.call(srv, :get_state) 16 | 17 | @impl true 18 | def handle_call(:get_state, _from, state, _data), do: {:reply, state} 19 | end 20 | 21 | defmodule TestServerOverridden do 22 | # override the child_spce to specify that we can kill something normally 23 | use StateServer, foo: [] 24 | 25 | def start_link(opts), do: StateServer.start_link(__MODULE__, :ok, opts) 26 | 27 | def child_spec(arg) do 28 | child_spec(arg, [restart: :transient]) 29 | end 30 | 31 | @impl true 32 | def init(data), do: {:ok, data} 33 | 34 | def get_state(srv), do: GenServer.call(srv, :get_state) 35 | def stop(srv), do: GenServer.call(srv, :stop) 36 | 37 | @impl true 38 | def handle_call(:get_state, _from, state, _data), do: {:reply, state} 39 | def handle_call(:stop, from, _state, _data) do 40 | reply(from, :ok) 41 | {:stop, :normal, :ok} 42 | end 43 | 44 | end 45 | 46 | describe "you can make a supervised stateserver" do 47 | test "without implementing child_spec" do 48 | 49 | Supervisor.start_link([{TestServer, name: TestServer}], strategy: :one_for_one) 50 | 51 | Process.sleep(20) 52 | 53 | assert :foo == TestServer.get_state(TestServer) 54 | 55 | TestServer |> Process.whereis |> Process.exit(:normal) 56 | 57 | Process.sleep(20) 58 | 59 | assert :foo == TestServer.get_state(TestServer) 60 | end 61 | 62 | @tag :one 63 | test "with overriding child_spec" do 64 | Supervisor.start_link([{TestServerOverridden, name: TestServer}], strategy: :one_for_one) 65 | 66 | Process.sleep(10) 67 | 68 | assert :foo == TestServerOverridden.get_state(TestServer) 69 | 70 | TestServer |> Process.whereis |> Process.exit(:kill) 71 | 72 | Process.sleep(20) 73 | 74 | assert :foo == TestServerOverridden.get_state(TestServer) 75 | 76 | TestServerOverridden.stop(TestServer) 77 | 78 | Process.sleep(20) 79 | 80 | refute Process.whereis(TestServer) 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /test/regression/timeout_on_state_entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.Regression.TimeoutOnStateEntryTest do 2 | # regression identified 12 Feb 2020, issue with timeouts and 3 | # on state entry clauses. 4 | 5 | use ExUnit.Case, async: true 6 | 7 | defmodule TestServer do 8 | use StateServer, [start: [], 9 | end: [tr: :end]] 10 | 11 | def start_link(resp_pid), do: StateServer.start_link(__MODULE__, resp_pid) 12 | 13 | @impl true 14 | def init(resp_pid), do: {:ok, resp_pid, goto: :end} 15 | 16 | @impl true 17 | def on_state_entry(transition, :end, resp_pid) do 18 | send(resp_pid, transition || :foo) 19 | {:noreply, state_timeout: {:timeout, 50}} 20 | end 21 | 22 | @impl true 23 | def handle_timeout(:timeout, :end, resp_pid) do 24 | send(resp_pid, :timed_out) 25 | {:noreply, transition: :tr} 26 | end 27 | end 28 | 29 | test "the test server will send two foos separated by 150ms" do 30 | {:ok, _srv} = TestServer.start_link(self()) 31 | 32 | # get the first foo back 33 | assert_receive :foo 34 | assert_receive :timed_out 35 | assert_receive :tr 36 | end 37 | 38 | end 39 | 40 | -------------------------------------------------------------------------------- /test/state_graph_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateGraphTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StateServer.StateGraph 5 | 6 | doctest StateGraph 7 | 8 | describe "when sent to valid?/1" do 9 | test "basic normal state graphs are alright" do 10 | assert StateGraph.valid?([foo: [bar: :foo]]) 11 | assert StateGraph.valid?([foo: [bar: :baz], baz: []]) 12 | assert StateGraph.valid?([foo: [bar: :baz, quux: :nix], baz: [quux: :nix], nix: []]) 13 | end 14 | 15 | test "non-keywords are invalid" do 16 | refute StateGraph.valid?("hello") 17 | end 18 | 19 | test "condition 0: ill-formed keys are invalid" do 20 | refute StateGraph.valid?([{"hello", [bar: :baz]}]) 21 | refute StateGraph.valid?([{"hello", [bar: :baz], "coo"}]) 22 | end 23 | 24 | test "condition 1: an empty state graph is invalid" do 25 | refute StateGraph.valid?([]) 26 | end 27 | 28 | test "condition 2: duplicate states make a state graph invalid" do 29 | refute StateGraph.valid?([foo: [], foo: []]) 30 | end 31 | 32 | test "condition 3: duplicate transitions within a state make a state graph invalid" do 33 | refute StateGraph.valid?([foo: [bar: :foo, bar: :foo]]) 34 | end 35 | 36 | test "condition 4: nonexisting destination states from a transition make a state graph invalid" do 37 | refute StateGraph.valid?([foo: [bar: :baz]]) 38 | end 39 | end 40 | 41 | describe "stategraph is able to find properties" do 42 | test "start/1" do 43 | assert :foo == StateGraph.start([foo: []]) 44 | assert :foo == StateGraph.start([foo: [bar: :baz], baz: []]) 45 | end 46 | 47 | test "states/1" do 48 | assert [:foo] == StateGraph.states([foo: []]) 49 | assert [:foo] == StateGraph.states([foo: [bar: :foo]]) 50 | assert [:foo, :baz] == StateGraph.states([foo: [bar: :baz], baz: []]) 51 | end 52 | 53 | test "transitions/1" do 54 | assert [] == StateGraph.transitions([foo: []]) 55 | assert [:bar] == StateGraph.transitions([foo: [bar: :foo]]) 56 | assert [:bar, :quux] == StateGraph.transitions([foo: [bar: :baz], baz: [quux: :foo]]) 57 | 58 | # duplicated transitions are not duplicated in the result. 59 | assert [:bar, :quux] == StateGraph.transitions([foo: [bar: :baz], baz: [bar: :baz, quux: :foo]]) 60 | end 61 | 62 | test "transitions/2" do 63 | assert [] == StateGraph.transitions([foo: []], :foo) 64 | assert [:bar] == StateGraph.transitions([foo: [bar: :foo]], :foo) 65 | assert [:bar, :quux] == StateGraph.transitions( 66 | [foo: [bar: :baz, quux: :foo], baz: [quux: :foo]], :foo) 67 | end 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /test/state_module/basic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExternalStart do 2 | 3 | @behaviour StateServer.State 4 | 5 | def handle_call(:get_data, _from, data) do 6 | {:reply, data} 7 | end 8 | end 9 | 10 | defmodule StateServerTest.StateModule.BasicTest do 11 | 12 | use ExUnit.Case, async: true 13 | 14 | defmodule Basic do 15 | use StateServer, [start: []] 16 | 17 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 18 | 19 | @impl true 20 | def init(data), do: {:ok, data} 21 | 22 | def get_data(srv), do: GenServer.call(srv, :get_data) 23 | 24 | delegate :handle_info 25 | 26 | defstate Start, for: :start do 27 | # for ignore/1 testing 28 | ignore :handle_info 29 | end 30 | end 31 | 32 | describe "when you use defstate/3" do 33 | test "it creates a submodule" do 34 | assert function_exported?(Basic.Start, :__info__, 1) 35 | end 36 | 37 | test "trapped, ignored functions don't crash the server" do 38 | {:ok, pid} = Basic.start_link(:foo) 39 | send(pid, :bar) # shouldn't crash. 40 | Process.sleep(100) 41 | assert Process.alive?(pid) 42 | end 43 | end 44 | 45 | defmodule WithoutBlock do 46 | use StateServer, [start: []] 47 | 48 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 49 | 50 | @impl true 51 | def init(data), do: {:ok, data} 52 | 53 | def get_data(srv), do: GenServer.call(srv, :get_data) 54 | 55 | defstate ExternalStart, for: :start 56 | end 57 | 58 | describe "when you use defstate without a block" do 59 | test "you can still get it to work" do 60 | {:ok, pid} = WithoutBlock.start_link("foo") 61 | assert "foo" == WithoutBlock.get_data(pid) 62 | end 63 | end 64 | 65 | describe "when you try to bind modules to invalid states" do 66 | test "it should die when you have a do block" do 67 | assert_raise ArgumentError, fn -> Code.require_file("test/assets/bad_state_binding.exs") end 68 | end 69 | 70 | test "it should die when you don't have a do block" do 71 | assert_raise ArgumentError, fn -> Code.require_file("test/assets/bad_state_binding_no_block.exs") end 72 | end 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /test/state_module/defer_update_test.exs: -------------------------------------------------------------------------------- 1 | 2 | defmodule StateServerTest.StateModule.DelegateUpdateTest do 3 | 4 | use ExUnit.Case, async: true 5 | 6 | defmodule Instrumentable do 7 | use StateServer, [start: [tr: :start]] 8 | 9 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 10 | 11 | @impl true 12 | def init(data), do: {:ok, data} 13 | 14 | def run_call(srv, msg), do: GenServer.call(srv, {:run, msg}) 15 | def run_cast(srv, msg), do: GenServer.cast(srv, {:run, self(), msg}) 16 | 17 | def data(srv), do: GenServer.call(srv, :data) 18 | 19 | @impl true 20 | def handle_call({:run, msg}, _from, _state, _data), do: msg 21 | def handle_call(:data, _from, _state, data), do: {:reply, data} 22 | 23 | @impl true 24 | def handle_cast({:run, _from, msg}, _state, _data), do: msg 25 | 26 | defstate Start, for: :start do 27 | @impl true 28 | def handle_call(_, _from, inner_data) do 29 | {:reply, inner_data} 30 | end 31 | 32 | @impl true 33 | def handle_cast({_, from, _}, inner_data) do 34 | send(from, inner_data) 35 | :noreply 36 | end 37 | end 38 | end 39 | 40 | describe "when you delegate with an update" do 41 | test "in the first position with call, it shows up in the data" do 42 | {:ok, srv} = Instrumentable.start_link(:foo) 43 | assert :foo == Instrumentable.data(srv) 44 | assert :bar == Instrumentable.run_call(srv, {:delegate, update: :bar}) 45 | assert :bar == Instrumentable.data(srv) 46 | end 47 | 48 | test "in the first position with cast, it shows up in the data" do 49 | {:ok, srv} = Instrumentable.start_link(:foo) 50 | assert :foo == Instrumentable.data(srv) 51 | Instrumentable.run_cast(srv, {:delegate, update: :bar}) 52 | assert_receive :bar 53 | assert :bar == Instrumentable.data(srv) 54 | end 55 | 56 | test "in the first position with call/goto, it shows up in the data" do 57 | {:ok, srv} = Instrumentable.start_link(:foo) 58 | assert :foo == Instrumentable.data(srv) 59 | assert :bar == Instrumentable.run_call(srv, {:delegate, goto: :start, update: :bar}) 60 | assert :bar == Instrumentable.data(srv) 61 | end 62 | 63 | test "in the first position with cast/goto, it shows up in the data" do 64 | {:ok, srv} = Instrumentable.start_link(:foo) 65 | assert :foo == Instrumentable.data(srv) 66 | Instrumentable.run_cast(srv, {:delegate, goto: :start, update: :bar}) 67 | assert_receive :bar 68 | assert :bar == Instrumentable.data(srv) 69 | end 70 | 71 | test "in the first position with call/transition, it shows up in the data" do 72 | {:ok, srv} = Instrumentable.start_link(:foo) 73 | assert :foo == Instrumentable.data(srv) 74 | assert :bar == Instrumentable.run_call(srv, {:delegate, transition: :tr, update: :bar}) 75 | assert :bar == Instrumentable.data(srv) 76 | end 77 | 78 | test "in the first position with cast/transition, it shows up in the data" do 79 | {:ok, srv} = Instrumentable.start_link(:foo) 80 | assert :foo == Instrumentable.data(srv) 81 | Instrumentable.run_cast(srv, {:delegate, transition: :tr, update: :bar}) 82 | assert_receive :bar 83 | assert :bar == Instrumentable.data(srv) 84 | end 85 | end 86 | 87 | end 88 | -------------------------------------------------------------------------------- /test/state_module/handle_call_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleCallTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data} 12 | 13 | def get_data(srv), do: GenServer.call(srv, :get_data) 14 | 15 | defstate Start, for: :start do 16 | @impl true 17 | def handle_call(:get_data, _from, data) do 18 | {:reply, data} 19 | end 20 | end 21 | end 22 | 23 | defmodule Delegated do 24 | use StateServer, [start: [tr: :end], end: []] 25 | 26 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 27 | 28 | @impl true 29 | def init(data), do: {:ok, data} 30 | 31 | def get_data(srv), do: GenServer.call(srv, :get_data) 32 | 33 | delegate :handle_call 34 | 35 | defstate Start, for: :start do 36 | @impl true 37 | def handle_call(:get_data, _from, data) do 38 | {:reply, data} 39 | end 40 | end 41 | end 42 | 43 | describe "when you implement a state with a handle_call function" do 44 | test "it gets called by the outside module" do 45 | {:ok, pid} = Undelegated.start_link("foo") 46 | 47 | assert "foo" == Undelegated.get_data(pid) 48 | end 49 | 50 | test "it can get called when delegated" do 51 | {:ok, pid} = Delegated.start_link("foo") 52 | 53 | assert "foo" == Delegated.get_data(pid) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/state_module/handle_cast_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleCastTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data} 12 | 13 | def send_cast(srv), do: GenServer.cast(srv, {:send_cast, self()}) 14 | 15 | defstate Start, for: :start do 16 | @impl true 17 | def handle_cast({:send_cast, from}, data) do 18 | send(from, {:response, data}) 19 | :noreply 20 | end 21 | end 22 | end 23 | 24 | defmodule Delegated do 25 | use StateServer, [start: [tr: :end], end: []] 26 | 27 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 28 | 29 | @impl true 30 | def init(data), do: {:ok, data} 31 | 32 | def send_cast(srv), do: GenServer.cast(srv, {:send_cast, self()}) 33 | 34 | delegate :handle_cast 35 | 36 | defstate Start, for: :start do 37 | @impl true 38 | def handle_cast({:send_cast, from}, data) do 39 | send(from, {:response, data}) 40 | :noreply 41 | end 42 | end 43 | end 44 | 45 | describe "when you implement a state with a handle_cast function" do 46 | test "it gets called by the outside module" do 47 | {:ok, pid} = Undelegated.start_link("foo") 48 | 49 | Undelegated.send_cast(pid) 50 | assert_receive {:response, "foo"} 51 | end 52 | 53 | test "it can get called when delegated" do 54 | {:ok, pid} = Delegated.start_link("foo") 55 | 56 | Delegated.send_cast(pid) 57 | assert_receive {:response, "foo"} 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/state_module/handle_continue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleContinueTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data, continue: :my_continuation} 12 | 13 | defstate Start, for: :start do 14 | @impl true 15 | def handle_continue(:my_continuation, resp_pid) do 16 | send(resp_pid, {:response, "foo"}) 17 | :noreply 18 | end 19 | end 20 | end 21 | 22 | defmodule Delegated do 23 | use StateServer, [start: [tr: :end], end: []] 24 | 25 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 26 | 27 | @impl true 28 | def init(data), do: {:ok, data, continue: :my_continuation} 29 | 30 | delegate :handle_continue 31 | 32 | defstate Start, for: :start do 33 | @impl true 34 | def handle_continue(:my_continuation, resp_pid) do 35 | send(resp_pid, {:response, "foo"}) 36 | :noreply 37 | end 38 | end 39 | end 40 | 41 | describe "when you implement a state with a handle_continue function" do 42 | test "it gets called by the outside module" do 43 | Undelegated.start_link(self()) 44 | assert_receive {:response, "foo"} 45 | end 46 | 47 | test "it can get called when delegated" do 48 | Delegated.start_link(self()) 49 | assert_receive {:response, "foo"} 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/state_module/handle_info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleInfoTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data} 12 | 13 | def send_cast(srv), do: GenServer.cast(srv, {:send_cast, self()}) 14 | 15 | defstate Start, for: :start do 16 | @impl true 17 | def handle_info({:respond, from}, data) do 18 | send(from, {:response, data}) 19 | :noreply 20 | end 21 | end 22 | end 23 | 24 | defmodule Delegated do 25 | use StateServer, [start: [tr: :end], end: []] 26 | 27 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 28 | 29 | @impl true 30 | def init(data), do: {:ok, data} 31 | 32 | def send_cast(srv), do: GenServer.cast(srv, {:send_cast, self()}) 33 | 34 | delegate :handle_info 35 | 36 | defstate Start, for: :start do 37 | @impl true 38 | def handle_info({:respond, from}, data) do 39 | send(from, {:response, data}) 40 | :noreply 41 | end 42 | end 43 | end 44 | 45 | describe "when you implement a state with a handle_info function" do 46 | test "it gets called by the outside module" do 47 | {:ok, pid} = Undelegated.start_link("foo") 48 | 49 | send(pid, {:respond, self()}) 50 | assert_receive {:response, "foo"} 51 | end 52 | 53 | test "it can get called when delegated" do 54 | {:ok, pid} = Delegated.start_link("foo") 55 | 56 | send(pid, {:respond, self()}) 57 | assert_receive {:response, "foo"} 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/state_module/handle_internal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleInternalTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data, internal: :internal_msg} 12 | 13 | defstate Start, for: :start do 14 | @impl true 15 | def handle_internal(:internal_msg, resp_pid) do 16 | send(resp_pid, {:response, "foo"}) 17 | :noreply 18 | end 19 | end 20 | end 21 | 22 | defmodule Delegated do 23 | use StateServer, [start: [tr: :end], end: []] 24 | 25 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 26 | 27 | @impl true 28 | def init(data), do: {:ok, data, internal: :internal_msg} 29 | 30 | delegate :handle_internal 31 | 32 | defstate Start, for: :start do 33 | @impl true 34 | def handle_internal(:internal_msg, resp_pid) do 35 | send(resp_pid, {:response, "foo"}) 36 | :noreply 37 | end 38 | end 39 | end 40 | 41 | describe "when you implement a state with a handle_internal function" do 42 | test "it gets called by the outside module" do 43 | Undelegated.start_link(self()) 44 | assert_receive {:response, "foo"} 45 | end 46 | 47 | test "it can gets called when delegated" do 48 | Delegated.start_link(self()) 49 | assert_receive {:response, "foo"} 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/state_module/handle_timeout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleTimeoutTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data, timeout: {:internal_timeout, 200}} 12 | 13 | defstate Start, for: :start do 14 | @impl true 15 | def handle_timeout(:internal_timeout, resp_pid) do 16 | send(resp_pid, {:response, "foo"}) 17 | :noreply 18 | end 19 | end 20 | end 21 | 22 | defmodule Delegated do 23 | use StateServer, [start: [tr: :end], end: []] 24 | 25 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 26 | 27 | @impl true 28 | def init(data), do: {:ok, data, timeout: {:internal_timeout, 200}} 29 | 30 | delegate :handle_timeout 31 | 32 | defstate Start, for: :start do 33 | @impl true 34 | def handle_timeout(:internal_timeout, resp_pid) do 35 | send(resp_pid, {:response, "foo"}) 36 | :noreply 37 | end 38 | end 39 | end 40 | 41 | describe "when you implement a state with a handle_timeout function" do 42 | test "it gets called by the outside module" do 43 | Undelegated.start_link(self()) 44 | refute_receive _ 45 | Process.sleep(100) 46 | assert_receive {:response, "foo"} 47 | end 48 | 49 | test "it can get called when delegated" do 50 | Delegated.start_link(self()) 51 | refute_receive _ 52 | Process.sleep(100) 53 | assert_receive {:response, "foo"} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/state_module/handle_transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.HandleTransitionTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Undelegated do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data, timeout: {:internal_timeout, 200}} 12 | 13 | defstate Start, for: :start do 14 | @impl true 15 | def handle_timeout(:internal_timeout, _) do 16 | {:noreply, transition: :tr} 17 | end 18 | 19 | @impl true 20 | def handle_transition(:tr, resp_pid) do 21 | send(resp_pid, {:response, "foo"}) 22 | :noreply 23 | end 24 | end 25 | end 26 | 27 | defmodule Delegated do 28 | use StateServer, [start: [tr: :end], end: []] 29 | 30 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 31 | 32 | @impl true 33 | def init(data), do: {:ok, data, timeout: {:internal_timeout, 200}} 34 | 35 | delegate :handle_transition 36 | 37 | defstate Start, for: :start do 38 | @impl true 39 | def handle_timeout(:internal_timeout, _) do 40 | {:noreply, transition: :tr} 41 | end 42 | 43 | @impl true 44 | def handle_transition(:tr, resp_pid) do 45 | send(resp_pid, {:response, "foo"}) 46 | :noreply 47 | end 48 | end 49 | end 50 | 51 | describe "when you implement a state with a handle_transition function" do 52 | test "it gets called by the outside module" do 53 | Undelegated.start_link(self()) 54 | refute_receive _ 55 | Process.sleep(100) 56 | assert_receive {:response, "foo"} 57 | end 58 | 59 | test "it can get called when delegated" do 60 | Delegated.start_link(self()) 61 | refute_receive _ 62 | Process.sleep(100) 63 | assert_receive {:response, "foo"} 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/state_module/on_state_entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.OnStateEntryTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule StateEntry do 6 | use StateServer, [start: [tr: :end, tr_trap: :end, tr_double: :end, tr_update: :end], end: []] 7 | 8 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 9 | 10 | @impl true 11 | def init(data), do: {:ok, data} 12 | 13 | @impl true 14 | def handle_call(action, _from, _state, _data), do: {:reply, :ok, action} 15 | 16 | @impl true 17 | def handle_transition(:start, :tr_update, pid) do 18 | # do a transition which will modify the state 19 | {:noreply, update: {:update, pid}} 20 | end 21 | def handle_transition(_, _, _), do: :noreply 22 | 23 | @impl true 24 | def on_state_entry(:tr_trap, :end, pid) do 25 | # traps the state_entry early and doesn't fall through to 26 | # the state module. 27 | send(pid, :trapped_route) 28 | :noreply 29 | end 30 | def on_state_entry(:tr_double, :end, pid) do 31 | # allows for a double-hit 32 | send(pid, :first_hit) 33 | :delegate 34 | end 35 | def on_state_entry(_, :start, _), do: :noreply 36 | delegate :on_state_entry 37 | 38 | defstate End, for: :end do 39 | @impl true 40 | def on_state_entry(_, {:update, resp_pid}) do 41 | send(resp_pid, :update_verified) 42 | :noreply 43 | end 44 | def on_state_entry(:tr_double, resp_pid) do 45 | send(resp_pid, :second_hit) 46 | :noreply 47 | end 48 | def on_state_entry(trans, resp_pid) do 49 | send(resp_pid, {:entry_via, trans}) 50 | :noreply 51 | end 52 | end 53 | 54 | end 55 | 56 | describe "when you implement a state with a on_state_entry function" do 57 | test "it gets called correctly when transitioning" do 58 | {:ok, pid} = StateEntry.start_link(self()) 59 | GenServer.call(pid, transition: :tr) 60 | assert_receive {:entry_via, :tr} 61 | end 62 | 63 | test "it gets called correctly on goto" do 64 | {:ok, pid} = StateEntry.start_link(self()) 65 | GenServer.call(pid, goto: :end) 66 | assert_receive {:entry_via, nil} 67 | end 68 | 69 | test "you can still trap special cases" do 70 | {:ok, pid} = StateEntry.start_link(self()) 71 | GenServer.call(pid, transition: :tr_trap) 72 | assert_receive :trapped_route 73 | end 74 | 75 | test "double hits must be explicit" do 76 | {:ok, pid} = StateEntry.start_link(self()) 77 | GenServer.call(pid, transition: :tr_double) 78 | assert_receive :first_hit 79 | assert_receive :second_hit 80 | end 81 | 82 | test "you can trigger an update" do 83 | {:ok, pid} = StateEntry.start_link(self()) 84 | GenServer.call(pid, transition: :tr_update) 85 | assert_receive :update_verified 86 | end 87 | end 88 | 89 | defmodule StateEntryDelegation do 90 | use StateServer, [start: [tr: :end, tr2: :end], end: []] 91 | 92 | def start_link(data), do: StateServer.start_link(__MODULE__, data) 93 | 94 | @impl true 95 | def init(data), do: {:ok, data} 96 | 97 | @impl true 98 | def handle_call(action, _from, _state, _data), do: {:reply, :ok, action} 99 | 100 | @impl true 101 | def on_state_entry(:tr2, :end, data) do 102 | send(data, :outer_handler) 103 | :noreply 104 | end 105 | def on_state_entry(_, :start, _), do: :noreply 106 | delegate :on_state_entry 107 | 108 | defstate End, for: :end do 109 | @impl true 110 | def on_state_entry(trans, resp_pid) do 111 | send(resp_pid, {:entry_via, trans}) 112 | :noreply 113 | end 114 | end 115 | 116 | end 117 | 118 | describe "when you implement a state with a on_state_entry function and delegate" do 119 | test "it gets called correctly after delegation" do 120 | {:ok, pid} = StateEntryDelegation.start_link(self()) 121 | GenServer.call(pid, transition: :tr) 122 | assert_receive {:entry_via, :tr} 123 | end 124 | 125 | test "it gets called correctly before delegation" do 126 | {:ok, pid} = StateEntryDelegation.start_link(self()) 127 | GenServer.call(pid, transition: :tr2) 128 | assert_receive :outer_handler 129 | end 130 | 131 | test "it gets called correctly on goto" do 132 | {:ok, pid} = StateEntryDelegation.start_link(self()) 133 | GenServer.call(pid, goto: :end) 134 | assert_receive {:entry_via, nil} 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/state_module/terminate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StateModule.TerminateTest do 2 | 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Instrumented do 6 | use StateServer, [start: [tr: :end], end: []] 7 | 8 | def start(resp_pid), do: StateServer.start(__MODULE__, resp_pid) 9 | 10 | @impl true 11 | def init(resp_pid) do 12 | {:ok, resp_pid} 13 | end 14 | 15 | @impl true 16 | def handle_call(:tr, _from, _state, _data) do 17 | {:reply, nil, transition: :tr} 18 | end 19 | def handle_call(:stop, from, _state, resp_pid) do 20 | reply(from, nil) 21 | {:stop, :normal, resp_pid} 22 | end 23 | 24 | @impl true 25 | def terminate(_reason, _state, resp_pid) do 26 | send(resp_pid, :terminating_start) 27 | :this_is_ignored 28 | end 29 | 30 | defstate End, for: :end do 31 | @impl true 32 | def terminate(_reason, resp_pid) do 33 | send(resp_pid, :terminating_end) 34 | :this_is_ignored 35 | end 36 | end 37 | end 38 | 39 | describe "instrumenting terminate" do 40 | test "works outside a state module" do 41 | {:ok, srv} = Instrumented.start(self()) 42 | StateServer.call(srv, :stop) 43 | assert_receive :terminating_start 44 | refute Process.alive?(srv) 45 | end 46 | 47 | test "works inside a state module" do 48 | {:ok, srv} = Instrumented.start(self()) 49 | StateServer.call(srv, :tr) 50 | StateServer.call(srv, :stop) 51 | assert_receive :terminating_end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/state_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest do 2 | # 3 | # tests relating to starting a StateServer startup 4 | # 5 | 6 | use ExUnit.Case, async: true 7 | 8 | defmodule Startup do 9 | use StateServer, [start: []] 10 | 11 | def start_link(data, opts \\ []) do 12 | StateServer.start_link(__MODULE__, data, opts) 13 | end 14 | 15 | @impl true 16 | def init(data), do: {:ok, data} 17 | 18 | @spec dump(StateServer.server()) :: {atom, any} 19 | def dump(srv), do: StateServer.call(srv, :dump) 20 | 21 | @spec dump_impl(any, any) :: {:reply, {atom, any}} 22 | defp dump_impl(state, data), do: {:reply, {state, data}} 23 | 24 | @impl true 25 | def handle_call(:dump, _from, state, data), do: dump_impl(state, data) 26 | end 27 | 28 | test "starting a StateServer starts with the expected transition" do 29 | {:ok, pid} = Startup.start_link("foo") 30 | assert {:start, "foo"} == Startup.dump(pid) 31 | end 32 | 33 | test "starting a StateServer as locally named works" do 34 | {:ok, _} = Startup.start_link("foo", name: TestServer) 35 | assert {:start, "foo"} == Startup.dump(TestServer) 36 | end 37 | 38 | test "starting a StateServer as globally named works" do 39 | {:ok, _} = Startup.start_link("foo", name: {:global, TestServer2}) 40 | assert {:start, "foo"} == Startup.dump({:global, TestServer2}) 41 | end 42 | 43 | test "starting a registered StateServer works" do 44 | Registry.start_link(keys: :unique, name: TestRegistry) 45 | 46 | {:ok, _} = Startup.start_link("foo", name: {:via, Registry, {TestRegistry, :foo}}) 47 | assert {:start, "foo"} == Startup.dump({:via, Registry, {TestRegistry, :foo}}) 48 | end 49 | 50 | test "raises with a strange name entry" do 51 | assert_raise ArgumentError, fn -> 52 | Startup.start_link("foo", name: "not_an_atom") 53 | end 54 | 55 | assert_raise ArgumentError, fn -> 56 | Startup.start_link("foo", name: {:foo, :bar}) 57 | end 58 | end 59 | 60 | defmodule StartupInstrumentable do 61 | use StateServer, [start: [], end: []] 62 | 63 | def start_link(fun, opts \\ []) do 64 | StateServer.start_link(__MODULE__, fun, opts) 65 | end 66 | 67 | @impl true 68 | def init(fun), do: fun.() 69 | 70 | @impl true 71 | def handle_internal(:foo, _state, test_pid) do 72 | send(test_pid, :inside) 73 | :noreply 74 | end 75 | 76 | @impl true 77 | def handle_continue(:foo, _state, test_pid) do 78 | send(test_pid, :continue) 79 | :noreply 80 | end 81 | 82 | @impl true 83 | def handle_timeout(_payload, _state, test_pid) do 84 | send(test_pid, :timeout) 85 | :noreply 86 | end 87 | 88 | @impl true 89 | def handle_call(:state, _from, state, _), do: {:reply, state} 90 | end 91 | 92 | test "StateServer started with :ignore can ignore" do 93 | assert :ignore = StartupInstrumentable.start_link(fn -> :ignore end) 94 | end 95 | 96 | test "StateServer started with {:stop, reason} returns the error" do 97 | assert {:error, :critical} = StartupInstrumentable.start_link(fn 98 | -> {:stop, :critical} 99 | end) 100 | end 101 | 102 | test "StateServer started with internal message executes it" do 103 | test_pid = self() 104 | StartupInstrumentable.start_link(fn -> {:ok, test_pid, internal: :foo} end) 105 | assert_receive(:inside) 106 | end 107 | 108 | test "StateServer started with timeout waits as expected" do 109 | test_pid = self() 110 | {:ok, _pid} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, timeout: {:foo, 200}} end) 111 | refute_receive(:timeout) 112 | Process.sleep(200) 113 | assert_receive(:timeout) 114 | end 115 | 116 | test "StateServer started with goto sets state" do 117 | test_pid = self() 118 | {:ok, pid} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, goto: :end} end) 119 | assert :end == StateServer.call(pid, :state) 120 | end 121 | 122 | test "StateServer started with goto and a continuation sets state" do 123 | test_pid = self() 124 | {:ok, pid} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, goto: :end, continue: :foo} end) 125 | assert_receive(:continue) 126 | assert :end == StateServer.call(pid, :state) 127 | end 128 | 129 | test "StateServer started with goto and internal sets state correctly" do 130 | test_pid = self() 131 | {:ok, pid} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, goto: :end, internal: :foo} end) 132 | assert_receive(:inside) 133 | assert :end == StateServer.call(pid, :state) 134 | end 135 | 136 | test "StateServer started with goto and timeout works as expected" do 137 | test_pid = self() 138 | {:ok, pid} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, goto: :end, timeout: {:foo, 200}} end) 139 | refute_receive(:timeout) 140 | Process.sleep(200) 141 | assert_receive(:timeout) 142 | assert :end == StateServer.call(pid, :state) 143 | end 144 | 145 | test "StateServer started with state_timeout works as expected" do 146 | test_pid = self() 147 | {:ok, _} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, state_timeout: {:timeout, 200}} end) 148 | refute_receive :timeout 149 | Process.sleep(200) 150 | assert_receive(:timeout) 151 | end 152 | 153 | test "StateServer started with event_timeout works as expected" do 154 | test_pid = self() 155 | {:ok, _} = StartupInstrumentable.start_link(fn -> {:ok, test_pid, event_timeout: {:timeout, 200}} end) 156 | refute_receive :timeout 157 | Process.sleep(200) 158 | assert_receive(:timeout) 159 | end 160 | 161 | end 162 | -------------------------------------------------------------------------------- /test/stop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StateServerTest.StopTest do 2 | # tests to make sure stop semantics are correct 3 | 4 | use ExUnit.Case, async: true 5 | 6 | @moduletag :stop_test 7 | 8 | defmodule Server do 9 | 10 | use StateServer, start: [] 11 | 12 | def start(resp_pid), do: StateServer.start(__MODULE__, resp_pid) 13 | 14 | @impl true 15 | def init(resp_pid), do: {:ok, resp_pid} 16 | 17 | @impl true 18 | def handle_call(:stop2, from, _, _data) do 19 | StateServer.reply(from, :ok) 20 | {:stop, :normal} 21 | end 22 | def handle_call(:stop3, from, _, resp_pid) do 23 | StateServer.reply(from, :ok) 24 | {:stop, :normal, {:change, resp_pid}} 25 | end 26 | def handle_call(:stop4, _from, _, resp_pid) do 27 | {:stop, :normal, :ok, {:change, resp_pid}} 28 | end 29 | 30 | @impl true 31 | def handle_cast(:stop2, _, _data) do 32 | {:stop, :normal} 33 | end 34 | def handle_cast(:stop3, _, resp_pid) do 35 | {:stop, :normal, {:change, resp_pid}} 36 | end 37 | 38 | @impl true 39 | def terminate(_, _, {:change, resp_pid}) do 40 | send(resp_pid, :changed) 41 | end 42 | def terminate(_, _, _), do: :ok 43 | end 44 | 45 | describe "for handle_call" do 46 | @tag :one 47 | test "the two-term reply works" do 48 | {:ok, srv} = Server.start(self()) 49 | :ok = StateServer.call(srv, :stop2) 50 | Process.sleep(20) 51 | refute Process.alive?(srv) 52 | end 53 | 54 | test "the three-term reply works" do 55 | {:ok, srv} = Server.start(self()) 56 | :ok = StateServer.call(srv, :stop3) 57 | assert_receive :changed 58 | Process.sleep(20) 59 | refute Process.alive?(srv) 60 | end 61 | 62 | test "the four-term reply works" do 63 | {:ok, srv} = Server.start(self()) 64 | :ok = StateServer.call(srv, :stop4) 65 | assert_receive :changed 66 | Process.sleep(20) 67 | refute Process.alive?(srv) 68 | end 69 | end 70 | 71 | describe "for handle_cast" do 72 | test "the two-term reply works" do 73 | {:ok, srv} = Server.start(self()) 74 | StateServer.cast(srv, :stop2) 75 | Process.sleep(20) 76 | refute Process.alive?(srv) 77 | end 78 | 79 | test "the three-term reply works" do 80 | {:ok, srv} = Server.start(self()) 81 | StateServer.cast(srv, :stop3) 82 | assert_receive :changed 83 | Process.sleep(20) 84 | refute Process.alive?(srv) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:state_server, :use_multiverses, true) 2 | 3 | ExUnit.start() 4 | --------------------------------------------------------------------------------