zerodds-endpoint-zig 1.0 — Spec Coverage

Source: docs/specs/zerodds-endpoint-zig-1.0.md — ZeroDDS Zig endpoint SDK spec (XRCE framing, sync Client, async Reader/Writer, reliable stream via reliable-endpoint-1.0). The marshalling comes from crates/lang-rules, column Language::Zig; this document covers the transport.

Implementation:

  • endpoints/zig/ — the pure-Zig endpoint SDK (ADR 0013: from-scratch XCDR wire core, no C): src/zerodds.zig (XRCE framing, Client, AsyncReader), src/reliable.zig (reliable state machine + async-decoupled AsyncWriter), example_sync.zig / example_async.zig / example_reliable.zig.
  • crates/endpoint-e2e/tests/zig.rs — the live ping-pong E2E (raw/sync/async) against the shared Rust XRCE peer.
  • crates/endpoint-e2e/tests/zig_reliable.rs — the reliable E2E (loss recovery, lossless baseline, example, latency bench).

§1 XRCE framing

§1 WRITE_DATA/DATA frame (8-byte header + body)

Spec: 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/zig/src/zerodds.zig — xrceWriteFrame/xrceReadFrame, constants XRCE_SESSION_NOKEY (0x80) and XRCE_STREAM_BEST_EFFORT (0x01); Transport as a function-pointer vtable (deliver/receive, no heap).

Tests: crates/endpoint-e2e/tests/xrce_write_framing.rs::zig_endpoint_frames_conformant_write_data builds the frame with zig run over the SDK file in the tree and hands it to zerodds_xrce::WriteDataPayload::try_from_submessage — the same check every other language column of the gate goes through; it covers the submessage id, the flag byte (0x01), the BaseObjectRequest and the sample. Plus the in-file tests "byte identity vs Rust goldens" in endpoints/zig/src/zerodds.zig (Writer LE+BE against build/golden_le.bin/build/golden_be.bin), "a WRITE_DATA frame carries the BaseObjectRequest under FORMAT_DATA flags", "read frame bounds body to declared length, ignoring appended submessage" and "read frame rejects over-long length and truncation, writer refuses >0xFFFF".

Status: done.

§1 Raw codec without an XRCE frame (ping-pong E2E)

Spec: evidence that the generated sample codec (zerodds-xcdr2-zig) also round-trips over a real channel without XRCE framing — the ground the framing sits on.

Repo: crates/endpoint-e2e/tests/zig.rs::ZIG_RAW_MAIN — raw UDP, no XRCE frame, uses only the generated Ping/Pong codec from crates/endpoint-e2e/tests/zig.rs::gen_module (written as gen.zig into the build directory at test time).

Tests: (Linux) zig_raw_udp — generated Ping/Pong codec directly over a raw UDP socket, no XRCE framing. Passed.

Status: done.

§2 sync Client

§2 Client.write/poll

Spec: A blocking pull path: write(sample) frames + delivers through the Transport, poll() receives a frame and returns the unframed body.

Repo: endpoints/zig/src/zerodds.zig::Client (write/poll, seq counter, fixed txbuf/rxbuf, no heap).

Tests: in-file test sync loopback (pull) (in-memory FIFO transport, 5 samples roundtrip); live E2E crates/endpoint-e2e/tests/zig.rs::ZIG_ENDPOINT_MAIN (mode sync) via UdpTransport + zerodds.zig.

Tests: (Linux) zig_endpoint_sync — full stack via Client.write/poll against the shared Rust XRCE peer. Passed.

Status: done.

§3 async Reader/Writer

§3 AsyncReader (callback reactor)

Spec: A callback reactor (push) as the counterpart to sync poll — Zig has no async/await, so the reactor dispatches each ready frame to a consumer callback.

Repo: endpoints/zig/src/zerodds.zig::AsyncReader (on_sample callback, poll() dispatches one frame, run(max) drains up to max frames or until nothing more is ready).

Tests: in-file test async loopback (push / callback reactor) (5 samples, Collector.on callback collects IDs in order); live E2E crates/endpoint-e2e/tests/zig.rs::ZIG_ENDPOINT_MAIN (mode async) via UdpTransport + zerodds.zig.

