zerodds-endpoint-elixir 1.0 — Spec Coverage

Source: docs/specs/zerodds-endpoint-elixir-1.0.md — the ZeroDDS Elixir endpoint SDK spec. The marshalling comes from crates/lang-rules, column Language::Elixir; this document covers the transport.

Implementation:

  • endpoints/elixir/lib/zerodds.ex — XRCE framing (ZeroDDS.Endpoint), sync ZeroDDS.Client, async ZeroDDS.AsyncReader, ZeroDDS.MemTransport.
  • endpoints/elixir/lib/reliable.ex — reliable sender/receiver state machine (ZeroDDS.Reliable.Sender/Receiver) + HEARTBEAT/ACKNACK wire codec (ZeroDDS.Reliable) + ZeroDDS.Reliable.Drain.
  • crates/endpoint-e2e/tests/elixir.rs — ping-pong E2E; crates/endpoint-e2e/tests/elixir_reliable.rs — reliable-stream E2E.

§1 XRCE framing

Spec: §1 — 8-byte XRCE header (session, stream, seq LE, submsg id 0x07 WRITE_DATA, flags, len LE) + body, byte-identical to crates/xrce + endpoints/c.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.Endpoint — write_frame/4/read_frame/1, constants session_nokey/0 (0x80) and stream_best_effort/0 (0x01).

Tests: crates/endpoint-e2e/tests/elixir.rs::elixir_raw_udp (raw XCDR2 with no XRCE frame — its own minimal harness); the framing itself is exercised live via elixir_endpoint_sync and elixir_endpoint_async (§4).

Status: done.

§2 Sync Client

Spec: §2 — blocking Client: write/2 frames + delivers synchronously, poll/1 is a single non-blocking receive; no built-in timeout-receive — the caller polls itself in a deadline loop.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.Client (new/1, write/2 with a modulo-0x10000 sequence counter, poll/1); the transport contract %{deliver: fun/1, receive: fun/0} as the sole integration point; ZeroDDS.MemTransport as the in-memory reference transport for tests/examples.

Tests: endpoints/elixir/test.exs — “sync loopback: 5 samples OK” (ZeroDDS.Client.write/poll over MemTransport, full roundtrip of 5 samples); live E2E elixir_endpoint_sync (§4).

Status: done.

§3 Async Reader/Writer

Spec: §3 — a spawned process polls the transport and sends unframed sample bodies as {:zerodds_sample, body} to the target’s mailbox; there is no separate AsyncWriter type — the sync Client (§2) is already the send path.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.AsyncReader (start/2 spawns the receive process, stop/1 via a :zerodds_stop message).

Tests: endpoints/elixir/test.exs — “async loopback: 5 samples OK” (ZeroDDS.AsyncReader.start/2 over MemTransport, a receive block in the consumer process for 5 samples); live E2E elixir_endpoint_async (§4).

Status: done.

§4 Ping-pong E2E (live)

Spec: §5.1/§5.2 — an Elixir app exchanges a typed sample with the shared Rust XRCE peer over a real UDP socket: once as raw generated codec with no XRCE frame, once as the full stack (generated types + endpoint SDK) sync and async.

Repo: crates/endpoint-e2e/tests/elixir.rs — ELIXIR_RAW_MAIN (raw UDP, no XRCE frame, uses only the generated Zdgen.Ping.marshal_xcdr/Zdgen.Pong.unmarshal), ELIXIR_ENDPOINT_MAIN (a %{deliver:, receive:} transport over :gen_udp + the ZeroDDS module, mode sync/async via CLI argument; the generated Zdgen.* types and the ZeroDDS.* SDK live in disjoint namespaces and are loaded side by side).

Tests: (Linux) - elixir_raw_udp — generated Ping/Pong codec directly over a raw UDP socket, no XRCE framing. - elixir_endpoint_sync — full stack via ZeroDDS.Client. - elixir_endpoint_async — full stack via ZeroDDS.AsyncReader (poll for the write side, process/mailbox for the read side).

3/3 passed (Linux).

Status: done.

§5 Reliable stream — state machine, wire, async writer

