F#‘s MailboxProcessor is a useful bridge to Gleam on Erlang. Both can give one message loop ownership of a value, rather than letting several callers mutate it directly.
Table of contents
On this page
A Counter with an F# Mailbox
Here’s a counter that handles additions, reads, and a stop message:
type Message =
| Add of int
| Read of AsyncReplyChannel<int>
| Stop
let counter =
MailboxProcessor.Start(fun inbox ->
let rec loop total = async {
let! message = inbox.Receive()
match message with
| Add amount -> return! loop (total + amount)
| Read reply ->
reply.Reply total
return! loop total
| Stop -> return ()
}
loop 0)
counter.Post(Add 3)
let total = counter.PostAndReply(Read, timeout = 1000)
counter.Post Stop
assert (total = 3)scratch.fsx
The state is an argument to loop. Handling Add recurs with a new total. No shared mutable counter is needed.
A Subject Has an Owner
Add the Erlang support package to the project from part 1:
gleam add gleam_erlang
A Subject(message) is a typed address for messages. Any process can send to it, but only its owning process can receive from it. Create the counter’s inbox inside the child process, then send that inbox back to the parent through a separate startup subject.
This handshake is important. Creating an inbox in the parent and handing it to a child doesn’t transfer its ownership.
Translate the Message Loop
Replace src/learning_gleam.gleam with these Gleam blocks in order:
import gleam/erlang/process
pub type Message {
Add(Int)
Read(reply_to: process.Subject(Int))
Stop
}
fn counter(inbox: process.Subject(Message), total: Int) -> Nil {
case process.receive_forever(inbox) {
Add(amount) -> counter(inbox, total + amount)
Read(reply_to) -> {
process.send(reply_to, total)
counter(inbox, total)
}
Stop -> Nil
}
}src/learning_gleam.gleam
receive_forever waits for the next message. The recursive calls are in tail position, so handling messages doesn’t keep growing the stack. The reply subject plays the part of F#‘s AsyncReplyChannel.
Try It
The entry point starts the process and checks one conversation:
pub fn main() -> Nil {
let ready = process.new_subject()
let _pid =
process.spawn(fn() {
let inbox = process.new_subject()
process.send(ready, inbox)
counter(inbox, 0)
})
let assert Ok(inbox) = process.receive(ready, within: 1000)
let reply = process.new_subject()
process.send(inbox, Add(3))
process.send(inbox, Read(reply))
let total = process.receive(reply, within: 1000)
process.send(inbox, Stop)
assert total == Ok(3)
}src/learning_gleam.gleam
Run gleam run. Then send another Add(4) before Read and change the expected result to Ok(7).
Messages sent from this one parent to the counter preserve their send order. Don’t infer a total ordering between unrelated senders. Also, send returning doesn’t acknowledge that the counter has processed the message. The reply does that for this read.
Failure Is Part of the Protocol
receive(..., within: 1000) waits up to 1,000 milliseconds and returns Error(Nil) on timeout. A timeout doesn’t cancel the work or prove it wasn’t performed. Retrying a state-changing request blindly can apply it twice.
The assertions above deliberately fail this short-lived example if startup or the reply fails. A service should handle those outcomes explicitly, monitor process lifetime, and define how clients recover. A subject isn’t durable storage, and the counter disappears when its process exits.
process.spawn creates a linked process. An abnormal exit normally propagates over that link, but a link doesn’t restart anything. For long-running services, use the actor and supervision APIs in gleam_otp rather than extending this raw loop into a home-made framework.
Further reading: gleam/erlang/process and gleam_otp.