Tests: (Linux) zig_endpoint_async — full stack via AsyncReader.run against the shared Rust XRCE peer. Passed.

Status: done.

Note: a standalone best-effort AsyncWriter (without reliability) does not exist for Zig — the async-decoupled write side is covered exclusively by the reliable AsyncWriter (§4) (spec §3, docs/specs/zerodds-endpoint-zig-1.0.md). Not an open item, a deliberate spec decision.

Ping-pong E2E sum (§1/§2/§3): zig_raw_udp/zig_endpoint_sync/zig_endpoint_async — 3/3 passed (Linux).

§4 reliable stream

§4 State machine (sender + receiver) + byte-golden HEARTBEAT/ACKNACK

Spec: XRCE reliable stream (stream_id 0x80, §8.4.10/§8.4.11), mirroring the reference crates/xrce/src/reliable.rs: Sender.submit/pendingHeartbeat/ recvAckNack/getInFlight (history + retransmit); Receiver.recvData/drainInto/ pendingAckNack/reset (reorder + dedup). Window 16, receiver buffer 64, heartbeat 500 ms, payload ≤ 65535, RFC-1982 16-bit sequence numbers. HEARTBEAT (0x0B) and ACKNACK (0x0A) byte-identical to the C SDK’s reference goldens. Full contract details in reliable-endpoint-1.0.

Repo: endpoints/zig/src/reliable.zig — writeDataFrame/parseWriteData, heartbeatFrame/parseHeartbeat, acknackFrame/parseAckNack; Sender, Receiver.

Tests: crates/endpoint-e2e/tests/xrce_write_framing.rs::zig_reliable_frames_conformant_write_data — writeDataFrame gets its own column in the framing gate, because reliable.zig is a separate framing with its own constants and does not @import zerodds.zig; the frame goes through zerodds_xrce::WriteDataPayload::try_from_submessage. Plus (Linux) zig_reliable_unit — zig test on reliable.zig: the mirror-of-reference unit suite (monotonic seq, payload-too-large, window-full, heartbeat first/silence/none, ACKNACK partial/full clear, receiver reorder/dedup/ buffer-full, pending-ACKNACK bitmap, reset, in-process end-to-end loss recovery) plus the byte-golden assertion in the same zig test run: heartbeatFrame(1,3) == 80 00 01 00 0b 01 05 00 01 00 03 00 80, acknackFrame(1,0) == 80 00 01 00 0a 01 05 00 01 00 00 00 80 — identical to the reference goldens. Passed.

Status: done.

§4 Async-decoupled AsyncWriter (SPSC ring) + loss-recovery E2E

Spec: The producer enqueues wait-free into an SPSC ring, a dedicated drain thread holds the Sender state and does all the I/O (send, heartbeat, ACKNACK-driven retransmit) — the producer never enters the kernel. Reliable delivery survives datagram loss — verified live against the shared Rust peer with injected loss.

Repo: endpoints/zig/src/reliable.zig::AsyncWriter (wait-free SPSC ring RING_CAP=1024/SLOT_CAP=512 via std.atomic.Value, write/pop/drainLoop/ drainAckNacks/close); endpoints/zig/example_reliable.zig (runnable in-process demo, no socket).

Tests: (Linux) - zig_reliable_loss_recovery — peer drops every 3rd sample once; the app retransmits on ACKNACK; all 12 samples delivered gap-free in order. - zig_reliable_no_loss — lossless baseline; 12/12. - zig_reliable_example — example_reliable.zig runs and reports sequence 0..11 verified in order.

3/3 passed (Linux) — together with zig_reliable_unit (§4 above) 4/5 of the reliable test set; the latency bench is §5.

Note (honest): while building AsyncWriter.close(), a shutdown deadlock was found and fixed in drainLoop — a lingering window with no incoming ACKNACKs must not block close(); drainLoop now checks running on every iteration instead of only while waiting for an ACKNACK.

Status: done.

§5 Latency

