Files
Martino FerrariandClaude Opus 4.6 50d85363a3 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>
2026-11-06 13:41:27 +01:00

205 lines
9.9 KiB
Markdown

# TCPStreamer / TCPStreamerClient
TCP counterparts of `UDPStreamer` / `UDPStreamerClient`. Same signals,
quantisation, time modes, publishing modes and UDPS wire format; only the
transport differs. Use them when you need lossless, ordered delivery to a few
consumers and do not need multicast.
## How it works
- `TCPStreamer` derives from `UDPStreamer`; `TCPStreamerClient` from
`UDPStreamerClient`. They install a `TCPSServer` / `TCPSClient` through the
`UDPSServerI` / `UDPSClientI` interfaces (`UDPSTransportI.h`), so the RT path
(`Synchronise()` = spinlock + memcpy) and serialisation are shared.
- Frames are UDPS frames (17-byte header + payload) on the byte stream, with
`totalFragments` always 1. The server sends CONFIG on accept; there is no
CONNECT / ACK / keepalive.
- Each client has its own bounded queue (`ClientQueueDepth` DATA frames plus
CONFIG slots). If a client cannot keep up, only its own oldest DATA frames
are dropped (counted by `GetClientDrops()`, logged at most once per second).
A client with no progress for `ClientStallTimeout` seconds is disconnected.
The RT thread and other clients are never affected.
- A new CONFIG (reconfiguration) discards the client's stale queued DATA.
- The client reconnects automatically after any disconnect or protocol error
(bad magic, oversize length, fragmented frame).
## TCPStreamer configuration
All `UDPStreamer` keys apply (see `UDPStreamer.md`) except the UDP-only
`MulticastGroup`, `Interface`, `DataPort`, `ClientTimeout`, which are rejected.
| Key | Default | Meaning |
|---|---|---|
| `Port` | 44500 | TCP listen port (0 = ephemeral, see `GetBoundPort()`) |
| `MaxPayloadSize` | 60000 | Frame budget; sizes Accumulate batches. Frames up to 1 MiB are sent |
| `MaxClients` | 16 | Simultaneous clients (1..1024); extra connections are closed |
| `ClientQueueDepth` | 8 | DATA frames queued per client (1..4096) |
| `ClientStallTimeout` | 5 | Seconds without progress before disconnect; 0 disables |
| `SendBufferSize` | 0 | `SO_SNDBUF` for client sockets; 0 keeps the OS default |
Accessors: `GetBoundPort()`, `GetClientCount()`, `GetClientDrops()`,
`GetStallEvictions()`, plus the inherited `GetDroppedPublications()`.
```
+Streamer = {
Class = TCPStreamer
Port = 44500
ClientQueueDepth = 16
Signals = {
Time = { Type = uint64 }
Value = { Type = float32 }
}
}
```
## TCPStreamerClient configuration
| Key | Default | Meaning |
|---|---|---|
| `ServerAddress` | 127.0.0.1 | Server IPv4 address or host name |
| `Port` | 44500 | Server TCP port |
| `SilenceTimeout` | 0 | Seconds without any byte before reconnecting; 0 disables |
| `ReconnectDelay` | 2 | Seconds between connection attempts |
| `CPUMask`, `StackSize` | default | Receiver thread |
`MulticastGroup`, `DataPort`, `Interface`, `KeepAliveInterval` are rejected.
Accessors: `IsConnected()`, `GetConnectionCount()`, `GetProtocolErrors()`.
The client delivers frames as received; it does no gap or stale-packet
handling.
## Service mode: one in-process server, many streams
`TCPStreamService` is a root-level object that owns the listening socket and a
pool of worker threads. Any number of `TCPStreamer` DataSources (each on its own
real-time thread) publish into it; clients choose a stream by name.
```
+Svc = { Class = TCPStreamService Port = 44500 Workers = 2 }
...
+Fast = { Class = TCPStreamer Service = "Svc" StreamName = "fast" Signals = { ... } }
+Slow = { Class = TCPStreamer Service = "Svc" StreamName = "slow" Signals = { ... } }
+Rx = { Class = TCPStreamerClient Port = 44500 StreamName = "fast" Signals = { ... } }
```
- Wire protocol: plain UDPS over TCP plus one client-to-server frame,
`SUBSCRIBE` (type 5, payload = stream name, 1..255 bytes, or a comma separated
list, see "Joining streams"). The service answers with that stream's CONFIG
and then its DATA frames. A connection that does not subscribe within `HandshakeTimeout`, or
names an unknown stream, is closed (the client reconnects).
- Threads: one acceptor plus `Workers` I/O threads; each connection lives on one
worker. Producers only copy frames into per-connection bounded queues
(drop-oldest) and wake the worker through a self-pipe, so a slow client never
delays a producer or another client. Lock order: topology, stream, connection.
- A stream registers at `Start()` (looked up with
`ObjectRegistryDatabase::Find(Service)`) and unregisters on stop.
- `StreamName` defaults to the DataSource name (max 255 chars). In service mode
`Port`, `MaxClients`, `ClientQueueDepth`, `ClientStallTimeout` and
`SendBufferSize` belong to the service and are rejected on the streamer.
- `TCPStreamerClient` / `TCPSClient` send SUBSCRIBE after every (re)connect when
`StreamName` is set. A plain `TCPSServer` ignores it, so the same client works
against both.
`TCPStreamService` keys:
| Key | Default | Meaning |
|---|---|---|
| `Port` | 44500 | TCP listen port |
| `Workers` | 2 | I/O threads (1..32) |
| `MaxClients` | 64 | Total simultaneous clients (1..1024) |
| `MaxStreams` | 32 | Streams that can register (1..256) |
| `ClientQueueDepth` | 8 | DATA frames queued per client (1..4096) |
| `ClientStallTimeout` | 5 | Seconds without progress before eviction; 0 disables |
| `HandshakeTimeout` | 5 | Seconds a new connection has to SUBSCRIBE (>= 1) |
| `SendBufferSize` | 0 | `SO_SNDBUF`; 0 keeps the OS default |
| `CPUMask`, `StackSize` | default | Worker threads |
## Joining streams
Two independent ways to take several service streams through one cheaper path.
### Multiplexed connection
`SUBSCRIBE` may carry a comma separated list (`"fast,slow"`; at most 64 names,
4096 payload bytes, no duplicates, no `,` in a stream name). One socket then
carries every listed stream, each keeping its own CONFIG, rate and timeline:
- The index of a stream is its position in the list; every server frame carries
it in `fragmentIdx` (`totalFragments` stays 1). A single name is index 0, so
existing clients are unchanged. One unknown name closes the connection.
- Queues are accounted per stream: `ClientQueueDepth` DATA frames *per stream*,
drop-oldest within the same stream only, so a fast stream never evicts a slow
stream's frames. Queued frames are written with one `sendmsg` (up to 16).
- `TCPSClient` accepts `StreamName = "a,b,c"`, demultiplexes by `fragmentIdx`
(`SetStreamListener(i, ...)`) and tracks the counter gap per stream.
`GetStream(i)` returns a `UDPSClientI` facade whose `Start()`/`Stop()` are
reference counted on the shared connection. `TCPStreamerClient` is
single-stream and rejects a comma.
- `GetClientCount()` of a service counts (connection, stream) subscriptions.
### Merged stream: `TCPStreamJoin`
```
+Svc = { Class = TCPStreamService Port = 44500 }
+Fast = { Class = TCPStreamer Service = "Svc" StreamName = "fast" ... }
+Slow = { Class = TCPStreamer Service = "Svc" StreamName = "slow" ... }
+Join = { Class = TCPStreamJoin Service = "Svc" StreamName = "all"
Sources = "fast,slow" Master = "fast" }
```
A root-level object, declared after the service, that publishes a new stream
`all` holding the signals of several streams of the same service:
- CONFIG: source descriptors concatenated in `Sources` order, named
`<stream>_<signal>` (at most 63 characters), `timeSignalIdx` rebased, publish
mode Strict, one HRT frequency (sources with different known frequencies are
refused; an unknown one matches any).
- DATA: one frame per `Master` frame, `[master timestamp][source0 body][source1
body]...`, a body being that source's last DATA payload without its 8-byte
timestamp (sample-and-hold). Nothing is emitted until every source has
delivered one frame. A held value is stamped with the master's time unless the
source has a time signal. No thread is added: the frame is built on the master
producer's thread.
- Only Strict and Decimate sources can be merged; a source whose body size
changes without a new CONFIG is ignored; the merged frame must fit
`MaxPayloadSize` (default 60000).
- The merged stream is registered while every source is registered, removed as
soon as one leaves (its subscribers are dropped and reconnect) and rebuilt when
a source re-registers or changes its CONFIG. A source with a join attached
keeps publishing even with no socket subscriber.
- Keys: `Service` (required), `StreamName` (default object name), `Sources`
(required, 1..16), `Master` (default first source), `MaxPayloadSize`.
Design: `docs/superpowers/specs/2026-11-06-tcp-stream-join-design.md`.
## StreamHub
A StreamHub source can consume over TCP: in the config `Sources` block (and in
`addSource` / the `SourcesFile`) set `Transport = "tcp"` (default `"udp"`) and,
for a service, `StreamName = "<stream>"`. `Addr` is then the TCP server
address. The hub uses `TCPSClient`, which reports the UDPS counter gap so the
accumulate-mode timing estimator tolerates dropped frames.
`StreamName = "a,b,c"` takes several streams over one multiplexed connection and
creates one session per stream, labelled `<Label>_<stream>` (just the stream
name without a label). Session ids are `<node>_<stream>` for a config `Sources`
node and `s<N>` for `addSource`.
## Tests
- GTest: `Test/GTest/TCPSServerGTest.cpp`, `TCPSClientGTest.cpp`,
`TCPStreamerGTest.cpp` (helpers in `TCPTestUtil.h`) and
`TCPStreamServiceGTest.cpp` (queue, protocol, multiplexing, joins,
multi-stream/worker, stalls).
- E2E (`Test/E2E/suite/scenarios.py`): `s58_direct_tcp`, `s59_direct_tcp_service`
(FileReader to FileWriter round trip) and chain scenarios `s60`-`s65` (plain
TCP: scalar, full array, accumulate, decimate/quantise, multi-source,
high rate), `s66`-`s71` (service: same features plus 3- and 6-stream
fan-in), `s72` (hub on one multiplexed connection) and `s73` (merged stream). Scenario configs are generated by `gen_cfg.py`
(`network = "tcp"` / `"tcp_service"`).
## Design
`docs/superpowers/specs/2026-11-05-tcp-streamer-design.md` (section 7 covers
the single-service design implemented by `TCPStreamService`).