The unix-socket scraper exposed by ristreceiver / ristsender
(activated by --metrics-unixsock) returned empty or stub-only
bodies on a substantial fraction of scrapes in production.
Six separate defects in tools/prometheus-exporter.c contributed:
1. listen(fd, 1) -- a backlog of one is too small for any
real deployment where Prometheus + an external collector
may scrape within the same RTT. Replaced with SOMAXCONN.
2. accept() returning EINTR was treated as fatal and broke the
thread out of the loop, silently disabling the exporter for
the remainder of the process lifetime. EINTR is now retried.
3. The hot path issued a single unchecked write() with the
result discarded via (void)!. Partial writes and transient
EINTR therefore truncated the response, and an early peer
close raised SIGPIPE on the exporter thread. Replaced with
a write_all helper that loops on partial writes, retries
EINTR, and uses MSG_NOSIGNAL where available.
4. shutdown(SHUT_RDWR) immediately before close() can behave
as an abortive teardown on AF_UNIX SOCK_STREAM under some
kernels, discarding bytes that write() had just queued.
Switched to shutdown(SHUT_WR), which is the documented
"no more writes -- let the receive queue drain" sequence.
5. close() with unread bytes in the receive queue is the
biggest source of empty-body scrapes in the field. The
exporter is a dump-on-connect protocol that never reads
the client's request, but tools like nc / curl / the
Prometheus scraper itself all send "GET /metrics HTTP/1.0
\r\n\r\n". On Linux, close() on an AF_UNIX SOCK_STREAM
with unread input is treated as an abortive teardown: the
kernel sends RST and discards everything in the transmit
queue -- including the 10 KB body we just wrote. The
client sees ECONNRESET / EOF with zero bytes. We now
shutdown(SHUT_WR) and drain the receive queue with a
SO_RCVTIMEO-bounded recv loop before close, so the kernel
does a clean FIN-FIN handshake and the body actually
reaches the client. Confirmed by a Python probe that
went from 5/5 ECONNRESET to 5/5 success in the same
environment.
6. In single-stat-point mode (the default for ristreceiver
and ristsender) rist_prometheus_stats_format() was zeroing
container_count after every scrape. Any subsequent scrape
that arrived before the next 1 Hz stats callback then
emitted only HELP/TYPE/UNIT preambles with no data points,
which manifested in the field as metrics flickering and
downstream consumers parsing NaN / blank bandwidth. The
ring is now only reset in --metrics-multipoint mode;
single-point mode keeps the latest sample visible until
the next callback overwrites it. cleanup_stale_locked()
still ages stale flows / peers out after 15 s.
In single-stat-point mode the writer set container_offset = 1 after
each callback, but the format() macro reads only container[0..count-1]
and container_count stays at 1. container[0] was therefore frozen at
the value from the first callback while every subsequent callback
silently overwrote container[1], which the reader never touches.
Three sites had the same pattern:
- rist_prometheus_handle_client_stats (receiver flow stats)
- rist_prometheus_handle_client_stats (per-receiver-peer stats)
- rist_prometheus_handle_sender_peer_stats
NEWS entry added under 0.2.18 Bug Fixes.
The original librist convention for URL parameters that select from a
small fixed enum is numeric values (aes-type, congestion-control,
timing-mode, reflector, and as of the previous commit profile).
?srp-compat= was added in v0.2.18-rc1 with a symbolic alias ("legacy")
and a permissive parser that mapped any unrecognised value, including
a typo of the alias, to 0 (compliant). Tightened to numeric-only
before v0.2.18 final.
The parser now accepts only 0 or 1. Out-of-range, non-numeric, and
trailing-garbage values cause rist_parse_address2 to return non-zero
with stderr output.
Tests
-----
- test/rist/unit/test_url_srp_compat_parse.c (new): two accepted forms
plus six rejected forms.
- meson test --suite unit: 6/6 OK (was 5/5).
- meson test --suite url-profile: 4/4 OK.
- meson test (full): 49 OK, 11 Expected Fail, 0 unexpected.
RIST_URL_PARAM_PROFILE has been declared in the public urlparam.h header
since at least the rist2rist merge but the URL parser never honored it
and the field had no library-side effect. This commit makes the
parameter do what the public header has always advertised: select the
wire profile of the context that owns the peer, from inside the URL
itself.
The override is implemented entirely in the library so every consumer
that already passes URLs through rist_parse_address2 + rist_peer_create
picks it up without application-side changes.
URL syntax
----------
?profile=0 Simple
?profile=1 Main
?profile=2 Advanced
Out-of-range, non-numeric, or trailing-garbage values cause
rist_parse_address2 to return non-zero with stderr output.
ABI
---
RIST_PEER_CONFIG_VERSION bumps from 3 to 4. Two trailing fields added
to struct rist_peer_config (profile, profile_set).
Tests
-----
- test/rist/unit/test_url_profile_parse.c: parser assertions including
the rejected forms.
- test/rist/unit/test_url_profile_override.c: three scenarios through
the public API - override applies, conflicting peer refused, late
peer refused.
- test/rist/meson.build: four end-to-end tests where rist_*_create is
called with a profile that differs from the URL ?profile= value;
both ends must converge on the URL profile for the test to pass.
- meson test --suite unit: 5/5 OK
- meson test --suite url-profile: 4/4 OK
- meson test (full): 48 OK, 11 Expected Fail, 0 unexpected.
NEWS updated under ABI/API and Bug Fixes.
The receiver-side flow bitrate fields (`bitrate`, `bitrate_retries`,
`bitrate_rejected`, `bitrate_ts_nulls`) are only updated when a packet
arrives on the corresponding path, because the bw->bitrate field is
recomputed inside rist_calculate_flow_bitrate() and that helper is only
called from the ingest path. As soon as the traffic on a counter stops
(e.g. retries cease), the matching `bitrate_*` field stays frozen at
the last value it was assigned while traffic was flowing.
Add a small rist_refresh_flow_bitrate() helper that recomputes
bw->bitrate with zero new bytes, mirroring what
rist_calculate_bitrate(0, ...) already does for the sender peer stats
path. The helper deliberately skips the per-flow inter-packet-spacing
bookkeeping that lives in rist_calculate_flow_bitrate(), so calling it
from the stats tick does not perturb the IPS counters.
Call the new helper for all four flow bitrate counters at the start of
the JSON/binary stats emission in rist_receiver_flow_statistics(), so
each counter naturally decays to zero when its traffic stops.
PROMETHEUS_COUNTER_PRINT_R wrote the per-sample timestamp using
"%"PRIu64"\n" with no leading separator, so when single_stat_point
is false (the multiple_metric_datapoints=true setup), every counter
row came out as
rist_client_flow_received_packets_total{...} 62001780601685
i.e. the value and the timestamp glued together. The sibling
PROMETHEUS_GAUGE_PRINT_R macro uses " %"PRIu64"\n" and emits the
correct "<value> <timestamp>" form. OpenMetrics requires the
timestamp to be space-separated from the value, so a scraper
reading these lines either rejects them or stores one large bogus
number.
Add the missing space. Only the multiple-datapoint branch is
affected; the single_stat_point branch omits the timestamp
entirely and is unchanged.
The Prometheus exporter publishes flow-level aggregates for receivers
(rist_client_flow_*) and per-peer breakdowns for senders
(rist_sender_peer_*) but no per-peer view on the receive side, even
though that data is already populated in rist_stats_receiver_flow.peers[].
Add the receiver-side counterpart as rist_receiver_peer_* with labels
{peer_id, flow_id, receiver_id}, mirroring the sender_peer schema:
rist_receiver_peer_bandwidth_bps gauge
rist_receiver_peer_avg_bandwidth_bps gauge
rist_receiver_peer_received_data_packets counter
rist_receiver_peer_received_bytes counter
rist_receiver_peer_received_rtcp_packets counter
rist_receiver_peer_sent_rtcp_packets counter
rist_receiver_peer_rtt_seconds gauge
rist_receiver_peer_avg_rtt_seconds gauge
Entries auto-register on first sight of a (receiver_id, flow_id,
peer_id) tuple, so callers do not need to wire peer_ids in at peer
creation time. Re-uses the existing stale-entry sweep (15s) and the
same realloc/abort-on-OOM idiom as sender_peers.
No public API change.
Public header <librist/transport.h> (0.2.18-rc1) typedefs ssize_t
on Windows under an _SSIZE_T_DEFINED guard. common/attributes.h
defines the same type without a guard, so any TU that includes
both pulls in a duplicate definition and trips MSVC C4142
(benign redefinition). Wrap each of the three Windows code paths
here in the same _SSIZE_T_DEFINED guard so the two headers are
order-independent. Closes the warning reported in #216.
The main librist library target already picks up the compat/msvc
<stdatomic.h> shim when MSVC's system header requires
/experimental:c11atomics, but the srp_unit test target compiles
random.c (and friends) without that dependency and so hits
'C atomic support is not enabled' on plain MSVC builds. Mirror
the library target's deps so the test follows the same code path.
Closes the second build error in #216.
MSVC has no <sched.h> and pulls calloc's prototype in via <stdlib.h>
(transitively, through <cmocka.h>), so the musl-specific shim is
only needed on POSIX targets. Closes the first build error in #216.
Bump project version to 0.2.18, API to 4.10.0, ABI to 13:0:9
(soversion 4 unchanged, binary-compatible with 0.2.15/0.2.16/0.2.17).
Headlining the release:
- srp-compat=legacy URL opt-in restores pre-0.2.16 interop with the
RFC 5054 PAD-compliant exchange landed in 0.2.16.
- Windows XP support restored: rist_transport_poll() uses select()
via the existing poll_win.c shim instead of WSAPoll() (Vista+).
- rist_peer_recv() accepts both numeric EAGAIN values (11 and 35) so
custom transport_ops backends in cross-compile environments work.
- <librist/transport.h> now defines ssize_t on Windows (#214).
- CI: srp_unit_test now runs via a cmocka wrap subproject, closing
the silent-skip that hid the SRP fixture drift reported in #215.
- Replace the placeholder "No source or binary changes" ABI/API entry
with the actual change: RIST_PEER_CONFIG_VERSION 2 -> 3 plus the
one new field (srp_compat_legacy). Existing zero-initialised
peer configs continue to work; the new field defaults to 0.
- Promote the SRP interop note to its own section and document the
new srp-compat=legacy URL option together with the advisory hint
emitted on M1/M2 failure.
- Mention the four new end-to-end SRP unit tests under CI / Test
Coverage alongside the existing fixture-regeneration entry.
Adds srp_run_exchange() — a self-contained authenticator+client
handshake on the 2048-bit NG_DEFAULT group using only the public SRP
API, then drives it through four scenarios:
1. Both ends in PAD mode (default) -> verify_m1 == 0
2. Both ends in legacy unpadded mode -> verify_m1 == 0
3. Authenticator PAD, client legacy -> verify_m1 != 0
4. Authenticator legacy, client PAD -> verify_m1 != 0
Cases 3 and 4 are the "operator forgot to set srp-compat on one side"
configurations. They MUST fail at M1 — otherwise the bypass would be
silently leaking through. The tests deliberately do NOT assert on the
specific cross-mode failure mode, because the SRP-6a identity
(A * v^u)^b = (B - k*g^x)^(a + u*x)
only holds when both peers use the same k, so no purely-local check
can produce a deterministic cross-mode M1 to compare against.
Also propagates the new legacy_pad parameter through every existing
ctx_create call site so the deterministic fixture tests continue to
exercise the spec-compliant path.
0.2.16 made TLS-SRP RFC 5054 / TR-06-2 §6.4 compliant by adding the
PAD operation to the u and k hash inputs. That change breaks
SRP-authenticated handshakes against peers still running 0.2.15 or
earlier. The recommended fix is to upgrade both ends, but
mixed-version deployments need a temporary escape hatch.
This change adds an opt-in legacy wire-format mode:
rist://user:pass@host:port?srp-compat=legacy
Must be set on BOTH ends. The library logs an explicit RIST_LOG_WARN
banner on startup whenever the legacy mode is active, marking it as
not TR-06-2 / RFC 5054 compliant. Default behaviour is unchanged.
Failure-side diagnostic
-----------------------
On SRP M1 (authenticator) or M2 (client) verification failure, the
library now emits a one-shot INFO hint pointing at this option as a
possible cause. The hint is advisory only — the SRP-6a transcript
binds the M1/M2 hashes to whichever wire mode the authenticator used
when constructing B, so there is no purely-local way to distinguish
"wrong password" from "peer is on the other wire format" once the
handshake has reached M1. Both possible causes are surfaced.
Plumbing
--------
- include/librist/peer.h: bump RIST_PEER_CONFIG_VERSION 2 -> 3, add
one trailing int (srp_compat_legacy); zero-initialised configs and
older clients keep the spec-compliant default.
- include/librist/urlparam.h: new RIST_URL_PARAM_SRP_COMPAT.
- src/rist-common.c: parse srp-compat=legacy | 1 into the new field
(anything else, including the explicit "auto", leaves PAD mode on).
store_peer_settings() also copies the flag into peer->config so
rist_enable_eap_srp_2(), which runs after the caller's config may
have been freed, can still see it.
- src/crypto/srp.{c,h}: new librist_crypto_srp_hash_2_bignum_unpadded()
helper (the pre-0.2.16 form) and a single srp_hash_uk() dispatch.
Both authenticator and client SRP contexts grow a bool legacy_pad
field set at ctx_create time; the four u/k hash sites pick padded
vs unpadded based on it.
- src/proto/eap.c: eapsrp_ctx grows srp_legacy_pad + a one-shot
srp_legacy_peer_warned latch; the flag is read from
peer->config.srp_compat_legacy when enable_eap_srp_2 builds the
ctx (both authenticator and authenticatee paths), propagated by
eap_clone_ctx, and passed into both SRP ctx_create calls.
Default behaviour is unchanged: peers without srp-compat=legacy keep
the PAD-compliant exchange.
Three additions under 0.2.18 in development:
- SRP interop note explaining why 0.2.16+ does not handshake
with 0.2.15 (PAD compliance is the reason; symmetric upgrade
required).
- Bug-fix entry for the new Windows ssize_t typedef (#214).
- CI/test coverage entry noting libcmocka-dev install and the
PAD-compliant test fixture regeneration (#215).
The recvfrom and sendto callbacks in struct rist_transport_ops
return ssize_t. On Windows that type is not a Win32 standard
type and is not provided by <winsock2.h>, so a downstream Windows
project that #includes <librist/transport.h> fails to compile
with "unknown type name 'ssize_t'".
Library-internal code worked because librist's private
common/attributes.h provides a typedef, but that header is not
installed. Add a public typedef inside the existing _WIN32 block,
guarded by _SSIZE_T_DEFINED to coexist with MSVC's CRT and other
public Windows headers that already conditionally define it.
Fixes#214.
Since 0.2.16 the SRP authenticator zero-pads the u and k hash inputs
to N-length per RFC 5054 §2.6 (and therefore TR-06-2). The fixtures
in srp_examples.c were captured against the pre-PAD code and have
failed deterministically since 0.2.16 with both gnutls, system
mbedtls 3.6, and bundled mbedtls — see #215.
A separate hardening change also enforced a 1024-bit minimum for
caller-supplied N in librist_crypto_srp_client_ctx_create(), which
caused test_srp_client_ctx_create (and three downstream tests) to
deref a NULL ctx and segfault on the deprecated 512-bit modulus.
Switch the deterministic setup to the 2048-bit RFC 5054 group
(NG_DEFAULT) and re-record every fixture from the current
PAD-compliant exchange. The DEBUG_USE_EXAMPLE_CONSTANTS hooks in
srp.c still pin a, b, and salt, so the run remains deterministic.
test_srp_client_ctx_create now exercises both the default_ng=true
path and a custom-N path with NG_2048, asserts non-NULL on every
create, and uses the standard sizeof()/mbedtls_mpi_size() byte
widths instead of the obsolete 64-byte buffers.
Fixes#215 (segfaults + hash mismatches).
srp_unit_test had been silently skipping in test-ubuntu because the
runner image (libplacebo-ubuntu-jammy) ships without libcmocka-dev
and does not give the CI user write access to /var/lib/apt/lists, so
an in-script apt-get install cannot fix it. The silent skip is how
the SRP fixture drift reported in #215 reached a release tag.
Switch the meson detection from cc.find_library('cmocka', required:
false) to dependency('cmocka', required: false, fallback:
['cmocka', 'cmocka_dep']) and add subprojects/cmocka.wrap pointing
at WrapDB cmocka_1.1.8-1. When the runner image has cmocka the
system copy is used; otherwise meson builds it as a subproject.
Existing .gitignore rules (/subprojects/* with !*.wrap) keep the
fetched source out of the tree.
srp_unit's link line now picks up bcrypt on Windows because
random.c calls BCryptGenRandom; the main librist target was already
pulling it in via the top-level deps list, but srp_unit links
random.c directly.
Drop the broken apt-get line from the test-ubuntu script and add
retry: 2 to both test jobs to absorb the simple+multicast environmental
flake that surfaces on the shared runner regardless of branch.
The shared CI runner used by test-win64 cannot complete the
send/receive tests within meson's default 30s per-test timeout when
the binaries are executed under wine. Recent pipelines on master
have been showing 38-41 of 55 tests marked TIMEOUT (with 0-3 real
failures) while the same suite passes on test-ubuntu.
The logs show peer authentication completing, packets flowing, NACK
/ retransmit loops operating normally — there are no crashes,
asserts or hangs, only tests being killed at the 30s boundary
because the wine layer adds enough overhead per test that they
cannot reach their target packet count in time.
Mark test-win64 as allow_failure so wine throughput jitter does not
block unrelated MRs. The job still runs and its log is still
uploaded on failure, so genuine Windows-runtime regressions remain
visible — they just no longer flip the overall pipeline status.
A TODO is left in the file describing the two ways to drop
allow_failure: bump meson's per-test timeout multiplier for this
job, or move it to a faster runner.
By default ristsender couples the input flow to RIST peer state:
multicast input groups are bound but the IGMP join is deferred until
at least one RIST output peer has completed its handshake, and
incoming UDP data is dropped until peer_connected_count > 0. That
keeps a freshly-started sender from generating multicast traffic the
network has not yet asked for and from buffering data nobody will
ever read, which is the right behaviour for point-to-point RIST.
The peer-handshake gate is a poor fit for broadcast topologies where
the sender is meant to stream regardless of who is (or isn't)
listening — multicast output in simple/main/advanced profile, or
ristsender as a fan-out in front of non-RIST consumers.
Add --blind-send. When set:
- the multicast input socket performs udpsocket_join_mcast_group()
immediately instead of marking it mcast_deferred, and
- input_udp_recv() writes data to the RIST context unconditionally.
Default behaviour is unchanged when the option is absent.
When a custom transport_ops backend returns -1 from recvfrom() to signal
"no data ready", librist's poll loop relies on errno == EAGAIN to treat
the call as transient and silently retry. In most environments this is
straightforward, but cross-compilation setups can end up with the
EAGAIN macro resolving to a different numeric value than the one the
custom backend actually wrote into errno.
This has been observed concretely with VLC's WebAssembly build, where
librist is compiled by Emscripten as part of VLC's contrib system: the
WebTransport-backed transport_ops returns -1 with errno = EAGAIN (= 11
in musl/Emscripten), but librist's own translation unit ends up with
EAGAIN expanding to 35 due to header search paths from the host
toolchain leaking into the cross build. The result was the poll loop
mistaking the transient case for a hard error and spamming
"Receive failed: errno=11" until the RIST handshake gave up.
Recognise both canonical EAGAIN errno values (11 on Linux / Emscripten /
musl, 35 on macOS / *BSD / iOS) explicitly when ret == -1. On any
platform where EAGAIN resolves consistently the existing macro check
already returns first, so this addition is unreachable and behaviour is
unchanged. It only fires in the pathological cross-compile case where
the macro and the runtime errno disagree, and protects the same set of
errno values that POSIX recvfrom(2) is documented to use for the
non-blocking "no data" path.
The Windows path (_WIN32) is unaffected.
librist 0.2.17 introduced a pluggable transport abstraction
(rist_transport_set + companion rist_transport_poll wrapper) that on
Windows fell back to WSAPoll(). WSAPoll is a Vista+ symbol, so adding
it to the librist link surface dropped runtime support for Windows XP
on the rist_transport_poll() code path — a regression compared with
0.2.16, which only ever called select() on Windows (via the
public-domain poll() shim included from contrib/poll_win.c into
src/libevsocket.c since 2019).
Route rist_transport_poll() through that same select()-based shim
instead of WSAPoll() so the pluggable-transport layer preserves XP
compatibility. The fix mirrors libevsocket.c's pattern: include
contrib/poll_win.c as a translation-unit-private static function on
Windows, then call poll() unconditionally on both platforms.
Also expose <winsock2.h>'s struct pollfd declaration at the prototype
site (transport-private.h) so the prototype and definition agree even
when callers pin _WIN32_WINNT below 0x0600 (e.g. VLC's 3.0.x contrib
system, which targets legacy Windows XP). The redefinition is
header-only and pulls in zero Vista+ runtime symbols — only the
struct pollfd typedef visibility changes.
After the fix, mingw-w64 cross-built librist.dll has WSAPoll removed
from its WS2_32.dll import table (verified locally with `objdump -p`);
XP runtime support is preserved.
Reported by Steve Lhomme (@robUx4) during review of VLC merge request
videolan/vlc!9297.
mbedtls 3.6 uses clock_gettime(CLOCK_MONOTONIC) in platform_util.c
which requires iOS 10.0+. When VLC contribs build with
-Werror=partial-availability and a deployment target of iOS 9.0,
this becomes a hard error.
The runtime fallback already handles the case correctly:
clock_gettime returns -1 on older iOS and mbedtls_ms_time() falls
back to time(NULL) * 1000.
Suppress -Wunguarded-availability for the bundled mbedcrypto static
library only, consistent with the six other warning suppressions
already applied to the bundled build.
When ristsender's input URL is a multicast address, the IGMP join is
now deferred until the first RIST peer handshake completes. This
prevents receiving multicast traffic before the RIST output path is
ready, avoiding packet loss during the initial connection setup.
The socket is opened and bound immediately (so the port is reserved),
but the multicast group join happens in the connection_status_callback
when RIST_CONNECTION_ESTABLISHED fires. Unicast inputs are unaffected.
Closes#85
Add ?local-port=N URL parameter to bind caller (non-listening) peers
to a specific local UDP port instead of using an ephemeral port.
When miface is also set, the specified port is included in the
interface bind. When only local-port is set, the socket binds to
INADDR_ANY (or in6addr_any) on the given port.
If the bind fails, a warning is logged (with WSAGetLastError on
Windows) and the peer falls back to ephemeral port behavior.
Closes#158
Add byte-level counters alongside the existing packet counters:
Sender:
- sent_bytes: total bytes sent (payload + overhead)
- retransmitted_bytes: total bytes retransmitted via ARQ
Receiver:
- received_bytes: per-peer and per-flow data bytes received
New fields appended to rist_stats_sender_peer,
rist_stats_receiver_peer, and rist_stats_receiver_flow structs.
JSON stats include sent_bytes, retransmitted_bytes, and
received_bytes fields.
RIST_STATS_VERSION bumped from 1 to 2.
RIST_STATS_JSON_SCHEMA_VERSION bumped from 2 to 3.
Closes#174
Add configurable multicast TTL via ?ttl=N URL parameter (1-255) and
IGMPv3 Source-Specific Multicast via ?source=IP URL parameter.
IPv6 multicast join was previously unsupported; implement it via
MCAST_JOIN_GROUP and IPV6_JOIN_GROUP. SSM support uses
IP_ADD_SOURCE_MEMBERSHIP, guarded with #ifdef for portability.
New public API:
- udpsocket_set_mcast_ttl(): set IPv4 TTL or IPv6 hop limit
- udpsocket_open_bind_mcast(): open+bind with TTL and SSM source
- udpsocket_join_mcast_group(): now public, with IPv6 and SSM support
All new error paths use WSAGetLastError() on Windows instead of
strerror(errno) for correct Winsock error reporting.
New fields in rist_peer_config and rist_udp_config: multicast_ttl,
multicast_source. RIST_PEER_CONFIG_VERSION bumped from 1 to 2.
Closes#112
Gate reflector logic behind an explicit URL parameter so it is not
active on every receiver listener by default. Add 'reflector' field
to rist_peer_config and RIST_URL_PARAM_REFLECTOR constant.
Document trade-offs vs rist2rist in the API header, NEWS, and URL
help so users understand the limitations:
- No per-subscriber retry buffer (relies on publisher's buffer)
- Retransmissions fan out to ALL subscribers (bandwidth x N)
- Recovery RTT ~ 2x a direct connection
- No per-subscriber congestion control or statistics
Best suited for low subscriber counts with clean last-mile links.
For high fan-out or lossy last-mile, use rist2rist instead.
Add transparent reflector logic to rist_peer_recv that forwards raw
packets between publisher (sender) and subscriber (receiver) children
before decryption. Publisher/subscriber roles are detected via buffer
negotiation. Buffer size is proxied from publisher to subscribers.
Log levels for harmless cross-role packets (NACK on receiver, data on
sender) lowered from ERROR/WARN to DEBUG.
Includes test_reflector.c with 3-phase verification: basic reflection,
NACK/retransmission through reflector, and RTT/buffer convergence.