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.

GoalGo to
Primitive API reference (nng-socket, send!, recv!, …)Networking Primitives
PID/mailbox actor modelMessage Passing & Actors
Actor APIsstd.actor, std.actor.node
std.net module functionsModules & Stdlib
Example programsLanguage 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:

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:

CriterionnngZeroMQ
LicenseMITMPL-2.0 (libzmq) + MIT (cppzmq)
Binary size~200 KB static~400 KB + C++ wrapper
BuildSingle FetchContent CMake targetTwo repos; DLL copying on Windows
Global contextNone — sockets are standalonezmq_ctx_t per process
Thread safetySockets thread-safe by defaultOne socket per thread
Windows IPCFirst-class (ipc:// via named pipes)Not supported; must use TCP loopback
Async I/OBuilt-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:

  1. Eta stdlib (std/net.eta) — high-level ergonomic patterns: with-socket, request-reply, worker-pool, pub-sub, survey.

  2. 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 the NngSocketPtr heap object, its GC destructor, and child process lifecycle via ProcessManager.

  3. 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:

SymbolPatternOrderingDelivery
'pair1-to-1 bidirectionalTotal orderGuaranteed
'req / 'repRequest-reply (lock-step)Per-sender FIFOGuaranteed
'push / 'pullPipeline fan-outPer-sender FIFOGuaranteed
'pub / 'subBroadcastPer-publisher FIFOBest-effort
'surveyor / 'respondentScatter-gatherPer-respondentBest-effort (deadline)
'busMany-to-many gossipPer-sender FIFOBest-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:

  1. 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).
  2. Launches a child etai process with the endpoint passed via --mailbox.
  3. 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

EventEffect
Parent calls (nng-close sock)Socket closes; child’s next recv! returns #f
Parent crashesOS closes socket; child’s recv! returns #f
Child crashesParent’s recv! raises 'nng-error
Child exits cleanlyParent’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:


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, and dynamic-wind cleanup 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

Not Included in the Current Baseline

FeatureRationale
Remote spawn-remoteRequires SSH integration or a distributed node agent. The current baseline supports cross-host messaging via raw tcp://; remote processes are started independently.
Actor name registryErlang’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 GCLifecycle is managed explicitly via nng-close / spawn-wait; distributed GC is not part of this model.
WebSocket / TLS transportsnng supports these transports, but they are not covered by this baseline API layer.

See Also