Spec: §4 (references reliable-endpoint v1.0 §3/§4) — XRCE reliable stream (stream_id 0x80, §8.4.10/§8.4.11), mirroring the reference crates/xrce/src/reliable.rs: ZeroDDS.Reliable.Sender.submit/2/ pending_heartbeat/2/recv_acknack/2/get_in_flight/2; ZeroDDS.Reliable.Receiver.recv_data/3/drain_in_order/1/ pending_acknack/2/reset/1. Window 16, receiver buffer 64, heartbeat 500 ms, payload ≤ 65535, RFC-1982 16-bit sequence numbers — as immutable structs (every call threads the state through and returns it, BEAM has no mutation). Alongside it, the async-decoupled ZeroDDS.Reliable.Drain: not a wait-free ring but a GenServer — the producer submit/2s (a GenServer.cast, one mailbox send, no kernel entry), a dedicated drain process instance holds the Sender state and the :gen_udp socket and does all the I/O (send, heartbeat tick every 50 ms, ACKNACK-driven retransmit).

Repo: endpoints/elixir/lib/reliable.ex — ZeroDDS.Reliable.write_frame/2, heartbeat_frame/6/parse_heartbeat/1, acknack_frame/6/parse_acknack/1; ZeroDDS.Reliable.Sender, ZeroDDS.Reliable.Receiver; ZeroDDS.Reliable.Drain (start_link/1/activate/1/submit/2/finish/2, FIFO drain_pending cascade, socket ownership handoff controlling_process → activate); endpoints/elixir/example_reliable.exs (runnable in-process demo, no socket); endpoints/elixir/reliable_app.exs (live UDP sender app for the E2E, including a bench mode).

Tests: (Linux) - elixir_reliable_unit_and_golden (crates/endpoint-e2e/tests/elixir_reliable.rs) runs elixir -r lib/reliable.ex reliable_test.exs [golden_dir] — 22 unit checks: monotonic seq (submit_assigns_monotonic_seqnrs, submit_two_in_flight), payload-too-large (submit_rejects_payload_too_large), window-full (submit_rejects_when_window_full), heartbeat first/body/t0/silence/after/none (pending_heartbeat_fires_first_time/ _body_first_last_stream/_fires_at_t0/_silenced_before_period/ _fires_after_period/_none_when_window_empty), ACKNACK partial/full clear (recv_acknack_clears_acked_keeps_missing/ _full_clear_when_no_bits_set), receiver in-order/reorder/dedup/buffer-full (recv_data_buffers_in_order/_reorder_blocks_on_gap/_reorder_delivers/ _drops_duplicates/_rejects_when_buffer_full), pending-ACKNACK bitmap (pending_acknack_marks_missing_slots), reset (reset_clears_state_completely), end-to-end loss recovery in three steps (e2e_drain_blocks_on_lost_middle_sample/ e2e_missing_sample_retransmittable/e2e_delivers_all_after_retransmit). Plus — when the Rust golden generator (zerodds-endpoint-golden) ran — 4 more checks: byte-golden (byte_golden_heartbeat/byte_golden_acknack: heartbeat_frame(0x80,0x00,1,1,3,0x80) == 80 00 01 00 0B 01 05 00 01 00 03 00 80, acknack_frame(0x80,0x00,1,1,0,0x80) == 80 00 01 00 0A 01 05 00 01 00 00 00 80 — identical to the reference goldens) and parse roundtrip (golden_heartbeat_parse/ golden_acknack_parse). No false-green: if the golden generator did not run, this is logged best-effort rather than silently skipped. - elixir_reliable_loss_recovery — peer drops every 3rd sample once; the app (reliable_app.exs) retransmits via ZeroDDS.Reliable.Drain on ACKNACK; all 12 samples delivered gap-free in order. - elixir_reliable_no_loss — lossless baseline; 12/12. - elixir_reliable_example — example_reliable.exs runs and reports RELIABLE OK (12 samples, drop simulation + ACKNACK recovery in the in-process model).

5/5 passed (Linux), 4 of which belong to this section (latency bench in §6).

Status: done.

§6 Latency — GenServer submit cast vs. inline :gen_udp.send

Spec: §5.4 — the producer path of ZeroDDS.Reliable.Drain.submit/2 (GenServer.cast → mailbox send) must be measurably below the inline :gen_udp.send syscall — the evidence that BEAM message-passing removes syscall latency from the producer path, not that it waits for ACKNACK. Unlike a wait-free ring (Go/Zig/Nim), the decoupling here is a GenServer cast — a mailbox send with scheduler involvement, hence a smaller but still clear margin over the inline syscall.

