March Docs

NodeSend

NodeSend: an actor message to a process on another node (distributed actors 1/4).

Everything cross-node used to be a CALL (NodeCall, synchronous, typed by a RemoteRef) or a MONITOR frame. This is the missing primitive: a one-way message to a GlobalPid, the way send(pid, msg) is one-way locally.

Wire (msgpack payload behind the NetFrame length prefix; tag byte first, the P5 convention in NetKernel): 0x09 ACTOR_MSG [tag=9, seq:Int, to_local_pid:Int, to_creation:Int, type_tag:Str, payload:Bin, schema:Str?] 0x0A DELIVERY_FAILED [tag=10, seq:Int, reason:Str]

seq is the sender's correlation number, echoed by DELIVERY_FAILED so the sender can tell which message a failure is about. to_creation is the receiving node's creation counter as the sender last knew it: a node that restarted reuses local pids, and a message carrying the old creation must be refused rather than delivered to whatever now owns that pid. type_tag names the message type (fully qualified), so a receiver can refuse a type it does not host without decoding the payload. schema (optional, distributed-deploys build step 9) is the message type's schema hash, a digest of its structure that the typed send puts in: a receiver that knows its own can tell a same-named type of another shape. It is sent only by a typed send; a frame without it decodes with schema: "". A node built before the field existed refuses a seven-element frame.

The receiving side is INJECTED, like NodeRpc's Targets: serve_one decodes the frame, checks the creation, and hands a Delivery to the caller's dispatch function, which knows which local actor hosts which type and which constructor to wrap the payload in (a constructor is minted by the actor that declares it; a library cannot name it). Any failure -- stale creation, a rejected type, a dead pid, an undecodable payload -- goes back to the sender as DELIVERY_FAILED. A remote send is not fire-and-forget.

Payload codec: the sender encodes its message with the type's derived codec (derive Json today, as @[endpoints] does) into bytes; this layer carries bytes. Design record: specs/progress/2026-09-14-remote-send-to-a-global-pid.md.

Type annotations use bare names where a qualified path would not unify (the NodeCall convention).

Types

