State Machines

Why State Machines?

The States and Events

i
pub type State {
    Idle
    Reading
    Routing
    Handling
    Sending
    Done
}

pub type Event {
    Connect
    Data(String)
    Match(String)
    NoMatch
    Response(Int)
    Sent
}

The Transition Function

i
pub fn step(state: State, event: Event) -> Result(State, String) {
    case state, event {
        Idle, Connect -> Ok(Reading)
        Reading, Data(_) -> Ok(Routing)
        Routing, Match(_) -> Ok(Handling)
        Routing, NoMatch -> Ok(Sending)
        Handling, Response(_) -> Ok(Sending)
        Sending, Sent -> Ok(Done)
        _, _ ->
            Error(
                "cannot handle "
                <> string.inspect(event)
                <> " in state "
                <> string.inspect(state),
            )
    }
}

Running a Sequence of Events

i
pub fn run(state: State, events: List(Event)) -> Result(State, String) {
    case events {
        [] -> Ok(state)
        [event, ..rest] ->
            case step(state, event) {
                Error(msg) -> Error(msg)
                Ok(next) -> run(next, rest)
            }
    }
}

The Happy Path and the 404 Path

i
    let happy_path = [
        Connect,
        Data("GET /users"),
        Match("users_handler"),
        Response(200),
        Sent,
    ]
    io.println("Happy path: " <> string.inspect(run(Idle, happy_path)))

    let not_found = [Connect, Data("GET /nope"), NoMatch, Sent]
    io.println("Not found: " <> string.inspect(run(Idle, not_found)))

    let bad = [Connect, Sent]
    io.println("Bad event: " <> string.inspect(run(Idle, bad)))

Testing

i
pub fn idle_connect_test() {
    step(Idle, Connect)
    |> should.equal(Ok(Reading))
}

pub fn reading_data_test() {
    step(Reading, Data("GET /users"))
    |> should.equal(Ok(Routing))
}

pub fn routing_match_test() {
    step(Routing, Match("users_handler"))
    |> should.equal(Ok(Handling))
}

pub fn routing_not_found_test() {
    step(Routing, NoMatch)
    |> should.equal(Ok(Sending))
}

pub fn handling_response_test() {
    step(Handling, Response(200))
    |> should.equal(Ok(Sending))
}

pub fn sending_sent_test() {
    step(Sending, Sent)
    |> should.equal(Ok(Done))
}

pub fn invalid_transition_test() {
    step(Idle, Sent)
    |> should.be_error()
}

pub fn happy_path_test() {
    let events = [
        Connect,
        Data("GET /users"),
        Match("users_handler"),
        Response(200),
        Sent,
    ]
    run(Idle, events)
    |> should.equal(Ok(Done))
}

pub fn not_found_path_test() {
    let events = [Connect, Data("GET /nope"), NoMatch, Sent]
    run(Idle, events)
    |> should.equal(Ok(Done))
}

pub fn stops_on_error_test() {
    let events = [Connect, Sent]
    run(Idle, events)
    |> should.be_error()
}

Check Understanding

Why does step return Result(State, String) rather than just State?

A real server can receive malformed or out-of-order events: a client might drop the connection mid-request, or a bug might deliver the wrong event at the wrong time. Returning Result forces the caller to decide what to do when an invalid event arrives. If step returned a bare State, the only options would be to panic, silently stay in the current state, or add a sentinel state like Crashed. Result keeps the transition function pure and lets the caller handle the failure in whatever way makes sense for its context.

What happens to events after an invalid transition in the list passed to run?

They are never processed. run stops immediately and returns the Error from step. This is the short-circuit behavior in the recursive case: when step returns Error(msg), the function returns Error(msg) without recursing further. The final valid state reached just before the error is also lost; a caller that needs to recover it would have to change run to return both the last valid state and the error message.

Exercises

Trace transitions (10 minutes)

Write trace(initial: State, events: List(Event)) -> List(String) that returns one human-readable string per successful transition, formatted as "Idle --Connect--> Reading". Stop without adding an entry when an invalid event is encountered.

Reset event (15 minutes)

Add Reset to Event. step should accept Reset from any state and return Ok(Idle). Write at least three tests: one that resets from Handling, one from Sending, and one confirming that run handles a reset in the middle of a longer event list and that the events following the reset proceed correctly from Idle.

Valid events (15 minutes)

Write valid_events(state: State) -> List(Event) that returns every event that produces Ok from the given state. Do not call step inside the function; enumerate the transitions directly. Write one test per state confirming that every event in the returned list really does produce Ok from that state, and that valid_events(Idle) does not include Sent or Response(0).

HTTP keepalive (20 minutes)

In HTTP/1.1, a connection can serve multiple requests without reconnecting. Modify step so that Done + Data(_) -> Ok(Routing), skipping Reading since the TCP connection is already open. Write two tests: one showing a two-request keepalive sequence that ends at Done after both requests, and one confirming that Done + Connect still returns Error.