Add TCPStreamer/TCPStreamerClient (TCP transport behind the UDPSServerI/UDPSClientI seam), an optional single in-process TCPStreamService serving many streams over worker threads, multiplexed SUBSCRIBE (comma list, stream index in fragmentIdx), and TCPStreamJoin, which merges several streams into one sample-and-hold stream paced by a master. StreamHub takes a comma list of streams over one connection. Includes GTests, E2E scenarios s58-s73 and docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13 KiB
TCPStreamer / TCPStreamerClient — design
Date: 2026-11-05
Goal
A pair of MARTe2 DataSources, TCPStreamer (output) and TCPStreamerClient
(input), equivalent to UDPStreamer / UDPStreamerClient but carrying data
over TCP. Plus a feasibility study (section 7) of a single in-process service
that streams data from several DataSources over multiple threads.
Decisions (agreed)
- Wire format: the existing UDPS framing (17-byte header + CONFIG/DATA
payload) over the TCP byte stream,
totalFragmentsalways 1. No new protocol; no fragmentation or reassembly. - Slow consumers: drop for the slow client only. Bounded per-client queue
(
ClientQueueDepthframes); a client whose queue is full loses frames (counted per client, logged at most once per second); a client stalled longer thanClientStallTimeoutis disconnected. Other clients and the RT thread are never affected. - Service: an in-process MARTe2
Interfaceobject (likeDebugService), not a separate daemon. Investigated here, not built in v1.
Decisions made without asking (flagged, easy to reverse):
- v1 scope is the two DataSources plus their transport classes. Consumers
other than
TCPStreamerClient(StreamHub, Go decoder, C client, UDPScope) keep speaking UDP; adding a TCP ingest to them is a follow-up that needs no protocol change because of decision 1. - The service is a design study only (section 7). v1 is built so the service can later reuse the same transport classes.
1. Architecture: pluggable transport, shared serialisation
UDPStreamer already isolates its transport behind seven calls on UDPSServer
(Initialise, Start, Stop, ServiceClients, HasClients, SendConfig,
SendData); UDPStreamerClient uses UDPSClient through four
(Initialise, SetListener, Start, Stop). Everything else in those files
(signal parsing, quantisation, Strict/Accumulate/Decimate, double buffering,
drop accounting, DATA decode) is transport-independent and ~1000 lines.
Forking the files would duplicate that. Instead:
- Add two small abstract interfaces next to the existing classes in
Source/Components/Interfaces/UDPStream/:UDPSServerI— the seven server calls above.UDPSClientI— the four client calls above.
UDPSServer/UDPSClientimplement them (signature-only change; behaviour untouched).UDPStreamer/UDPStreamerClienthold a pointer to the interface, created by a protected virtual factory (CreateServer()/CreateClient()) that defaults to the UDP implementation.TCPStreamer : UDPStreamerandTCPStreamerClient : UDPStreamerClientoverride the factory, register as their own MARTe2 classes, and overrideInitialiseonly to parse the TCP-specific keys and reject the UDP-only ones.- New transport classes
TCPSServerandTCPSClientin the sameInterfaces/UDPStreamlibrary.
Risk: this touches working RT code. Mitigation: the change to the UDP classes is mechanical (pointer instead of member), and the existing GTests and the E2E suite cover them; run both before and after.
2. TCPSServer
Single-threaded, driven from the owner's Execute() loop exactly like
UDPSServer; no thread per client.
- Listening socket,
SO_REUSEADDR, non-blocking accept inServiceClients(). - Per client: non-blocking socket,
TCP_NODELAY, ring ofClientQueueDepthframes (pre-allocated atStart(), each sizedUDPS_HEADER_SIZE + MaxPayloadSize), partial-write cursor, drop counter, last-progress timestamp. - On accept: enqueue the cached CONFIG first, so a client always sees CONFIG
before any DATA.
SendConfig()while clients are connected enqueues the new CONFIG on every client (same as UDP) and refreshes the cache. - CONFIG never goes through the DATA ring: each client has a separate pending-CONFIG slot that is always flushed before any queued DATA, so CONFIG can't be dropped and a CONFIG change can't be reordered behind stale DATA.
SendData(): builds one frame, copies it into each client's DATA ring (when full, the oldest queued DATA frame is discarded and counted, so a recovering client resumes near live), then attempts a non-blocking flush on each client.ServiceClients()also flushes, so a client that became writable drains without waiting for the next DATA.- Stall: no bytes written for
ClientStallTimeoutwhile the queue is non-empty → close and evict. Peer close / error on write → evict. Stop()closes everything and frees buffers.- No STL, no OS mutexes; allocation only in
Start().
Config keys (read by TCPSServer::Initialise from the datasource node):
| Key | Default | Meaning |
|---|---|---|
Port |
44500 | TCP listen port |
MaxPayloadSize |
60000 | Max payload bytes per frame; also bounds Accumulate batches |
MaxClients |
16 | Simultaneous clients; extra connections refused |
ClientQueueDepth |
8 | Frames queued per client before dropping |
ClientStallTimeout |
5 | Seconds without progress before disconnect; 0 disables |
MaxPayloadSize keeps its UDP meaning only in that Accumulate sizes batches
from it; with no MTU to respect the default is raised, capped at
UDPS_CLIENT_MAX_PACKET_BYTES (1 MiB).
3. TCPStreamer
Same signal and publishing configuration as UDPStreamer (PublishingMode,
MinRefreshRate, MaxBatchSize, per-signal Unit/Range*/QuantizedType/
TimeMode/TimeSignal/SamplingRate, CPUMask, StackSize). Rejected as
invalid: MulticastGroup, Interface, DataPort. Class name TCPStreamer.
The CONFIG payload, including the hrtFrequency trailer, is unchanged.
Accessors (GetPort, IsClientConnected, GetDroppedPublications) are
inherited; add GetClientDrops() for the per-client queue drops.
4. TCPSClient and TCPStreamerClient
TCPSClient implements UDPSClientI and delivers via the existing
UDPSClientListener (OnUDPSConfig, OnUDPSData, OnUDPSConnected,
OnUDPSDisconnected), so UDPStreamerClient's decode path is reused as is.
- Own thread (
SingleThreadService). Connect with a bounded timeout; on successOnUDPSConnected. - Frame reader: read the 17-byte header, check magic and
payloadBytes <= UDPS_CLIENT_MAX_PACKET_BYTES, read the payload, dispatch by type. Reads are non-blocking with a shortpoll, soStop()is prompt and a partial frame survives across iterations. - Bad magic or oversize length → close and reconnect (stream is unrecoverable once out of sync).
- Peer close, read error, or
SilenceTimeoutwith no bytes → close,OnUDPSDisconnected, retry afterReconnectDelay. - No CONNECT/ACK/keepalive traffic: the connection itself is the session, and the server sends CONFIG on accept.
- Gaps: TCP is lossless but
TCPSServermay drop frames;GetLastDataGap()is computed from thecounterfield exactly as for UDP, so timestamp reconstruction keeps working. Stale-packet gating is a no-op (ordered stream).
Keys: ServerAddress (default 127.0.0.1), Port, SilenceTimeout,
ReconnectDelay, CPUMask, StackSize. TCPStreamerClient rejects the
multicast keys.
5. Error handling
- RT thread path unchanged: spinlock + memcpy in
Synchronise(), nothing network-related. - All socket failures are per client; a failing client never fails
SendData()for the others, andSendData()returns true when at least one client accepted the frame or none are connected. - Drops and stalls are counted and reported rate-limited (1/s), like
ReportDroppedPublications.
6. Testing
- GTest for
TCPSServer+TCPSClientover loopback: CONFIG-before-DATA on late join; multiple clients; slow client drops while a fast one receives all frames; stall disconnect; reconnect after server restart; garbage/oversize frame rejection; partial-frame reads. - Datasource tests under
Test/Components/DataSources/TCPStreamer{,Client}mirroring the UDP ones (Strict, Accumulate, Decimate, quantised signals, round trip streamer → client). - Regression: existing UDP GTests and the E2E suite before and after the interface refactor.
- E2E scenario with a TCP chain is deferred with the consumer-side follow-up.
7. Single service, many DataSources, many threads (implemented as TCPStreamService; feasibility notes below)
Verdict: feasible, moderate effort, and the v1 design is the right stepping stone.
Shape: TCPStreamService as a MARTe2 Interface singleton (registered like
DebugService). Config declares the port and the worker count. TCPStreamer
instances gain Service = <name> and, instead of owning a TCPSServer, call
service->Publish(streamId, frame) and service->PublishConfig(...).
Threading:
- One accept thread. N worker threads (config
Workers), each owning a shard of client connections and running the same flush loop asTCPSServer(poll-driven, non-blocking). Clients are assigned least-loaded at accept. - Producers (the per-DataSource sender threads from
UDPStreamer::Execute, not the RT threads) publish into a per-stream buffer. Each worker reads those buffers; handoff by a per-streamFastPollingMutexSem-guarded latest-frame slot plus a monotonically increasing sequence number, so a worker that falls behind skips to the newest frame rather than queueing — consistent with the drop policy. This keeps RT paths free of cross-thread contention beyond what exists today. - Per-client queues stay private to the owning worker, so no locking on the send path.
Protocol gap: the UDPS header has no stream identifier. Two ways to carry several DataSources on one connection:
- One port per stream — no protocol change, but then there is no multiplexing and the service only saves threads.
- Add a stream id — a new packet type
UDPS_TYPE_SUBSCRIBE(client → server, lists stream names) and a 2-byte stream-id prefix on DATA/CONFIG payloads of multiplexed connections, negotiated by the SUBSCRIBE exchange so legacy single-stream clients are unaffected. This is a protocol change and must be mirrored across the C++, Go, C and JS parsers (seeCLAUDE.md).
Recommendation: build v1 now; if the service is wanted, do it as a separate
spec choosing option 2, reusing TCPSServer's per-client queue/flush code as
the worker's inner loop. A separate-process daemon was considered and
rejected: it overlaps StreamHub, adds IPC to the RT path and a deployment
unit, with the only gain being survival across application restarts.
Open for that follow-up spec: stream naming and collision rules across DataSources, per-stream vs per-client queue accounting, and whether subscribe-time CONFIG filtering is needed.
8. As implemented (deviations from sections 2-6)
Decisions taken while building v1; where they differ from the text above, this section wins.
- Per-client queue:
ClientQueueDepth + 2slots (up todepthDATA, one in-flight CONFIG, one pending CONFIG), grown lazily on the sender thread rather than pre-allocated atStart(). A new CONFIG discards queued frames that are not in flight (stale DATA) instead of being "flushed before DATA". DATA with a full queue drops the oldest non-in-flight DATA frame, or the incoming frame if only the in-flight one is queued. - Extra key
SendBufferSize(SO_SNDBUF for client sockets, 0 = OS default). Port = 0binds an ephemeral port;TCPStreamer::GetBoundPort()/TCPSServer::GetPort()return the bound port once started.SendData()returns false only for frames larger than 1 MiB (TCPS_MAX_FRAME_PAYLOAD). Frames may exceedMaxPayloadSize; it only sizes Accumulate batches.- Extra accessors:
GetClientCount(),GetClientDrops()(uint64),GetStallEvictions(). - UDP-only keys rejected by
TCPStreamer:MulticastGroup,Interface,DataPort,ClientTimeout. ByTCPStreamerClient:MulticastGroup,DataPort,Interface,KeepAliveInterval. TCPSClientkeys:ServerAddr,Port,SilenceTimeout(default 0 = disabled, TCP reports dead peers itself),ReconnectDelay(default 2 s),CPUMask,StackSize.TCPStreamerClienttakesServerAddressas the UDP one does.- No client-side gap or stale-packet handling: the client delivers frames exactly as received. A bad magic, oversize length or fragmented frame drops the connection and counts a protocol error.
- Tests:
Test/GTest/TCPSServerGTest.cpp,TCPSClientGTest.cpp,TCPStreamerGTest.cpp(shared helpers inTCPTestUtil.h), built intoMainGTest.ex. Not yet covered: Accumulate/Decimate/quantised signals over TCP (serialisation is inherited unchanged fromUDPStreamer), and the E2E suite.
9. Known gap
- Default
MaxPayloadSize(60000) is a first guess; confirm against Accumulate batch latency during testing.