Message Passing & Actors


Erlang-style actor model for Eta: independent actors communicating through message passing over nng sockets.


Overview

Eta’s actor model is built on a simple principle: share nothing, communicate through messages. Each actor has its own VM, heap, and GC, and exchanges data exclusively through serialized messages over nng sockets.

Actors come in two flavours:

PrimitiveIsolationTransportUse case
spawnSeparate OS processipc:// or tcp://Heavy work, fault isolation, cross-host distribution
spawn-threadIn-process threadinproc://Low-latency, no fork/exec overhead, closures as workers

Both return a PAIR socket and use the same send! / recv! / current-mailbox API — code written for one works unchanged with the other.

Note

spawn launches a child process (separate executable) connected over IPC or TCP, while spawn-thread runs a thunk in a new in-process VM thread over an inproc:// socket. The messaging API is identical — choose spawn for fault isolation and network distribution, or spawn-thread for minimal overhead when actors share a machine.

This gives you:

┌───────────────────────┐     PAIR socket      ┌────────────────────────┐
│     Parent Process    │◄──────────────────►  │     Child Process      │
│                       │  ipc:// or tcp://    │                        │
│  VM₁  Heap₁  GC₁      │                      │  VM₂  Heap₂  GC₂       │
│                       │                      │                        │
│  (define w            │                      │  (define mailbox       │
│    (spawn "w.eta"))   │                      │    (current-mailbox))  │
│  (send! w '(task 42)) │                      │  (define msg           │
│  (recv! w 'wait)      │                      │    (recv! mailbox))    │
└───────────────────────┘                      └────────────────────────┘

Primitive Reference

spawn

(spawn module-path)          → parent-side PAIR socket
(spawn module-path endpoint) → parent-side PAIR socket (custom endpoint)

Launches a child etai process that loads module-path. The parent and child are connected by a PAIR nng socket over an automatically-selected IPC endpoint (Unix domain socket on Linux/macOS, named pipe on Windows).

Returns the parent-side socket, which is used for all subsequent send! / recv! calls to the child.

(define worker (spawn "cookbook/concurrency/message-passing-worker.eta"))
(send! worker '(compute 42))
(define result (recv! worker 'wait))
(nng-close worker)

Child endpoint selection:

PlatformDefault transport
Linux / macOSipc:///tmp/eta-<pid>-<n>.sock
Windowsipc://\\.\pipe\eta-<pid>-<n>

Override with the optional endpoint argument:

(spawn "worker.eta" "tcp://*:6000")  ; parent listens on TCP port 6000

current-mailbox

(current-mailbox) → PAIR socket

Called inside a spawned child module, returns the PAIR socket connected to the parent. This is automatically bound when the child is launched via spawn.

;; worker.eta — runs inside the spawned child process
(module worker
  (import std.io)
  (begin
    (define mailbox (current-mailbox))
    (define task (recv! mailbox 'wait))  ; block until parent sends a task
    (send! mailbox (* task 2) 'wait)     ; send result back
    (println "worker: done")))

spawn-wait

(spawn-wait sock) → exit-code

Blocks until the child process associated with sock exits. Returns the process exit code (0 for clean exit, non-zero for error).

(define worker (spawn "worker.eta"))
(send! worker '(work))
(define result (recv! worker 'wait))
(spawn-wait worker)   ; wait for clean exit
(nng-close worker)

spawn-kill

(spawn-kill sock) → void

Forcibly terminates the child process associated with sock. The child receives SIGTERM (Unix) or TerminateProcess (Windows). Use this only when (send! worker '(exit)) is not feasible.

(spawn-kill worker)   ; forcibly terminate
(nng-close worker)    ; close the socket

Lifecycle Management

EventBehavior
Parent calls (nng-close worker)Socket closes; child’s next recv! returns #f → child should exit cleanly
Parent crashesOS closes the socket; child’s recv! returns #f
Child crashesParent’s recv! raises 'nng-error; parent can handle it
Child exits normallyParent’s recv! returns #f on next call

Recommended pattern — clean shutdown:

;; Parent: signal exit, wait for child to finish
(send! worker '(exit))
(spawn-wait worker)
(nng-close worker)

Recommended pattern — safe socket management:

(define worker (spawn "worker.eta"))
(dynamic-wind
  (lambda () #f)
  (lambda ()
    (send! worker '(task 42))
    (recv! worker 'wait))
  (lambda ()
    (nng-close worker)))  ; always close, even on exception

Message Serialization

All values passed over send! / recv! are serialized to a binary wire format. The serialization is transparent — you send and receive ordinary Eta values.

Serializable types:

TypeExample
Booleans#t, #f
Fixnums42, -7
Flonums3.14, 1.0e10
Characters#\a, #\space
Strings"hello"
Symbols'compute
Pairs & lists'(a b c), (cons 1 2)
Vectors#(1 2 3)
Bytevectors#u8(0 255 128)
'() (nil)The empty list

Not serializable for send!/recv! message payloads: Closures, continuations, ports, nng sockets, and tensors. Attempting to send one raises:

'nng-error "cannot send non-serializable value"

This restriction applies to network/message transport encoding. spawn-thread uses a separate capture serializer that can transfer closures (including nested closures and referenced module globals), but still rejects runtime-only handles such as ports/sockets/tensors.

Wire format auto-detection:

(send! sock value)         ; binary (default) — fast, compact
(send! sock value 'text)   ; s-expression text — human-readable, slower

(recv! sock)               ; auto-detects binary vs text

Common Patterns

Pattern 1 — Parent / Child (basic)

The simplest actor pattern: spawn one worker, send it a task, get back a result.

;; parent.eta
(module parent
  (import std.net)
  (import std.io)
  (begin
    (define worker (spawn "worker.eta"))
    (send! worker '(square 7))
    (define result (recv! worker 'wait))   ; => 49
    (println result)
    (send! worker '(exit))
    (spawn-wait worker)
    (nng-close worker)))
;; worker.eta
(module worker
  (begin
    (define mailbox (current-mailbox))
    (letrec ((loop (lambda ()
                     (define msg (recv! mailbox 'wait))
                     (cond
                       ((equal? (car msg) 'square)
                        (send! mailbox (* (cadr msg) (cadr msg)) 'wait)
                        (loop))
                       ((equal? (car msg) 'exit) #f)
                       (#t (loop))))))
      (loop))))

Pattern 2 — Request / Reply (synchronous RPC)

request-reply from std.net encapsulates a single synchronous round-trip over a REQ/REP socket pair:

;; Client (any .eta file or REPL)
(import std.net)
(define answer
  (request-reply "tcp://localhost:5555" '(compute 42)))
;; echo-server.eta — standalone REP server
(module echo-server
  (import std.io)
  (begin
    (define sock (nng-socket 'rep))
    (nng-listen sock "tcp://*:5555")
    (println "echo-server: listening on tcp://*:5555")
    (letrec ((loop (lambda ()
                     (define msg (recv! sock 'wait))
                     (send! sock msg 'wait)          ; echo back unchanged
                     (loop))))
      (loop))))

Run the server:

etai cookbook/concurrency/echo-server.eta

From another terminal or the REPL:

(import std.net)
(request-reply "tcp://localhost:5555" '(hello world))
; => (hello world)

Pattern 3 — Worker Pool (parallel map)

worker-pool spawns one child per task and collects results in parallel:

(import std.net)

;; Square each number in parallel using 5 worker processes
(define results
  (worker-pool "worker.eta" '(1 2 3 4 5)))
; => (1 4 9 16 25)  (if worker squares its input)

See cookbook/concurrency/worker-pool.eta and cookbook/concurrency/parallel-map.eta for complete runnable demos.


Pattern 4 — Publish / Subscribe

;; publisher.eta — sends price updates every second
(module publisher
  (begin
    (define pub (nng-socket 'pub))
    (nng-listen pub "tcp://*:5556")
    (letrec ((broadcast (lambda (n)
                          (send! pub (list 'prices.eur n))
                          (send! pub (list 'prices.usd (* n 1.1)))
                          (broadcast (+ n 1)))))
      (broadcast 100))))
;; subscriber.eta — receives price updates
(module subscriber
  (import std.net)
  (import std.io)
  (begin
    (define sub (nng-socket 'sub))
    (nng-dial sub "tcp://localhost:5556")
    (nng-subscribe sub "prices.")          ; filter: only price messages
    (letrec ((loop (lambda ()
                     (define msg (recv! sub 'wait))
                     (when msg (println msg) (loop)))))
      (loop))))

Or use pub-sub from std.net:

(pub-sub "tcp://localhost:5556"
         '("prices.")
         (lambda (msg) (println msg)))

Pattern 5 — Scatter / Gather (Survey)

Ask multiple workers a question and collect all their answers before a deadline:

;; respondent-worker.eta — answers status queries
(module respondent
  (begin
    (define mailbox (current-mailbox))
    ;; A respondent must connect to the surveyor
    (define surveyor (nng-socket 'respondent))
    (nng-dial surveyor "tcp://localhost:5557")
    (letrec ((loop (lambda ()
                     (define question (recv! surveyor 'wait))
                     (when question
                       (send! surveyor (list 'ok (hostname)) 'wait)
                       (loop)))))
      (loop))))
;; scatter-gather.eta — collect status from all respondents
(import std.net)
(define responses
  (survey "tcp://*:5557" '(status?) 1000))
; => ((ok worker-1) (ok worker-2) ...)

See cookbook/concurrency/scatter-gather.eta for a complete runnable demo.


Pattern 6 — Event Loop with nng-poll

When a process manages multiple sockets simultaneously, nng-poll prevents blocking on any single socket:

(define commands (nng-socket 'pull))
(define events   (nng-socket 'sub))
(nng-listen commands "ipc:///tmp/cmds.sock")
(nng-dial   events   "tcp://localhost:5556")
(nng-subscribe events "")

(letrec ((loop (lambda ()
                 ;; Wait up to 100 ms for any socket to become readable
                 (define ready
                   (nng-poll (list (cons commands 'recv)
                                   (cons events 'recv))
                             100))
                 ;; Process all ready sockets
                 (for-each
                   (lambda (sock)
                     (define msg (recv! sock 'noblock))
                     (when msg (dispatch sock msg)))
                   ready)
                 (loop))))
  (loop))

Pattern 7 — In-Process Threads (spawn-thread)

spawn-thread is a lightweight alternative to spawn for actors that live inside the same OS process. There is no fork/exec overhead; the thunk’s bytecode and upvalues are serialized and executed in a fresh in-process VM thread connected over an inproc:// PAIR socket. The API is identical to spawn / current-mailbox.

;; A worker that captures an offset in its closure.
;; No separate worker file needed.
(define (make-worker offset)
  (spawn-thread
    (lambda ()
      (let ((mb (current-mailbox)))          ; thread-side PAIR socket
        (let ((n (recv! mb 'wait)))
          (send! mb (+ n offset) 'wait))))))

(define t1 (make-worker 10))
(define t2 (make-worker 20))
(define t3 (make-worker 30))

(send! t1 5 'wait)  (send! t2 5 'wait)  (send! t3 5 'wait)
(recv! t1 'wait)    ; => 15
(recv! t2 'wait)    ; => 25
(recv! t3 'wait)    ; => 35

(thread-join t1) (thread-join t2) (thread-join t3)
(nng-close t1)   (nng-close t2)   (nng-close t3)

See cookbook/concurrency/inproc.eta for the runnable demo.

Thread lifecycle

EventEffect
Parent calls (nng-close sock)Socket closes; thread’s next recv! returns #f → thread should exit
Thread thunk returns normallyThread exits; parent’s recv! returns #f on next call
Thread raises an unhandled exceptionThread exits; parent’s next recv! raises 'nng-error
(thread-alive? sock)#t while running, #f after exit
(thread-join sock)Blocks until thread exits; returns 0

Constraints


Cross-Host Messaging

The same send! / recv! API works across machines — just use tcp:// endpoints:

;; ── Machine A (server) ────────────────────────────────────────────────
(define server (nng-socket 'rep))
(nng-listen server "tcp://*:6000")
(define msg (recv! server 'wait))
(send! server (process msg) 'wait)

;; ── Machine B (client) ────────────────────────────────────────────────
(define client (nng-socket 'req))
(nng-dial client "tcp://10.0.0.1:6000")   ; replace with actual server IP
(send! client '(compute 42))
(define result (recv! client 'wait))

See cookbook/concurrency/distributed-compute.eta for a complete two-process demonstration.


Interaction with dynamic-wind and Continuations

nng sockets are heap objects — like file ports. They are not captured or rewound by continuations. call/cc in the middle of a send/receive sequence does not replay or undo I/O.

Socket cleanup with dynamic-wind:

;; The after-thunk always runs — even if an exception escapes the body
;; or a continuation is invoked.
(let ((sock #f))
  (dynamic-wind
    (lambda () (set! sock (nng-socket 'req)))
    (lambda ()
      (nng-dial sock "tcp://localhost:5555")
      (send! sock msg)
      (recv! sock))
    (lambda ()
      (when sock (nng-close sock)))))

The with-socket helper from std.net encapsulates this pattern.


Timeouts and Blocking

Critical: Eta’s VM is single-threaded. A blocking recv! freezes the REPL, LSP, and all dynamic-wind cleanup.

StrategyCodeWhen to Use
Default timeout (1 s)(recv! sock)Most cases — returns #f on timeout
Non-blocking(recv! sock 'noblock)In event loops, polling
Indefinite block(recv! sock 'wait)Only when a reply is guaranteed
Multi-socket poll(nng-poll items 100)Monitoring multiple sockets

Change the default timeout per socket:

(nng-set-option sock 'recv-timeout 5000)  ; 5 s
(nng-set-option sock 'recv-timeout -1)    ; infinite (same as 'wait)

See Also