Docs
NNG Transport & Network Message Passing
Transport-level message passing for Eta, powered by NNG (nanomsg-next-generation).
Note
Eta’s primary local actor model is now PID/mailbox based and documented in Message Passing & Actors and
std.actor. This page documents the explicit NNG socket layer and the legacy socket-mailbox compatibility workflows that remain useful for endpoint-level transport patterns.
Warning
This design note covers the current socket mailbox model. The actor roadmap moves local actor identity and receive semantics to VM mailboxes addressed by PID values, while keeping NNG as the distribution transport layer.
Quick Links
| Goal | Go to |
|---|---|
Primitive API reference (nng-socket, send!, recv!, …) | Networking Primitives |
| PID/mailbox actor model | Message Passing & Actors |
| Actor APIs | std.actor, std.actor.node |
std.net module functions | Modules & Stdlib |
| Example programs | Language Guide — Networking |
Motivation
Eta’s VM is intentionally single-threaded: the interpreter loop, GC,
value stack, call-frame stack, winding stack, catch stack, and trail stack
are all owned by one thread. Making every data structure thread-safe
would add synchronisation overhead to every instruction and introduce
subtle bugs around continuations and dynamic-wind.
The explicit NNG transport layer still supports a process/socket model for endpoint-level workflows without shared state:
- Each socket-based worker is an independent OS process (or OS thread) with its own heap, GC, and stack.
- Workers communicate through serialized messages over NNG sockets.
- Because nothing is shared, there are no data races, no lock contention, and no GC pauses in one actor due to another actor’s allocation.
For BEAM-like local actor semantics, prefer std.actor; NNG remains the
transport bridge for distributed actor nodes and raw network protocols.
Why nng?
nng (nanomsg-next-generation) was chosen over alternatives for several concrete reasons:
| Criterion | nng | ZeroMQ |
|---|---|---|
| License | MIT | MPL-2.0 (libzmq) + MIT (cppzmq) |
| Binary size | ~200 KB static | ~400 KB + C++ wrapper |
| Build | Single FetchContent CMake target | Two repos; DLL copying on Windows |
| Global context | None — sockets are standalone | zmq_ctx_t per process |
| Thread safety | Sockets thread-safe by default | One socket per thread |
| Windows IPC | First-class (ipc:// via named pipes) | Not supported; must use TCP loopback |
| Async I/O | Built-in nng_aio (completion-based) | zmq_poll (readiness-based) |
The absence of a global context object is particularly important: there is nothing to create at startup or destroy at exit, which eliminates a class of lifecycle bugs common in ZeroMQ applications.
Architecture
┌──────────────────────────────────────────────────────────┐
│ Eta Source (std.net) │
│ │
│ (spawn "worker.eta") → parent-side PAIR socket │
│ (send! sock '(task 42)) │
│ (recv! sock 'wait) │
│ (worker-pool "w.eta" '(1 2 3)) │
└──────────────────────┬───────────────────────────────────┘
│ stdlib/std/net.eta
▼
┌──────────────────────────────────────────────────────────┐
│ eta/builtins/nng/ — C++ Primitive Layer │
│ │
│ NngSocketPtr — GC-managed heap object │
│ Wire format — LispVal ↔ binary / s-expression │
│ nng_primitives — register_nng_primitives(env, …) │
│ ProcessManager — spawn / wait / kill child processes │
└──────────────────────┬───────────────────────────────────┘
│ links against
▼
┌──────────────────────────────────────────────────────────┐
│ libnng (fetched via CMake FetchContent) │
└──────────────────────────────────────────────────────────┘
Three layers:
-
Eta stdlib (
std/net.eta) — high-level ergonomic patterns:with-socket,request-reply,worker-pool,pub-sub,survey. -
C++ primitive layer (
eta/builtins/nng/) — registers low-level builtins into the VM’s global slot table:nng-socket,nng-listen,nng-dial,send!,recv!,spawn,current-mailbox, etc. Manages theNngSocketPtrheap object, its GC destructor, and child process lifecycle viaProcessManager. -
libnng — the OS-level socket library. Eta code never calls nng directly; everything goes through the primitive layer.
Socket Protocols
nng implements the Scalability Protocols (SP) specification. Each protocol encodes a distinct communication pattern. The protocol is chosen at socket creation time with a symbol:
| Symbol | Pattern | Ordering | Delivery |
|---|---|---|---|
'pair | 1-to-1 bidirectional | Total order | Guaranteed |
'req / 'rep | Request-reply (lock-step) | Per-sender FIFO | Guaranteed |
'push / 'pull | Pipeline fan-out | Per-sender FIFO | Guaranteed |
'pub / 'sub | Broadcast | Per-publisher FIFO | Best-effort |
'surveyor / 'respondent | Scatter-gather | Per-respondent | Best-effort (deadline) |
'bus | Many-to-many gossip | Per-sender FIFO | Best-effort |
'pair is used by the legacy socket-based spawn helper — each spawned child
gets a PAIR socket as its transport mailbox. Current std.actor mailboxes are
VM-owned queues addressed by PIDs; distributed actor nodes use NNG beneath
std.actor.node.
For the full primitive reference (nng-socket, nng-listen, nng-dial,
nng-subscribe, nng-set-option, endpoint formats, error handling) see
Networking Primitives.
Legacy Socket-Mailbox Compatibility Model
spawn and current-mailbox
The APIs in this section are compatibility helpers for socket-backed child
workers. They are not the primary local actor API. New local actor code should
use (import std.actor) with spawn, send, and receive over PIDs.
Parent Process Child Process
───────────────────── ──────────────────────
(spawn "worker.eta") ;; child loads worker.eta
→ parent-side PAIR socket ◄────────► (current-mailbox) → PAIR socket
(send! worker '(task 42)) (recv! mailbox 'wait)
(recv! worker 'wait) (send! mailbox result 'wait)
spawn does three things atomically:
- Creates a PAIR socket in the parent and listens on an auto-assigned IPC endpoint (Unix domain socket on Linux/macOS, named pipe on Windows).
- Launches a child
etaiprocess with the endpoint passed via--mailbox. - Returns the parent-side socket as the handle for all subsequent
send!/recv!calls.
The child’s (current-mailbox) returns its PAIR socket already dialled
and connected. The two processes are immediately ready to exchange messages.
Message Lifecycle
send! (parent) recv! (child)
│ │
│── serialize to bytes ──────►│── deserialize from bytes ──► LispVal
│ binary wire frame │ on child's heap
Messages are copied — there is no shared memory. The serialization step is the only overhead compared to in-process function calls, but it provides complete isolation: a bug in the child cannot corrupt the parent’s heap.
Lifecycle and Error Model
| Event | Effect |
|---|---|
Parent calls (nng-close sock) | Socket closes; child’s next recv! returns #f |
| Parent crashes | OS closes socket; child’s recv! returns #f |
| Child crashes | Parent’s recv! raises 'nng-error |
| Child exits cleanly | Parent’s recv! returns #f on next call |
Clean shutdown pattern:
(send! worker '(exit))
(spawn-wait worker)
(nng-close worker)
Wire Format
All values exchanged over send! / recv! are serialized to a byte buffer.
Binary (default): Compact encoding derived from the .etac bytecode
constant scheme. Messages begin with the magic byte 0xEA. Fast and
space-efficient for all data types.
Text ('text flag): S-expression string. Human-readable for debugging.
recv! auto-detects the format — no configuration required on the
receiving end.
Serializable types: booleans, fixnums, flonums, characters, strings, symbols, pairs, lists, vectors, bytevectors.
Not serializable: closures, continuations, ports, nng sockets, tensors,
Tape, and TapeRef.
Tape and TapeRef are VM-local AAD runtime values and cannot cross actor or
worker boundaries. Attempting to transport them raises runtime tag
:ad/cross-vm-ref with structured payload fields (including traversal path).
Other non-serializable values raise the existing nng serialization error.
Interaction with Continuations and dynamic-wind
nng sockets are heap objects — exactly like file ports. They are
not captured or replayed by call/cc:
-
Re-invoking a continuation does not replay or undo messages already sent or received. This matches Erlang semantics and common sense for I/O.
-
dynamic-windafter-thunks run on any exit from a region — including exception escapes and continuation invocations — making them reliable for socket cleanup. Thewith-sockethelper instd.netpackages this pattern. -
Spawned processes are fully independent — a parent’s continuations cannot reach into a child’s VM. Communication is exclusively through messages.
Default Timeout and Single-Threaded Safety
Critical constraint: Eta’s VM is single-threaded. A
recv!that blocks indefinitely freezes the entire VM — the REPL, the LSP server, anddynamic-windcleanup code all stop.
Every newly created socket has a 1 000 ms receive timeout by default.
recv! returns #f on timeout rather than blocking forever.
(recv! sock) ; up to 1 s → value or #f on timeout
(recv! sock 'wait) ; indefinite block — use only when reply is guaranteed
(recv! sock 'noblock) ; immediate → value or #f
(nng-set-option sock 'recv-timeout 5000) ; change to 5 s
(nng-set-option sock 'recv-timeout -1) ; infinite (same as 'wait)
For monitoring multiple sockets without blocking any one of them,
use nng-poll — it waits on a set of sockets and returns only those
with messages ready.
Cross-Host Transparency
The same send! / recv! API works over any transport. Changing from
local IPC to TCP is a one-line endpoint change:
;; Local IPC (same machine, fastest)
(nng-listen sock "ipc:///tmp/eta-worker.sock")
;; TCP (any host on the network)
(nng-listen sock "tcp://*:6000")
(nng-dial sock "tcp://10.0.0.5:6000")
spawn uses IPC automatically for local children. For cross-host actors,
start etai independently on the remote machine and connect via TCP — the
Eta code on both sides is identical.
Scope and Limitations
In scope
- All ten nng socket protocols across IPC, TCP, and inproc transports
- Linux, macOS, and Windows (IPC via named pipes on Windows)
spawnfor local child processes;current-mailboxin the childspawn-threadfor in-process actor threads — serializes a thunk (bytecode + upvalues) into a fresh VM thread; samesend!/recv!APIspawn-thread-withfor file-based in-process threads — loads a source module in a fresh VM thread and calls a named functionthread-join/thread-alive?— lifecycle management for thread actorsmonitor— receive a(down ...)message when a peer process or thread exits, used bystd.supervisorstd.supervisor— Erlang-styleone-for-oneandone-for-allrestart strategies on top ofspawn+monitor- Binary and text wire formats with auto-detection on receive
std.nethigh-level helpers:with-socket,request-reply,worker-pool,pub-sub,survey- VS Code extension: syntax highlighting, snippets, and DAP child process tree view
Not Included in the Current Baseline
| Feature | Rationale |
|---|---|
Remote spawn-remote | Requires SSH integration or a distributed node agent. The current baseline supports cross-host messaging via raw tcp://; remote processes are started independently. |
| Actor name registry | Erlang’s register/2 and whereis/1 provide process lookup by name. The current baseline requires knowing the endpoint or holding the socket handle directly. |
| Distributed GC | Lifecycle is managed explicitly via nng-close / spawn-wait; distributed GC is not part of this model. |
| WebSocket / TLS transports | nng supports these transports, but they are not covered by this baseline API layer. |
See Also
- Networking Primitives — Complete nng API and option reference
- Message Passing & Actors — Actor patterns, worked examples, timeouts
- Modules & Stdlib — High-level helper reference
- Examples — Networking — All runnable demos
- Project Status — Current subsystem status overview