feat(tcp): TCPStreamer DataSources, in-process TCPStreamService, stream multiplexing and TCPStreamJoin

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>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-06 13:41:27 +01:00
1 parent 5562877c99
commit 50d85363a3
79 files changed
+12348 -138

No files matched your search

@@ -0,0 +1,249 @@
# 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)
1. **Wire format**: the existing UDPS framing (17-byte header + CONFIG/DATA
payload) over the TCP byte stream, `totalFragments` always 1. No new
protocol; no fragmentation or reassembly.
2. **Slow consumers**: drop for the slow client only. Bounded per-client queue
(`ClientQueueDepth` frames); a client whose queue is full loses frames
(counted per client, logged at most once per second); a client stalled
longer than `ClientStallTimeout` is disconnected. Other clients and the RT
thread are never affected.
3. **Service**: an in-process MARTe2 `Interface` object (like `DebugService`),
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` / `UDPSClient` implement them (signature-only change; behaviour
untouched).
- `UDPStreamer` / `UDPStreamerClient` hold a pointer to the interface, created
by a protected virtual factory (`CreateServer()` / `CreateClient()`) that
defaults to the UDP implementation.
- `TCPStreamer : UDPStreamer` and `TCPStreamerClient : UDPStreamerClient`
override the factory, register as their own MARTe2 classes, and override
`Initialise` only to parse the TCP-specific keys and reject the UDP-only ones.
- New transport classes `TCPSServer` and `TCPSClient` in the same
`Interfaces/UDPStream` library.
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 in `ServiceClients()`.
- Per client: non-blocking socket, `TCP_NODELAY`, ring of `ClientQueueDepth`
frames (pre-allocated at `Start()`, each sized `UDPS_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 `ClientStallTimeout` while 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
success `OnUDPSConnected`.
- 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 short `poll`, so `Stop()` 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 `SilenceTimeout` with no bytes → close,
`OnUDPSDisconnected`, retry after `ReconnectDelay`.
- No CONNECT/ACK/keepalive traffic: the connection itself is the session, and
the server sends CONFIG on accept.
- Gaps: TCP is lossless but `TCPSServer` may drop frames; `GetLastDataGap()`
is computed from the `counter` field 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, and `SendData()` 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` + `TCPSClient` over 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 as `TCPSServer`
(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-stream `FastPollingMutexSem`-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:
1. **One port per stream** — no protocol change, but then there is no
multiplexing and the service only saves threads.
2. **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 (see `CLAUDE.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 + 2` slots (up to `depth` DATA, one
in-flight CONFIG, one pending CONFIG), grown lazily on the sender thread
rather than pre-allocated at `Start()`. 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 = 0` binds 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 exceed `MaxPayloadSize`; it only
sizes Accumulate batches.
- Extra accessors: `GetClientCount()`, `GetClientDrops()` (uint64),
`GetStallEvictions()`.
- UDP-only keys rejected by `TCPStreamer`: `MulticastGroup`, `Interface`,
`DataPort`, `ClientTimeout`. By `TCPStreamerClient`: `MulticastGroup`,
`DataPort`, `Interface`, `KeepAliveInterval`.
- `TCPSClient` keys: `ServerAddr`, `Port`, `SilenceTimeout` (default 0 =
disabled, TCP reports dead peers itself), `ReconnectDelay` (default 2 s),
`CPUMask`, `StackSize`. `TCPStreamerClient` takes `ServerAddress` as 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 in `TCPTestUtil.h`), built into
`MainGTest.ex`. Not yet covered: Accumulate/Decimate/quantised signals over
TCP (serialisation is inherited unchanged from `UDPStreamer`), and the E2E
suite.
## 9. Known gap
- Default `MaxPayloadSize` (60000) is a first guess; confirm against
Accumulate batch latency during testing.
@@ -0,0 +1,86 @@
# TCPStreamService: joining streams (multiplex + merge)
Date: 2026-11-06. Extends `2026-11-05-tcp-streamer-design.md` section 7.
## Goal
Let one consumer take several service streams through a single, cheaper path.
Two independent mechanisms were requested ("both"):
1. **Multiplexed connection**: one TCP socket carries N streams, each keeping its
own CONFIG, rate and timeline.
2. **Merged stream**: the service builds one new stream whose CONFIG holds the
signals of several source streams, emitted at the pace of a master source
with sample-and-hold of the others.
## 1. Multiplexed connection
### Wire
- `SUBSCRIBE` (type 5) payload becomes a comma separated list of stream names
(`"fast,slow"`). One name is the old behaviour. Names may not contain `,`.
Limits: 64 names, 4096 payload bytes, no duplicates.
- The index of a stream is its position in the list.
- Every server-to-client frame carries the stream index in `fragmentIdx`
(`totalFragments` stays 1). Single-name connections always send 0, so
existing clients are unchanged.
- Any unknown name closes the whole connection (the client retries), exactly
like the single-name case.
### Server
- `TCPSConn` holds up to 64 stream attachments instead of one.
- A stream's frame is built once; `Push` copies it into each subscriber's queue
and stamps that connection's own index into `fragmentIdx`.
- `TCPSFrameQueue` accounts per stream: `ClientQueueDepth` DATA frames *per
stream*, drop-oldest of the same stream only, and a CONFIG only discards the
stale frames of its own stream. A fast stream can therefore never evict a
slow stream's frames.
- `Flush` batches queued frames into one `writev()` (up to 16 frames), which is
the optimisation for the many-small-frames case; it also applies to
single-stream connections.
- If a stream unregisters, every connection attached to it is closed.
### Client
- `TCPSClient` accepts `StreamName = "a,b,c"`. It demultiplexes by
`fragmentIdx` to one listener per stream and tracks the counter gap per
stream.
- `TCPSClient::GetStream(i)` returns a `UDPSClientI` facade for stream `i`.
Facades reference-count `Start()`/`Stop()` of the shared connection, so
existing consumers (StreamHub sessions) work unchanged.
- `TCPStreamer` / `TCPStreamerClient` DataSources are single-stream: a comma in
`StreamName` is rejected there.
### StreamHub
- A source with `Transport="tcp"` and `StreamName="a,b,c"` creates one session
per stream, all sharing one `TCPSClient`. Session labels are
`<Label>_<stream>` (just the stream name when no label is given).
## 2. Merged stream (`TCPStreamJoin`)
```
+Join = { Class = TCPStreamJoin Service = "Svc" StreamName = "all"
Sources = "fast,slow" Master = "fast" }
```
- Root-level object, like the service. It taps the source streams
(`TCPSStreamTapI`), and registers its own output stream with the service once
every source is registered. Losing a source unregisters the output.
- CONFIG: concatenated descriptors, names `<stream>_<signal>` (<= 63 chars),
`timeSignalIdx` rebased, publish mode Strict, common `hrtFrequency` (sources
with different frequencies are refused).
- DATA (one per master frame): `[master timestamp][source0 body][source1 body]...`
where a body is that source's last DATA payload without its 8-byte
timestamp. Nothing is emitted until every source has delivered one DATA.
- Only Strict and Decimate sources can be merged (Accumulate batches have
incompatible lengths) and a source body whose size changes without a CONFIG
change is ignored. Held values of a slower source are stamped with the
master's timestamp unless that source uses a time signal.
- Locking: join mutex, then the output stream mutex, then connection `qMutex`.
Notifications from the service happen outside its topology lock.
- No extra thread: the merged frame is built on the master producer's thread.
## Testing
- GTest: queue per-stream accounting + writev partial writes; service mux
(index tagging, fairness, unknown name, duplicate name, comma rejection);
client demux/gaps/facades; join (config merge, sample-and-hold, refusal
cases).
- E2E: a chain scenario with a multiplexed hub source (`tcp_service` + comma
list) and one with a merged stream.