Repo: endpoints/elixir/reliable_app.exs::ReliableApp.bench/1 — 20000 iterations of inline :gen_udp.send (connected socket) vs. 20000 iterations of ZeroDDS.Reliable.Drain.submit/2, no live peer needed (an arbitrary bound UDP port, only local dispatch cost under measurement).

Tests: (Linux) elixir_reliable_producer_latency (crates/endpoint-e2e/tests/elixir_reliable.rs) — submit cast 452 ns vs. inline :gen_udp.send 5618 ns (~12×).

Status: done.


Audit-Status

6 done / 0 partial / 0 open / 0 blocked / 0 n/a (informative).

Test run (Linux, verified): cargo test -p zerodds-endpoint-e2e --test elixir 3/3 (ping-pong: elixir_raw_udp/elixir_endpoint_sync/elixir_endpoint_async); --test elixir_reliable 5/5 (elixir_reliable_unit_and_golden — 22 Elixir unit checks incl. byte-golden, elixir_reliable_loss_recovery, elixir_reliable_no_loss, elixir_reliable_example, elixir_reliable_producer_latency); latency bench submit cast 452 ns / inline :gen_udp.send 5618 ns (~12×).

Open items: none.

zerodds-endpoint-elixir 1.0 — Spec-Coverage

Quelle: docs/specs/zerodds-endpoint-elixir-1.0.md — ZeroDDS Elixir Endpoint-SDK-Spec. Das Marshalling kommt aus crates/lang-rules, Spalte Language::Elixir; hier steht der Transport.

Implementation:

  • endpoints/elixir/lib/zerodds.ex — XRCE-Framing (ZeroDDS.Endpoint), sync ZeroDDS.Client, async ZeroDDS.AsyncReader, ZeroDDS.MemTransport.
  • endpoints/elixir/lib/reliable.ex — reliable Sender/Receiver-State-Machine (ZeroDDS.Reliable.Sender/Receiver) + HEARTBEAT/ACKNACK-Wire-Codec (ZeroDDS.Reliable) + ZeroDDS.Reliable.Drain.
  • crates/endpoint-e2e/tests/elixir.rs — Ping-Pong-E2E; crates/endpoint-e2e/tests/elixir_reliable.rs — reliable-Stream-E2E.

§1 XRCE-Framing

Spec: §1 — 8-Byte-XRCE-Header (session, stream, seq LE, submsg id 0x07 WRITE_DATA, flags, len LE) + Body, byte-identisch zu crates/xrce + endpoints/c.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.Endpoint — write_frame/4/read_frame/1, Konstanten session_nokey/0 (0x80) und stream_best_effort/0 (0x01).

Tests: crates/endpoint-e2e/tests/elixir.rs::elixir_raw_udp (rohes XCDR2 ohne XRCE-Frame — eigener Mini-Harness); Framing selbst wird über elixir_endpoint_sync und elixir_endpoint_async (§4) live geübt.

Status: done.

§2 Sync Client

Spec: §2 — blockierender Client: write/2 framet + liefert synchron, poll/1 ist ein nicht-blockierender Einzel-Receive; kein eingebautes Timeout-Receive — der Aufrufer pollt selbst in einer Deadline-Schleife.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.Client (new/1, write/2 mit modulo-0x10000-Sequenzzähler, poll/1); der transport-Vertrag %{deliver: fun/1, receive: fun/0} als einziger Integrationspunkt; ZeroDDS.MemTransport als In-Memory-Referenztransport für Tests/Beispiele.

Tests: endpoints/elixir/test.exs — “sync loopback: 5 samples OK” (ZeroDDS.Client.write/poll über MemTransport, voller Roundtrip von 5 Samples); Live-E2E elixir_endpoint_sync (§4).

Status: done.

§3 Async Reader/Writer

Spec: §3 — ein gespawnter Prozess pollt den transport und sendet entrahmte Sample-Bodies als {:zerodds_sample, body} an die Mailbox des target; kein separater AsyncWriter-Typ, der sync Client (§2) ist bereits der Sendepfad.