§5 SPSC-ring push vs. inline sendto

Spec: The producer path of AsyncWriter (write → ring-slot memcpy + release store) must be measurably below the inline sendto syscall — the evidence that async write removes syscall latency from the producer path, not that it waits for ACKNACK.

Repo: crates/endpoint-e2e/tests/zig_reliable.rs::runBench (Zig app, mode bench): 4000 iterations of inline sendto vs. 4000 iterations of AsyncWriter.write after 100 warmup pushes.

Tests: (Linux) zig_reliable_latency_bench — producer push (wait-free ring) 14 ns vs. inline sendto 7950 ns (~568×), asserts decoupled_ns < inline_ns. Passed.

Status: done.


Audit status

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

Test run (Linux, verified): cargo test -p zerodds-endpoint-e2e --test zig 3/3 (ping-pong: zig_raw_udp/zig_endpoint_sync/zig_endpoint_async); --test zig_reliable 5/5 (zig_reliable_unit incl. byte-golden, zig_reliable_loss_recovery, zig_reliable_no_loss, zig_reliable_example, zig_reliable_latency_bench); latency bench decoupled 14 ns / inline sendto 7950 ns (~568×).

Open items: none.

zerodds-endpoint-zig 1.0 — Spec-Coverage

Quelle: docs/specs/zerodds-endpoint-zig-1.0.md — ZeroDDS Zig Endpoint-SDK-Spec (XRCE-Framing, sync Client, async Reader/Writer, reliable Stream via reliable-endpoint-1.0). Das Marshalling kommt aus crates/lang-rules, Spalte Language::Zig; hier steht der Transport.

Implementation:

  • endpoints/zig/ — das pure-Zig-Endpoint-SDK (ADR 0013: from-scratch XCDR-Wire-Core, kein C): src/zerodds.zig (XRCE-Framing, Client, AsyncReader), src/reliable.zig (reliable State-Machine + async-entkoppelter AsyncWriter), example_sync.zig / example_async.zig / example_reliable.zig.
  • crates/endpoint-e2e/tests/zig.rs — das Live-Ping-Pong-E2E (raw/sync/async) gegen den geteilten Rust-XRCE-Peer.
  • crates/endpoint-e2e/tests/zig_reliable.rs — das reliable-E2E (Loss-Recovery, lossless Baseline, Example, Latenz-Bench).

§1 XRCE-Framing

§1 WRITE_DATA/DATA-Rahmen (8-Byte-Header + Body)

Spec: 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/zig/src/zerodds.zig — xrceWriteFrame/xrceReadFrame, Konstanten XRCE_SESSION_NOKEY (0x80) und XRCE_STREAM_BEST_EFFORT (0x01); Transport als Funktionszeiger-Vtable (deliver/receive, kein Heap).

Tests: crates/endpoint-e2e/tests/xrce_write_framing.rs::zig_endpoint_frames_conformant_write_data baut den Frame mit zig run über die SDK-Datei im Baum und gibt ihn an zerodds_xrce::WriteDataPayload::try_from_submessage — dieselbe Prüfung, die jede andere Sprachspalte des Gatters durchläuft; sie deckt Submessage-Id, Flag-Byte (0x01), BaseObjectRequest und Sample ab. Dazu die In-File-Tests "byte identity vs Rust goldens" in endpoints/zig/src/zerodds.zig (Writer LE+BE gegen build/golden_le.bin/build/golden_be.bin), "a WRITE_DATA frame carries the BaseObjectRequest under FORMAT_DATA flags", "read frame bounds body to declared length, ignoring appended submessage" und "read frame rejects over-long length and truncation, writer refuses >0xFFFF".

Status: done.

§1 Roher Codec ohne XRCE-Frame (Ping-Pong-E2E)

Spec: Nachweis, dass der generierte Sample-Codec (zerodds-xcdr2-zig) auch ohne XRCE-Framing über einen echten Kanal roundtrippt — die Grundlage, auf der das Framing aufsetzt.

