HomeBlog › No mutexes: what sequential mailboxes buy you
Blog

No mutexes: what sequential mailboxes buy you

Jul 19, 20266 min readBy the Rysh team

rysh's actors hold no locks: one message at a time, one owner per state — plus the two documented exceptions we keep honest about.

rysh's actor layer has a rule you can state in four words: no mutexes in actors.

Not "prefer channels." Not "lock carefully." No locks in actor code, full stop — with exactly two documented exceptions, both at the same architectural boundary, both of which I'll show you rather than hide. That last part matters: a concurrency story is only as trustworthy as its footnotes.

The setup

rysh is an agentic terminal multiplexer. Under the UI is an actor tree — workspace → tabs → lanes → pane groups → panes, plus per-pane children like the LLMPromptExecutionActor that runs agent tool-use loops (on Claude, with your own key). Actors talk exclusively through messages on an embedded NATS bus.

The concurrency contract comes from proto.actor: each actor processes its mailbox sequentially. Receive() handles one message at a time. While a PaneActor is appending output, it cannot simultaneously be resizing its PTY or building a snapshot — those messages are behind it in the queue, not beside it on another core.

Sequential processing plus exclusive ownership is the synchronization. A pane's buffers, mode, and history have one writer: the pane. Mutation requests arrive as messages; the mailbox serializes them; there is nothing left to lock.

func (p *PaneActor) Receive(ctx actor.Context) {
    switch msg := ctx.Message().(type) {
    case *msgs.MsgPaneShellOutputAppend:
        p.output = append(p.output, msg.Lines...)   // no lock
    case *msgs.MsgPaneSnapshotRequest:
        ctx.Respond(p.buildSnapshot())              // no lock — same queue
    }
}

The hard part: goroutines that outlive a message

The model would be trivial if every operation were quick. It isn't. An agent's LLM call runs for seconds or minutes; a PTY read loop runs for the pane's whole life. These need goroutines — and goroutines are exactly where actor discipline usually dies, because the goroutine helpfully keeps a pointer to the actor and mutates it from outside the mailbox.

Our rule for spawned work: capture only immutable values. The LLMPromptExecutionActor launches a completion goroutine that closes over the pane ID, the prompt string, and a publisher handle — never the actor pointer, never a mutable field:

paneID, prompt := a.paneID, msg.Prompt      // copies
pub := a.publisher                           // safe shared handle
go func() {
    out, err := provider.Complete(ctx, prompt)
    pub.SendPaneAIOutput(paneID, render(out, err))  // result returns as a message
}()

The goroutine's only side effect is publishing. Results re-enter the world through the bus and get processed inside somebody's mailbox, back under the sequential guarantee. Cancellation follows the same shape: a new prompt cancels the in-flight context ("last-prompt-wins") — the state deciding which completion is current lives in the actor, mutated only by messages.

The two exceptions

Both live at the boundary where the actor world meets a byte-oriented one: the PTY.

Exception 1: the VTerm wrapper holds a sync.Mutex. To run vim or htop in a pane, rysh feeds PTY bytes into a vt10x terminal emulator. The feeder is rawReadLoop — a goroutine pumping 4KB reads for the pane's lifetime. Routing every read through the mailbox as a message would put the hottest byte-path in the system through serialization it doesn't need. So the read loop writes the emulator directly, snapshot builds read the screen grid, and the wrapper locks around both. A classic two-party lock with clear roles — the honest cost of embedding a byte-driven emulator in a message-driven world.

Exception 2: rawReadLoop touches pane fields. The read loop also flips p.rawMode when the emulator detects alternate-screen entry/exit (that's how vim auto-switches the pane into raw rendering), and buildSnapshot — running in the mailbox — reads it. Two parties again: one dedicated writer outside the mailbox, one reader inside it, with the read path consuming a boolean whose worst-case staleness is one frame. We judged that tolerable and wrote the reasoning into the code instead of pretending the field is mailbox-owned.

Is exception 2 the first thing a strict reviewer would flag? Yes. Would an atomic, or a tiny "mode changed" message, make it unimpeachable? Also yes — and that's likely where it ends up. The point of this section isn't that our carve-outs are beautiful; it's that there are exactly two, they're fenced at one boundary, and they're documented rather than discovered.

What the rule actually buys

Bug-class elimination, not bug reduction. Data races on actor state aren't rare in rysh's actor layer — they're inexpressible. There's no shared mutable access to race.

Local reasoning. Reading Receive() is reading single-threaded code. Every function an actor calls on its own state inherits the guarantee. Reviews stop asking "what else runs concurrently with this?" — for actor code, the answer is structurally nothing.

Deadlock immunity by absence. No locks, no lock ordering, no ordering violations. Fire-and-forget messaging has its own hazard (request/reply cycles), which we sidestep by using async sends for commands and confining request/reply to leaf-ward snapshot collection.

The bill

If you take one thing: don't aim for "we lock carefully." Aim for a design where the question "who else can touch this?" has a structural answer — and where every exception to that answer is small, fenced, and written down.


rysh is in early access and we're recruiting design partners — teams who want agents in their real workflow and will tell us what breaks. Direct line to the founders: rysh.ai/design-partner.

More in this series: Snapshot-driven rendering · Emulating a terminal inside a terminal

Try Rysh

Every pane is a shell and an AI agent — install takes one command.

Get started free →