:title: Multicast and Socket Attributes in Unicon :author: Jafar Al-Gharaibeh :trnumber: 28 :date: August 2026 :copyright: 2026, Jafar Al-Gharaibeh :abstract: Unicon could open TCP and UDP sockets, but applications had no way to join a multicast group, pick an interface, or set TTL from the language. This report describes trailing name=value attributes on open(), the same style as SSL and graphics windows, plus Attrib() after open() for mutation and f["name"] / key(f) for status. Binding a UDP socket to a group address joins that group; source@group or source= selects source-specific multicast. Defaults cover reuse, all-interface IPv4 joins, and outbound interface selection so the common cases need no attributes at all. :keywords: Unicon, multicast, SSM, UDP, socket attributes, Attrib, runtime, technical report. :docclass: report .. _sec-intro: 1. Introduction =============== This report describes IP multicast :cite:`RFC1112` and the socket-attribute facility it sits on. Trailing arguments to ``open()`` on network modes are ``name=value`` strings applied with ``setsockopt(2)``. After ``open()``, ``Attrib()`` uses the same names. The language-facing reference lives in *Programming with Unicon* :cite:`Jeffery:PwU` (Chapter 5, "Multicast and Socket Attributes") and the language reference (``open`` trailing attributes, ``Attrib``, subscript peek, ``key()``). Automated tests live in ``tests/posix/mcast.icn`` and ``tests/posix/sockpeek.icn``. .. _sec-motivation: 2. Motivation ============= Unicon's ``open()`` already constructed TCP and UDP handles. Multicast -- any-source (ASM) and source-specific (SSM) :cite:`RFC4607` -- is ordinary UDP plus membership and hop-limit options. Without those options, a Unicon program could not join a multicast group like ``239.1.1.1``, restrict a join to one NIC, or send with a limited TTL like ``ttl=3``. These options were not supported. .. _sec-principles: 3. Design principles ==================== **Attributes are ``name=value``, like graphics windows.** Unknown names fail ``open()`` with error 1310 (``bad socket attribute``). Booleans are exactly ``yes`` or ``no``, matching ``verifyPeer``. .. code-block:: unicon procedure main() if open(":5110", "nua4", "bogus=yes") then write("oops") else write("bogus: ", &errortext) if open(":5110", "nua4", "reuseaddr=yes") then write("oops") else write("numeric boolean: ", &errortext) end Output:: bogus: bad socket attribute numeric boolean: bad socket attribute **DWIM for the common cases.** Binding a UDP socket to a multicast group joins that group. Listeners default to ``reuseaddr=yes``. No ``iface=`` means join every UP IPv4 interface and send out the first non-loopback IPv4 address (else loopback). Sending to ``255.255.255.255`` enables ``SO_BROADCAST``. Explicit attributes always override. **``source@group``.** The address form matches VLC, ffmpeg, and RFC 4607 ``(S,G)`` order. Empty either side is invalid. **``ttl`` and ``iface``, not multicast-only names.** Hop limit and interface are used by unicast, multicast, and raw sockets :cite:`AlGharaibeh:UTR29`. ``ttl=`` sets both ``IP_TTL`` and ``IP_MULTICAST_TTL`` (or the IPv6 hop options). ``iface=`` accepts an IPv4 address, a numeric index, or a name (``en0``, ``lo0``; Windows also maps ``lo`` / ``lo0`` to ``loopback_0``). **Order matters.** ``iface=`` before ``join=`` selects the interface used by that join. ``Attrib()`` applies a batch of assignments together for the same reason. **Cached listeners are aliases; attributes get a new socket.** A second ``open()`` of the same listener address with no socket attributes returns another file for the cached fd. An open that supplies ``join=`` / ``iface=`` / and so on creates an independent socket so it cannot retune earlier aliases. Use ``Attrib()`` to change options on a live shared handle. .. _sec-open: 4. Opening a multicast socket ============================= IP multicast uses UDP (modes ``nu`` / ``nua``, optionally with ``4`` or ``6`` for family). A minimal receiver and sender: .. code-block:: unicon procedure main() local f, r f := open("239.1.1.1:5000", "nua") | stop(&errortext) while r := receive(f) do write(r.addr, " ", r.msg) end .. code-block:: unicon procedure main() local f, i f := open("239.1.1.1:5000", "nu") | stop(&errortext) every i := 1 to 5 do writes(f, "hello-" || i) & delay(500) end The receiver binds the group address, so the runtime joins that group (ASM) and enables port reuse. The sender's destination is the group; without ``iface=``, outbound multicast uses the first non-loopback IPv4 address. Kernel multicast loopback stays on, so a local receiver still sees the packets. The same exchange in one process over loopback (this is the shape of ``tests/posix/mcast.icn``). ``iface=127.0.0.1`` keeps the datagram on loopback even when the host has no multicast route: .. code-block:: unicon procedure main() local f, s, r f := open("239.42.42.99:5199", "nua", "iface=127.0.0.1") | stop(&errortext) s := open("239.42.42.99:5199", "nu", "iface=127.0.0.1") | stop(&errortext) writes(s, "hello-multicast") if *select(f, 2000) > 0 then { r := receive(f) write(r.msg) } else write("timeout") close(s) close(f) end Output:: hello-multicast ``r.addr`` is the sender (host plus an ephemeral port). A wildcard bind plus explicit joins is the other shape: .. code-block:: unicon f := open(":5000", "nua", "iface=192.168.1.10", "join=239.1.1.1", "join=232.1.1.1,10.0.0.5") ``join=group`` is ASM; ``join=group,source`` is SSM. Attributes may be repeated. Mixing ASM and SSM in one call is allowed. If ``open()`` has no ``4`` / ``6`` flag, a ``join=`` group address picks the family so an IPv4 join is not attempted on an IPv6 wildcard socket. .. _sec-ssm: 5. Source-specific multicast ============================ Name the source before the group, or pass ``source=`` on a socket already bound to the group. Both IPv4 and IPv6 are supported. For IPv6 SSM, pass an explicit ``iface=`` (name or index) so the join uses a single interface: .. code-block:: unicon f := open("192.168.1.1@239.1.1.1:5000", "nua") f := open("239.1.1.1:5000", "nua", "source=192.168.1.1") f := open("::1@ff3e::1:5000", "nua6", "iface=lo0") f := open("ff3e::1:5000", "nua6", "iface=lo0", "source=::1") On send or connect, ``source@group:port`` keeps only the group so the destination is the group address. The source is a receive-side filter, not a send address. IPv6 SSM is a single ``MCAST_JOIN_SOURCE_GROUP`` / ``MCAST_LEAVE_SOURCE_GROUP`` with no multi-interface walk. Those walks have triggered macOS kernel panics in IPv6 source filter teardown. Already-a-member and already-gone are treated as success. .. _sec-attrs: 6. Attributes ============= .. list-table:: :header-rows: 1 * - Attribute - Role * - ``join=`` - Add membership: ``group`` (ASM) or ``group,source`` (SSM). Repeatable. Set-only. * - ``leave=`` - Drop membership; same forms as ``join``. Pass ``iface=`` first when the join used a specific interface. Set-only. * - ``source=`` - SSM source for a group bind (repeatable). Set-only. * - ``iface=`` - IPv4 address, index, or name. Restricts join and multicast send. ``f["iface"]`` peeks a dotted IPv4 address or an IPv6 ifindex. * - ``ttl=`` - Unicast and multicast hop limits. ``f["ttl"]`` peeks the multicast hop limit. * - ``mcastloop=yes|no`` - Whether the host receives its own multicasts. Kernel default is ``yes``. * - ``reuseaddr=`` / ``reuseport=`` - Bind reuse. Listeners default to ``reuseaddr=yes`` (UNIX). Multicast binds also get ``reuseport=yes`` where available (and ``reuseaddr=yes`` on Windows). * - ``broadcast=`` - ``SO_BROADCAST``. Opening ``255.255.255.255`` sets it. * - ``rcvbuf=`` / ``sndbuf=`` - Buffer sizes in bytes. .. _sec-attrib: 7. ``Attrib()`` and status peek =============================== .. code-block:: unicon procedure main() local f, g f := open(":5110", "nua4") | stop(&errortext) Attrib(f, "ttl=4") write("ttl=", f["ttl"]) write("mcastloop=", f["mcastloop"]) if g := f["groups"] then every write(!g) else write("groups: unpopulated") close(f) end Output:: ttl=4 mcastloop=yes groups: unpopulated Assignments in one call are applied together, preserving ``iface`` then ``join`` order. Status is peeked with ``f["name"]``. Unknown names raise 1310. An unpopulated field fails. Boolean fields that answered succeed with ``"yes"`` or ``&null``. ``key(f)`` generates every answerable field. ``f["*"]`` snapshots those fields under one lock. Peeking a closed handle is error 174. ``key(f)`` that has not yet produced a name also raises 174 if the handle is already closed. TCP, UDP, multicast, and raw sockets share one peek table (``sock_peek``). ``join``, ``leave``, and ``source`` are verbs, not peek fields. ``proto`` is stored at ``socket()`` time on raw sockets :cite:`AlGharaibeh:UTR29`. ``f["groups"]`` is a list of joined groups (SSM as ``source@group``); a single group is still a list of length 1. .. list-table:: :header-rows: 1 * - Name - Value * - ``ttl`` - Hop limit (integer), typically ``1`` .. ``255`` * - ``iface`` - Dotted IPv4 address (``127.0.0.1``) or IPv6 ifindex (``1``) * - ``groups`` - List of joined groups, e.g. ``239.1.1.1``; SSM as ``192.0.2.1@239.1.1.1``. Unpopulated before any ``join=`` * - ``mcastloop`` - ``"yes"`` or ``&null`` * - ``reuseaddr`` / ``reuseport`` - ``"yes"`` or ``&null`` (``reuseport`` may be unpopulated) * - ``broadcast`` - ``"yes"`` or ``&null`` * - ``rcvbuf`` / ``sndbuf`` - Buffer sizes in bytes (integers) * - ``proto`` - IP protocol number: ``1`` (ICMP), ``2`` (IGMP), ``89`` (OSPF); typical on raw sockets * - ``hdrincl`` - ``"yes"`` or ``&null``; typical on raw sockets .. code-block:: unicon f := open(":5110", "nua4") | stop(&errortext) Attrib(f, "ttl=4") Attrib(f, "iface=127.0.0.1", "join=239.1.1.1") Attrib(f, "iface=127.0.0.1", "join=239.1.1.2") write("ttl=", f["ttl"]) every g := !f["groups"] do write("joined ", g) every k := key(f) do write("field ", k) close(f) Membership changes still use assignment form: .. code-block:: unicon Attrib(f, "join=239.1.1.2") Attrib(f, "iface=127.0.0.1", "leave=239.1.1.1") On some Linux/musl builds ``getsockopt(IP_MULTICAST_IF)`` does not recover the interface used at join time. Pass ``iface=`` on ``leave=`` so ``IP_DROP_MEMBERSHIP`` matches the join. Already-not-a-member is success; the runtime also retries ``INADDR_ANY`` and each local IPv4 address when a single DROP fails. .. _sec-cache: 8. Listener cache ================= ``sock_listen`` still caches the first bind of an address so repeated ``open()`` of the same listener is cheap. That cache is a problem if a later ``open(..., "join=...")`` reused the fd and changed memberships for every live alias. Rules: - Cache hit, no socket attributes: another ``File`` for the same fd (owner refcount). Closing one alias leaves the others until the last handle is closed. - Cache hit with attributes: release the pin and create an independent socket (``reuseaddr`` makes the second bind succeed). ``leave=`` on that socket does not drop the cached alias's membership. - Changing options on a shared handle: ``Attrib()`` on one of the aliases. A cache-hit open that is not a pure alias only allows additive ``join=`` / ``source=``; ``leave=`` and option changes on that path would retune every live ``File``. .. _sec-impl: 9. Runtime implementation ========================= All of this lives in ``src/runtime/rposix.r``, applied from ``open()`` in ``fsys.r`` and from ``Attrib()`` in ``fmisc.r``. **Two passes.** ``reuseaddr`` / ``reuseport`` must be set between ``socket()`` and ``bind()``. Everything else runs after the socket is bound. ``apply_sock_attrs()`` takes a ``prebind`` flag. **Auto-join.** After the post-bind pass, a UDP socket bound to a multicast group is joined unless an explicit ``join=`` took over. ``source@group`` or ``source=`` makes that join SSM. **IPv4 join without ``iface=``.** Walk ``getifaddrs()`` and ``IP_ADD_MEMBERSHIP`` on every UP IPv4 address so same-host and multi-homed receives work without naming a NIC. An explicit ``iface=`` joins only that one. Outbound multicast without ``iface=`` picks the first non-loopback IPv4 address via ``IP_MULTICAST_IF``. **IPv6 ASM** uses ``IPV6_JOIN_GROUP`` / ``IPV6_LEAVE_GROUP`` with an ifindex (zero means default). There is no all-iface walk like IPv4. **Errors.** Unknown attributes and bad booleans are 1310. ``setsockopt`` failures fail ``open()`` / ``Attrib()`` with the system ``&errortext``, except idempotent membership errors (already a member / already gone). Mode ``n`` with ``4`` or ``6`` still selects the address family. ``"nua4"`` is useful when a join group must match an IPv4 wildcard. .. _sec-status: 10. Status and remaining work ============================= The language surface described here is implemented: trailing attributes, implicit ASM/SSM joins, ``Attrib()`` join/leave, listener-cache isolation, IPv4 all-iface join, IPv6 SSM with an explicit interface, and the broadcast shortcut. **Membership query.** ``f["groups"]`` is a list of groups joined through ``open()`` or ``Attrib()`` ``join=`` / ``source=``. The kernel still has no portable membership dump; this is runtime-tracked state. **IPv6 all-interface join.** IPv4 walks every NIC; IPv6 does not. Callers who need every IPv6 interface must join per ``iface=``. **IPv6 SSM without ``iface=``.** Not recommended. The implementation refuses to walk interfaces for IPv6 source filters. **Loopback delivery.** Alpine (especially under QEMU) often does not deliver loopback multicast even when the join succeeds. ``tests/posix/mcast.icn`` still checks membership and treats a missing datagram as success on Alpine so CI stays portable. **Windows IPv6 SSM.** Membership calls are attempted; ``loopback_0`` and UNIX ``lo`` / ``lo0`` names are mapped. Delivery and option support vary by stack; the test skips cleanly on ``ENOPROTOOPT``. **Raw sockets** :cite:`AlGharaibeh:UTR29` reuse ``ttl`` / ``iface`` / ``join`` on ``SOCK_RAW``. That is a separate report. .. _sec-coverage: Appendix: test coverage ======================= ``tests/posix/mcast.icn`` runs in one process over loopback, one UDP port per case so a late datagram cannot land on the next receiver. It checks: - unknown attributes and numeric booleans fail with 1310 - explicit ``join=`` plus ``iface=127.0.0.1`` - implicit join by binding the group address - SSM via ``source@group`` and via ``source=`` - broadcast open of ``255.255.255.255`` (never ``EACCES``) - ``Attrib()`` ``ttl=``, ``join=``, ``leave=`` - IPv6 SSM membership via ``@``, ``source=``, and ``join=group,source`` (delivery not required) - cache-hit alias vs independent attr open ``tests/posix/sockpeek.icn`` checks ``f["ttl"]``, ``f["groups"]`` as a list after two joins, and ``key(f)``. Expected output is ``tests/posix/stand/mcast.std``: :: bogus attribute: bad socket attribute numeric boolean: bad socket attribute Received hello-multicast Received hello-implicit Received hello-ssm-at Received hello-ssm-source broadcast: ok ttl=3 Received hello-attrib leave: ok ipv6-ssm-at: ok ipv6-ssm-source: ok ipv6-ssm-join: ok cache-attr open: independent Received hello-alias References ========== .. bibliography:: utr28.bib