Repo: crates/endpoint-e2e/tests/zig.rs::ZIG_RAW_MAIN — rohes UDP, kein XRCE-Frame, nutzt nur den generierten Ping/Pong-Codec aus crates/endpoint-e2e/tests/zig.rs::gen_module (zur Testlaufzeit als gen.zig ins Build-Verzeichnis geschrieben).

Tests: (Linux) zig_raw_udp — generierter Ping/Pong-Codec direkt über einen rohen UDP-Socket, ohne XRCE-Framing. Grün.

Status: done.

§2 sync Client

§2 Client.write/poll

Spec: Ein blockierender Pull-Pfad: write(sample) framet + liefert über den Transport, poll() empfängt ein Frame und liefert den entrahmten Body.

Repo: endpoints/zig/src/zerodds.zig::Client (write/poll, seq-Zähler, feste txbuf/rxbuf, kein Heap).

Tests: In-File-Test sync loopback (pull) (In-Memory-FIFO-Transport, 5 Samples roundtrip); Live-E2E crates/endpoint-e2e/tests/zig.rs::ZIG_ENDPOINT_MAIN (Modus sync) über UdpTransport + zerodds.zig.

Tests: (Linux) zig_endpoint_sync — voller Stack über Client.write/poll gegen den geteilten Rust-XRCE-Peer. Grün.

Status: done.

§3 async Reader/Writer

§3 AsyncReader (Callback-Reaktor)

Spec: Ein Callback-Reaktor (push) als Gegenstück zum sync poll — Zig hat kein async/await, der Reaktor dispatcht jedes bereite Frame an einen Consumer-Callback.

Repo: endpoints/zig/src/zerodds.zig::AsyncReader (on_sample-Callback, poll() dispatcht ein Frame, run(max) drained bis max Frames oder bis nichts mehr bereit ist).

Tests: In-File-Test async loopback (push / callback reactor) (5 Samples, Collector.on-Callback sammelt IDs in Reihenfolge); Live-E2E crates/endpoint-e2e/tests/zig.rs::ZIG_ENDPOINT_MAIN (Modus async) über UdpTransport + zerodds.zig.

Tests: (Linux) zig_endpoint_async — voller Stack über AsyncReader.run gegen den geteilten Rust-XRCE-Peer. Grün.

Status: done.

Anmerkung: ein eigenständiger best-effort AsyncWriter (ohne Reliability) ist für Zig nicht angelegt — die async-entkoppelte Schreibseite wird ausschließlich vom reliable AsyncWriter (§4) abgedeckt (Spec §3, docs/specs/zerodds-endpoint-zig-1.0.md). Kein offener Punkt, sondern eine bewusste Spec-Entscheidung.

Ping-Pong-E2E-Summe (§1/§2/§3): zig_raw_udp/zig_endpoint_sync/zig_endpoint_async — 3/3 grün (Linux).

§4 reliable Stream

§4 State-Machine (Sender + Receiver) + Byte-golden HEARTBEAT/ACKNACK

Spec: XRCE reliable Stream (stream_id 0x80, §8.4.10/§8.4.11), spiegelt die Referenz crates/xrce/src/reliable.rs: Sender.submit/pendingHeartbeat/ recvAckNack/getInFlight (History + Retransmit); Receiver.recvData/ drainInto/pendingAckNack/reset (Reorder + Dedup). Window 16, Receiver-Buffer 64, Heartbeat 500 ms, Payload ≤ 65535, RFC-1982 16-bit Sequenznummern. HEARTBEAT (0x0B) und ACKNACK (0x0A) byte-identisch zu den Referenz-Goldens des C-SDKs. Volle Kontraktdetails in reliable-endpoint-1.0.

Repo: endpoints/zig/src/reliable.zig — writeDataFrame/parseWriteData, heartbeatFrame/parseHeartbeat, acknackFrame/parseAckNack; Sender, Receiver.