Repo: endpoints/elixir/lib/zerodds.ex::ZeroDDS.AsyncReader (start/2 spawnt den Empfangs-Prozess, stop/1 per :zerodds_stop-Message).

Tests: endpoints/elixir/test.exs — “async loopback: 5 samples OK” (ZeroDDS.AsyncReader.start/2 über MemTransport, receive-Block im Consumer-Prozess für 5 Samples); Live-E2E elixir_endpoint_async (§4).

Status: done.

§4 Ping-Pong-E2E (live)

Spec: §5.1/§5.2 — eine Elixir-App tauscht mit dem geteilten Rust-XRCE-Peer über einen echten UDP-Socket ein typisiertes Sample aus: einmal roher generierter Codec ohne XRCE-Frame, einmal voller Stack (generierte Typen + Endpoint-SDK) sync und async.

Repo: crates/endpoint-e2e/tests/elixir.rs — ELIXIR_RAW_MAIN (rohes UDP, kein XRCE-Frame, nutzt nur den generierten Zdgen.Ping.marshal_xcdr/Zdgen.Pong.unmarshal), ELIXIR_ENDPOINT_MAIN (%{deliver:, receive:}-Transport über :gen_udp + ZeroDDS-Modul, Modus sync/async per CLI-Argument; generierte Zdgen.*-Typen und die ZeroDDS.*-SDK liegen in disjunkten Namespaces und werden nebeneinander geladen).

Tests: (Linux) - elixir_raw_udp — generierter Ping/Pong-Codec direkt über einen rohen UDP-Socket, ohne XRCE-Framing. - elixir_endpoint_sync — voller Stack über ZeroDDS.Client. - elixir_endpoint_async — voller Stack über ZeroDDS.AsyncReader (Poll für den Write-Teil, Prozess/Mailbox für den Read-Teil).

3/3 grün (Linux).

Status: done.

§5 Reliable Stream — State-Machine, Wire, Async-Writer

Spec: §4 (verweist auf reliable-endpoint v1.0 §3/§4) — XRCE reliable Stream (stream_id 0x80, §8.4.10/§8.4.11), spiegelt die Referenz crates/xrce/src/reliable.rs: ZeroDDS.Reliable.Sender.submit/2/ pending_heartbeat/2/recv_acknack/2/get_in_flight/2; ZeroDDS.Reliable.Receiver.recv_data/3/drain_in_order/1/ pending_acknack/2/reset/1. Window 16, Receiver-Buffer 64, Heartbeat 500 ms, Payload ≤ 65535, RFC-1982 16-bit Sequenznummern — als unveränderliche Structs (jeder Call fädelt den Zustand durch und liefert ihn zurück, BEAM kennt keine Mutation). Dazu der async-entkoppelte ZeroDDS.Reliable.Drain: kein wait-free Ring, sondern ein GenServer — der Producer submit/2t (ein GenServer.cast, ein Mailbox-Send, kein Kernel-Eintritt), eine dedizierte Drain-Prozess-Instanz hält den Sender-State und den :gen_udp-Socket und macht die gesamte I/O (senden, Heartbeat-Tick alle 50 ms, ACKNACK-getriebenes Retransmit).

Repo: endpoints/elixir/lib/reliable.ex — ZeroDDS.Reliable.write_frame/2, heartbeat_frame/6/parse_heartbeat/1, acknack_frame/6/parse_acknack/1; ZeroDDS.Reliable.Sender, ZeroDDS.Reliable.Receiver; ZeroDDS.Reliable.Drain (start_link/1/activate/1/submit/2/finish/2, FIFO-drain_pending-Kaskade, Socket-Ownership-Handoff controlling_process → activate); endpoints/elixir/example_reliable.exs (lauffähige In-Process-Demo, kein Socket); endpoints/elixir/reliable_app.exs (live UDP-Sender-App für das E2E, inkl. bench-Modus).