typeSendErrorSendError = NoConnection | Refused(Int, String)#
typeDeliveryDelivery = {#

from_node is the node id of the connection the frame arrived on, filled in by the receiver (ClusterNode's data reader, or its own id for the loopback), never read off the wire: "" from decode_msg. An access point checks the sender's certificate by it (step 11b), so it must be the link's verified peer, not anything the sender claims.

Functions

fncastcast(fd : Int, seq : Int, to : Pid, type_tag : String, payload : List(Int)) : Result((), SendError)#

Write one ACTOR_MSG for message [seq] to [to] on [fd] (cast: send is the local actor primitive's reserved name). Returns Ok(()) once the frame is written; a delivery failure arrives later as a DELIVERY_FAILED frame (see recv_failure, on_failure), not here. A failed write is Err(NoConnection).

On a certificate-mode connection where raw sends are refused (NetKernel.raw_allowed), anything but session traffic is Err(Refused(seq, "not authorized: ...")), counted, never written.

NodeSend.cast(fd, 1, to, "Chat.Msg", Bytes.to_list(Bytes.from_string("{}")))
fncast_fromcast_from(fd : Int, seq : Int, from, to : Pid, type_tag : String, payload : List(Int)) : Result((), SendError)#

cast, first recording [from] (the sending actor) under [seq] in this process's pending table, so a later DELIVERY_FAILED for [seq] reaches it through on_failure. The record is kept even when the cast fails, and a message that is delivered keeps its entry (only a failure removes it).

fncast_schemacast_schema(fd : Int, seq : Int, to : Pid, type_tag : String, schema : String, payload : List(Int)) : Result((), SendError)#

cast with the message type's schema hash [schema] in the frame (encode_msg_schema; "" for none). The raw-send check (step 11b) is here, so both spellings get it.

fndecode_faileddecode_failed(bytes : List(Int)) : Result((Int, String), String)#

Decode a DELIVERY_FAILED frame payload into (seq, reason). Err for bytes that are not MessagePack or not a DELIVERY_FAILED.

fndecode_msgdecode_msg(bytes : List(Int)) : Result(Delivery, String)#

Decode an ACTOR_MSG frame payload (six or seven elements) into a Delivery: schema is "" for a six-element frame, and from_node is always "" (the receiver fills it in from the connection, never from the wire). Err for bytes that are not MessagePack or not an ACTOR_MSG.

fnencode_failedencode_failed(seq : Int, reason : String) : List(Int)#

A DELIVERY_FAILED frame payload (MessagePack, not yet length-framed) for message [seq], carrying [reason].

fnencode_msgencode_msg(seq : Int, to : Pid, type_tag : String, payload : List(Int)) : List(Int)#

An ACTOR_MSG frame payload (MessagePack, not yet length-framed) for message [seq] to [to], with [type_tag] and [payload]: the six-element frame every node reads (no schema).

fnencode_msg_schemaencode_msg_schema(seq : Int, to : Pid, type_tag : String, schema : String, payload : List(Int)) : List(Int)#

An ACTOR_MSG carrying the message type's schema hash [schema] as a seventh element. With [schema] "" it is exactly encode_msg: the six-element frame every node reads.

fnhandle_framehandle_frame(frame : List(Int), reply_fd : Int, my_creation : Int, dispatch : Delivery -> Result((), String)) : Result((), String)#

The frame-level half of serve_one_reply, for a reader that already has the frame in hand (a PeerReader dispatching by tag: ACTOR_MSG here, MONITOR_REQ to the registry, ...). A frame that is not an ACTOR_MSG is ignored (Ok). Otherwise, in order: a raw (non-session) message on a [reply_fd] that refuses raw sends is refused and counted; a creation other than [my_creation] is stale; else [dispatch] decides. A refusal, stale creation or dispatch Err is answered on [reply_fd] with DELIVERY_FAILED. Err only when writing that reply fails.

fnis_session_tagis_session_tag(type_tag : String) : Bool#

Is [type_tag] session traffic? True when the tag, with anything from its first "#" on removed (a "#<sid>" suffix), is one of session_tags().

NodeSend.is_session_tag("SessionNode.Deliver#42")   -- true
NodeSend.is_session_tag("Chat.Msg")                 -- false
fnon_failureon_failure(frame : List(Int), deliver) : Result((), String)#

Handle a DELIVERY_FAILED [frame] from the per-peer reader: find the sender cast_from recorded under its seq, forget the entry, and call deliver, which turns it into the actor's own message. Err for a frame that is not a DELIVERY_FAILED, or a seq with no recorded sender (nothing is delivered).

NodeSend.on_failure(frame, fn (p, seq, reason) -> send(p, SendFailed(seq, reason)))
fnserve_oneserve_one(fd : Int, my_creation : Int, dispatch : Delivery -> Result((), String)) : Result((), String)#

Read one frame from [fd] and handle it (handle_frame, replying on the same [fd]). An ACTOR_MSG whose creation matches [my_creation] is handed to [dispatch]; a stale creation, a refused raw send, or a dispatch that returns Err is answered with a DELIVERY_FAILED carrying the reason. A frame that is not an ACTOR_MSG is ignored, with no reply. Returns Err only on I/O failure.

NodeSend.serve_one(fd, my_creation, fn d ->
  if Node.tag_is(d, "Chat.Msg") do Ok(()) else Err("no host for " ++ d.type_tag) end)
fnserve_one_replyserve_one_reply(fd : Int, reply_fd : Int, my_creation : Int, dispatch : Delivery -> Result((), String)) : Result((), String)#

As serve_one, but DELIVERY_FAILED goes on [reply_fd]: with the control/data split the message arrives on the data connection [fd] and the failure travels back on the control one. The raw-send check is made against [reply_fd].

fnsession_tagssession_tags() : List(String)#

The ACTOR_MSG type tags that are session traffic (SessionAP's and SessionNode's frames), which cross a connection even when certificate mode refuses raw sends: "SessionAP.Invite", "SessionAP.Withdraw", "SessionAP.Answer" and the seven "SessionNode.*" tags (Hello, Deliver, Bye, Cancel, Ping, Undelivered, Drained).

fntag_actor_msgtag_actor_msg() : Int do 9 end#

The frame tag of an ACTOR_MSG: 9.

fntag_delivery_failedtag_delivery_failed() : Int do 10 end#

The frame tag of a DELIVERY_FAILED: 10.