Tests: crates/endpoint-e2e/tests/xrce_write_framing.rs::zig_reliable_frames_conformant_write_data — writeDataFrame hat eine eigene Spalte im Framing-Gatter, weil reliable.zig ein eigenes Framing mit eigenen Konstanten ist und zerodds.zig nicht importiert; der Frame geht durch zerodds_xrce::WriteDataPayload::try_from_submessage. Dazu (Linux) zig_reliable_unit — zig test auf reliable.zig: die Mirror-of-Reference-Unit-Suite (monotone seq, Payload-zu-groß, Window-full, Heartbeat first/silence/leer, ACKNACK Teil-/Voll-Clear, Receiver Reorder/Dedup/Buffer-full, Pending-ACKNACK-Bitmap, Reset, In-Process-End-to-End-Loss-Recovery) plus die Byte-Golden-Assertion in derselben zig test-Runde: heartbeatFrame(1,3) == 80 00 01 00 0b 01 05 00 01 00 03 00 80, acknackFrame(1,0) == 80 00 01 00 0a 01 05 00 01 00 00 00 80 — identisch zu den Referenz-Goldens. Grün.

Status: done.

§4 async-entkoppelter AsyncWriter (SPSC-Ring) + Loss-Recovery-E2E

Spec: Der Producer enqueued wait-free in einen SPSC-Ring, ein dedizierter Drain-Thread hält den Sender-State und macht die gesamte I/O (senden, Heartbeat, ACKNACK-getriebenes Retransmit) — der Producer geht nie in den Kernel. Reliable Delivery überlebt Datagramm-Verlust — verifiziert live gegen den geteilten Rust-Peer mit injiziertem Loss.

Repo: endpoints/zig/src/reliable.zig::AsyncWriter (wait-freier SPSC-Ring RING_CAP=1024/SLOT_CAP=512 via std.atomic.Value, write/pop/drainLoop/ drainAckNacks/close); endpoints/zig/example_reliable.zig (lauffähige In-Process-Demo, kein Socket).

Tests: (Linux) - zig_reliable_loss_recovery — Peer dropt jedes 3. Sample einmalig; die App retransmittet auf ACKNACK; alle 12 Samples lückenlos in Reihenfolge geliefert. - zig_reliable_no_loss — lossless Baseline; 12/12. - zig_reliable_example — example_reliable.zig läuft und meldet sequence 0..11 verified in order.

3/3 grün (Linux) — zusammen mit zig_reliable_unit (§4 oben) 4/5 des reliable-Tests; Latenz-Bench ist §5.

Anmerkung (ehrlich): Beim Aufbau von AsyncWriter.close() wurde ein Shutdown-Deadlock in drainLoop gefunden und gefixt — ein hängendes Fenster ohne eingehende ACKNACKs durfte close() nicht blockieren; drainLoop prüft running jetzt in jeder Iteration statt nur beim Warten auf ein ACKNACK.

Status: done.

§5 Latenz

§5 SPSC-Ring-Push vs. inline sendto

Spec: Der Producer-Pfad des AsyncWriter (write → Ringslot-Memcpy + Release-Store) muss messbar unter dem inline sendto-Syscall liegen — das ist der Beleg, dass Async-Write die Syscall-Latenz aus dem Producer-Pfad nimmt, nicht das Warten auf ACKNACK.

Repo: crates/endpoint-e2e/tests/zig_reliable.rs::runBench (Zig-App, Modus bench): 4000 Iterationen inline sendto vs. 4000 Iterationen AsyncWriter.write nach 100 Warmup-Pushes.

Tests: (Linux) zig_reliable_latency_bench — Producer-Push (wait-free Ring) 14 ns vs. inline sendto 7950 ns (~568×), Assert decoupled_ns < inline_ns. Grün.

Status: done.


Audit-Status

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

Test-Lauf: Linux, verifiziert — cargo test -p zerodds-endpoint-e2e --test zig 3/3 (Ping-Pong: zig_raw_udp/zig_endpoint_sync/zig_endpoint_async); --test zig_reliable 5/5 (zig_reliable_unit inkl. Byte-Golden, zig_reliable_loss_recovery, zig_reliable_no_loss, zig_reliable_example, zig_reliable_latency_bench); Latenz-Bench decoupled 14 ns / inline sendto 7950 ns (~568×).

Offene Punkte: keine.