Tests: (Linux) - elixir_reliable_unit_and_golden (crates/endpoint-e2e/tests/elixir_reliable.rs) läuft elixir -r lib/reliable.ex reliable_test.exs [golden_dir] — 22 Unit-Checks: monotone seq (submit_assigns_monotonic_seqnrs, submit_two_in_flight), Payload-zu-groß (submit_rejects_payload_too_large), Window-full (submit_rejects_when_window_full), Heartbeat first/body/t0/silence/danach/leer (pending_heartbeat_fires_first_time/_body_first_last_stream/_fires_at_t0/ _silenced_before_period/_fires_after_period/_none_when_window_empty), ACKNACK Teil-/Voll-Clear (recv_acknack_clears_acked_keeps_missing/ _full_clear_when_no_bits_set), Receiver In-Order/Reorder/Dedup/Buffer-full (recv_data_buffers_in_order/_reorder_blocks_on_gap/_reorder_delivers/ _drops_duplicates/_rejects_when_buffer_full), Pending-ACKNACK-Bitmap (pending_acknack_marks_missing_slots), Reset (reset_clears_state_completely), End-to-End-Loss-Recovery in drei Schritten (e2e_drain_blocks_on_lost_middle_sample/e2e_missing_sample_retransmittable/ e2e_delivers_all_after_retransmit). Dazu — wenn der Rust-Golden-Generator (zerodds-endpoint-golden) lief — 4 weitere Checks: Byte-Golden (byte_golden_heartbeat/byte_golden_acknack: heartbeat_frame(0x80,0x00,1,1,3,0x80) == 80 00 01 00 0B 01 05 00 01 00 03 00 80, acknack_frame(0x80,0x00,1,1,0,0x80) == 80 00 01 00 0A 01 05 00 01 00 00 00 80 — identisch zu den Referenz-Goldens) sowie Parse-Roundtrip (golden_heartbeat_parse/golden_acknack_parse). Kein false-green: läuft der Golden-Generator nicht, wird das best-effort geloggt statt stumm übersprungen. - elixir_reliable_loss_recovery — Peer dropt jedes 3. Sample einmalig; die App (reliable_app.exs) retransmittet via ZeroDDS.Reliable.Drain auf ACKNACK; alle 12 Samples lückenlos in Reihenfolge geliefert. - elixir_reliable_no_loss — lossless Baseline; 12/12. - elixir_reliable_example — example_reliable.exs läuft und meldet RELIABLE OK (12 Samples, Drop-Simulation + ACKNACK-Recovery im In-Process-Modell).

5/5 grün (Linux), davon 4 in diesem Abschnitt (Latenz-Bench in §6).

Status: done.

§6 Latenz — GenServer-submit-Cast vs. inline :gen_udp.send

Spec: §5.4 — der Producer-Pfad von ZeroDDS.Reliable.Drain.submit/2 (GenServer.cast → Mailbox-Send) muss messbar unter dem inline :gen_udp.send-Syscall liegen — der Beleg, dass BEAM-Message-Passing die Syscall-Latenz aus dem Producer-Pfad nimmt, nicht das Warten auf ACKNACK. Anders als bei einem wait-free Ring (Go/Zig/Nim) ist die Entkopplung hier ein GenServer-Cast — ein Mailbox-Send mit Scheduler-Beteiligung, dadurch ein kleinerer, aber weiterhin klarer Faktor gegenüber dem inline-Syscall.

Repo: endpoints/elixir/reliable_app.exs::ReliableApp.bench/1 — 20000 Iterationen inline :gen_udp.send (verbundener Socket) vs. 20000 Iterationen ZeroDDS.Reliable.Drain.submit/2, kein Live-Peer nötig (ein beliebiger gebundener UDP-Port, nur lokale Dispatch-Kosten unter Messung).

Tests: (Linux) elixir_reliable_producer_latency (crates/endpoint-e2e/tests/elixir_reliable.rs) — submit-Cast 452 ns vs. inline :gen_udp.send 5618 ns (~12×).

Status: done.


Audit-Status

6 done / 0 partial / 0 open / 0 blocked / 0 n/a (informative).

Test-Lauf: Linux, verifiziert — cargo test -p zerodds-endpoint-e2e --test elixir 3/3 (Ping-Pong: elixir_raw_udp/elixir_endpoint_sync/elixir_endpoint_async); --test elixir_reliable 5/5 (elixir_reliable_unit_and_golden — 22 Elixir-Unit-Checks inkl. Byte-Golden, elixir_reliable_loss_recovery, elixir_reliable_no_loss, elixir_reliable_example, elixir_reliable_producer_latency); Latenz-Bench submit-Cast 452 ns / inline :gen_udp.send 5618 ns (~12×).

Offene Punkte: keine.