From 50d85363a3ed93056caf21aab4ca3ebf764a1c78 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 6 Nov 2026 13:41:27 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 + Common/Client/c/udps_client.h | 3 +- Common/Client/go/udpsprotocol/protocol.go | 1 + Common/UDP/UDPSProtocol.h | 8 + Docs/Protocol.md | 1 + Docs/TCPStreamer.md | 204 +++ Makefile.gcc | 6 + Source/Applications/StreamHub/StreamHub.cpp | 212 +++- Source/Applications/StreamHub/StreamHub.h | 37 +- .../StreamHub/UDPSourceSession.cpp | 54 +- .../Applications/StreamHub/UDPSourceSession.h | 30 +- .../DataSources/TCPStreamer/Makefile.gcc | 25 + .../DataSources/TCPStreamer/Makefile.inc | 63 + .../DataSources/TCPStreamer/TCPStreamer.cpp | 172 +++ .../DataSources/TCPStreamer/TCPStreamer.h | 112 ++ .../DataSources/TCPStreamer/depends.x86-linux | 145 +++ .../TCPStreamer/dependsRaw.x86-linux | 145 +++ .../TCPStreamerClient/Makefile.gcc | 25 + .../TCPStreamerClient/Makefile.inc | 62 + .../TCPStreamerClient/TCPStreamerClient.cpp | 122 ++ .../TCPStreamerClient/TCPStreamerClient.h | 79 ++ .../TCPStreamerClient/depends.x86-linux | 144 +++ .../TCPStreamerClient/dependsRaw.x86-linux | 144 +++ .../DataSources/UDPStreamer/UDPStreamer.cpp | 103 +- .../DataSources/UDPStreamer/UDPStreamer.h | 32 +- .../DataSources/UDPStreamer/depends.x86-linux | 4 +- .../UDPStreamer/dependsRaw.x86-linux | 4 +- .../UDPStreamerClient/UDPStreamerClient.cpp | 76 +- .../UDPStreamerClient/UDPStreamerClient.h | 27 +- .../UDPStreamerClient/depends.x86-linux | 3 +- .../UDPStreamerClient/dependsRaw.x86-linux | 3 +- .../Interfaces/TCPStreamService/Makefile.gcc | 1 + .../Interfaces/TCPStreamService/Makefile.inc | 33 + .../Interfaces/TCPStreamService/TCPSConn.h | 70 ++ .../TCPStreamService/TCPSFrameQueue.cpp | 204 +++ .../TCPStreamService/TCPSFrameQueue.h | 79 ++ .../TCPStreamService/TCPSServiceStream.cpp | 329 +++++ .../TCPStreamService/TCPSServiceStream.h | 110 ++ .../TCPStreamService/TCPSStreamTapI.h | 40 + .../TCPStreamService/TCPSWorker.cpp | 266 ++++ .../Interfaces/TCPStreamService/TCPSWorker.h | 68 + .../TCPStreamService/TCPStreamJoin.cpp | 494 ++++++++ .../TCPStreamService/TCPStreamJoin.h | 139 +++ .../TCPStreamService/TCPStreamService.cpp | 502 ++++++++ .../TCPStreamService/TCPStreamService.h | 174 +++ .../TCPStreamService/depends.x86-linux | 353 ++++++ .../TCPStreamService/dependsRaw.x86-linux | 353 ++++++ .../Interfaces/UDPStream/Makefile.inc | 2 +- .../Interfaces/UDPStream/TCPSClient.cpp | 517 ++++++++ .../Interfaces/UDPStream/TCPSClient.h | 163 +++ .../Interfaces/UDPStream/TCPSServer.cpp | 606 +++++++++ .../Interfaces/UDPStream/TCPSServer.h | 160 +++ .../Interfaces/UDPStream/UDPSClient.h | 3 +- .../Interfaces/UDPStream/UDPSServer.h | 3 +- .../Interfaces/UDPStream/UDPSTransportI.h | 71 ++ Test/Applications/StreamHub/depends.x86-linux | 312 +++++ .../StreamHub/dependsRaw.x86-linux | 312 +++++ .../DataSources/UDPStreamer/depends.x86-linux | 16 +- .../UDPStreamer/dependsRaw.x86-linux | 16 +- .../UDPStreamerClient/depends.x86-linux | 5 +- .../UDPStreamerClient/dependsRaw.x86-linux | 5 +- Test/E2E/datasources/E2ETCPServiceTest.cfg | 28 + Test/E2E/datasources/E2ETCPTest.cfg | 25 + Test/E2E/suite/gen_cfg.py | 56 +- Test/E2E/suite/gen_data.py | 6 + Test/E2E/suite/run_e2e.sh | 15 + Test/E2E/suite/scenarios.py | 153 ++- Test/E2E/suite/tests_py.py | 3 +- Test/GTest/Makefile.inc | 6 +- Test/GTest/TCPSClientGTest.cpp | 699 +++++++++++ Test/GTest/TCPSServerGTest.cpp | 455 +++++++ Test/GTest/TCPStreamServiceGTest.cpp | 1104 +++++++++++++++++ Test/GTest/TCPStreamerGTest.cpp | 434 +++++++ Test/GTest/TCPTestUtil.h | 195 +++ Test/GTest/depends.x86-linux | 702 ++++++++++- Test/GTest/dependsRaw.x86-linux | 702 ++++++++++- .../plans/2026-11-05-tcp-streamer.md | 114 ++ .../specs/2026-11-05-tcp-streamer-design.md | 249 ++++ .../2026-11-06-tcp-stream-join-design.md | 86 ++ 79 files changed, 12348 insertions(+), 138 deletions(-) create mode 100644 Docs/TCPStreamer.md create mode 100644 Source/Components/DataSources/TCPStreamer/Makefile.gcc create mode 100644 Source/Components/DataSources/TCPStreamer/Makefile.inc create mode 100644 Source/Components/DataSources/TCPStreamer/TCPStreamer.cpp create mode 100644 Source/Components/DataSources/TCPStreamer/TCPStreamer.h create mode 100644 Source/Components/DataSources/TCPStreamer/depends.x86-linux create mode 100644 Source/Components/DataSources/TCPStreamer/dependsRaw.x86-linux create mode 100644 Source/Components/DataSources/TCPStreamerClient/Makefile.gcc create mode 100644 Source/Components/DataSources/TCPStreamerClient/Makefile.inc create mode 100644 Source/Components/DataSources/TCPStreamerClient/TCPStreamerClient.cpp create mode 100644 Source/Components/DataSources/TCPStreamerClient/TCPStreamerClient.h create mode 100644 Source/Components/DataSources/TCPStreamerClient/depends.x86-linux create mode 100644 Source/Components/DataSources/TCPStreamerClient/dependsRaw.x86-linux create mode 100644 Source/Components/Interfaces/TCPStreamService/Makefile.gcc create mode 100644 Source/Components/Interfaces/TCPStreamService/Makefile.inc create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSConn.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSFrameQueue.cpp create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSFrameQueue.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSServiceStream.cpp create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSServiceStream.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSStreamTapI.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSWorker.cpp create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPSWorker.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPStreamJoin.cpp create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPStreamJoin.h create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPStreamService.cpp create mode 100644 Source/Components/Interfaces/TCPStreamService/TCPStreamService.h create mode 100644 Source/Components/Interfaces/TCPStreamService/depends.x86-linux create mode 100644 Source/Components/Interfaces/TCPStreamService/dependsRaw.x86-linux create mode 100644 Source/Components/Interfaces/UDPStream/TCPSClient.cpp create mode 100644 Source/Components/Interfaces/UDPStream/TCPSClient.h create mode 100644 Source/Components/Interfaces/UDPStream/TCPSServer.cpp create mode 100644 Source/Components/Interfaces/UDPStream/TCPSServer.h create mode 100644 Source/Components/Interfaces/UDPStream/UDPSTransportI.h create mode 100644 Test/E2E/datasources/E2ETCPServiceTest.cfg create mode 100644 Test/E2E/datasources/E2ETCPTest.cfg create mode 100644 Test/GTest/TCPSClientGTest.cpp create mode 100644 Test/GTest/TCPSServerGTest.cpp create mode 100644 Test/GTest/TCPStreamServiceGTest.cpp create mode 100644 Test/GTest/TCPStreamerGTest.cpp create mode 100644 Test/GTest/TCPTestUtil.h create mode 100644 docs/superpowers/plans/2026-11-05-tcp-streamer.md create mode 100644 docs/superpowers/specs/2026-11-05-tcp-streamer-design.md create mode 100644 docs/superpowers/specs/2026-11-06-tcp-stream-join-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 4278292..c63d6aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ Two independent data paths: 1. **Streaming path**: `UDPStreamer` DataSource serialises signals each RT cycle to UDPS binary packets (UDP 44500, unicast/multicast) → `StreamHub` (`Source/Applications/StreamHub/`, headless C++ app: ring buffers, LTTB decimation, trigger FSM) → WebSocket 8090 → browser (`Client/udpstreamer`, Go), native ImGui client (`Client/streamhub`), or native Qt client (`Client/streamhub-qt`). 2. **Debug path**: `DebugService` patches the `ClassRegistryDatabase` at `Initialise()` so subsequent `ConfigureApplication()` instantiates `DebugBrokerWrapper` around all `MemoryMap*Broker` types — no application changes. RT hot path goes through `DebugServiceI` (abstract singleton in `DebugServiceI.h`) for forcing/tracing/breakpoints. Exposes TCP 8080 (text commands), UDP 8081 (trace telemetry), works with `TcpLogger` on 8082. Web UI: `Client/debugger` (Go). +**TCP variant**: `TCPStreamer` / `TCPStreamerClient` (`Source/Components/DataSources/TCP*`) subclass `UDPStreamer` / `UDPStreamerClient` and swap the transport for `TCPSServer` / `TCPSClient` (`Source/Components/Interfaces/UDPStream/`) through the `UDPSServerI` / `UDPSClientI` seam in `UDPSTransportI.h`. Same UDPS framing (`totalFragments` always 1), per-client bounded queues so a slow client only drops its own frames. `TCPStreamService` (`Source/Components/Interfaces/TCPStreamService/`) is an optional single in-process server (acceptor + worker threads) shared by many `TCPStreamer`s via `Service=`/`StreamName=`; clients pick a stream with a SUBSCRIBE frame (UDPS type 5). A SUBSCRIBE may list several streams (`"a,b"`) to multiplex them over one socket (frames tagged with the stream index in `fragmentIdx`), and `TCPStreamJoin` publishes a merged stream (sample-and-hold of several streams paced by a master). StreamHub sources accept `Transport="tcp"` + `StreamName` (a comma list creates one session per stream). See `Docs/TCPStreamer.md`. + **Shared wire format**: `Common/UDP/UDPSProtocol.h` defines the UDPS binary protocol (17-byte packed header, 136-byte signal descriptors, little-endian). It is deliberately MARTe2-free so it's shared by C++ producers (`UDPStreamer`, `DebugService`), the C++ consumer (`Source/Components/Interfaces/UDPStream/UDPSClient`), the Go decoder (`Common/Client/go/udpsprotocol`), and the standalone C client (`Common/Client/c`, which redeclares the constants rather than including this header, so it stays MARTe-free). Changes to the protocol must be mirrored across all of these, plus the JS client parsers. **StreamHub WebSocket protocol**: JSON text frames for commands/events, binary frames for data pushes — spec in `ARCHITECTURE.md` §6. The Go hub (`Client/udpstreamer`) and C++ StreamHub implement the identical protocol; both clients (browser JS and ImGui) must stay compatible with both. diff --git a/Common/Client/c/udps_client.h b/Common/Client/c/udps_client.h index 0d3fe8b..1c7c0ad 100644 --- a/Common/Client/c/udps_client.h +++ b/Common/Client/c/udps_client.h @@ -52,7 +52,8 @@ enum { UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */ UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */ UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */ - UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */ + UDPS_PKT_DISCONNECT = 4, /**< Either direction: close a session. */ + UDPS_PKT_SUBSCRIBE = 5 /**< Client to TCP stream service only: payload = stream name(s), comma separated; server frames carry the stream index in fragmentIdx. */ }; /** Sample type codes (udps_signal_t::type_code). */ diff --git a/Common/Client/go/udpsprotocol/protocol.go b/Common/Client/go/udpsprotocol/protocol.go index 8365987..8871b81 100644 --- a/Common/Client/go/udpsprotocol/protocol.go +++ b/Common/Client/go/udpsprotocol/protocol.go @@ -18,6 +18,7 @@ const ( PktACK uint8 = 2 PktConnect uint8 = 3 PktDisconnect uint8 = 4 + PktSubscribe uint8 = 5 // TCP stream service only: payload = stream name(s), comma separated; server frames carry the stream index in fragmentIdx HeaderSize = 17 SigDescSize = 136 diff --git a/Common/UDP/UDPSProtocol.h b/Common/UDP/UDPSProtocol.h index eb9f1a6..1af4cf2 100644 --- a/Common/UDP/UDPSProtocol.h +++ b/Common/UDP/UDPSProtocol.h @@ -61,6 +61,14 @@ static const uint8 UDPS_TYPE_CONFIG = 1u; ///< Server → Client: signal con static const uint8 UDPS_TYPE_ACK = 2u; ///< Client → Server: ack counter static const uint8 UDPS_TYPE_CONNECT = 3u; ///< Client → Server: connect request static const uint8 UDPS_TYPE_DISCONNECT = 4u; ///< Client → Server: disconnect +static const uint8 UDPS_TYPE_SUBSCRIBE = 5u; ///< Client → Server (TCP stream service only): payload = stream name(s), comma separated + +/** TCP stream service: most streams one connection may multiplex. On such a + * connection every server frame carries the stream's position in the SUBSCRIBE + * list in the header's fragmentIdx field (totalFragments stays 1). */ +static const uint32 UDPS_TCP_MAX_STREAMS = 64u; +/** TCP stream service: longest SUBSCRIBE payload (comma separated names, each <= 255). */ +static const uint32 UDPS_TCP_MAX_SUBSCRIBE_PAYLOAD = 4096u; /*---------------------------------------------------------------------------*/ /* Wire packet header (17 bytes) */ diff --git a/Docs/Protocol.md b/Docs/Protocol.md index 5e361a9..6cd8faa 100644 --- a/Docs/Protocol.md +++ b/Docs/Protocol.md @@ -37,6 +37,7 @@ Offset Size Type Field | 2 | Client → Server | ACK | Acknowledge a data counter (reserved) | | 3 | Client → Server | CONNECT | Request a session | | 4 | Client → Server | DISCONNECT | End the session | +| 5 | Client → Server | SUBSCRIBE | TCP only: select a stream of a `TCPStreamService` (payload = stream name, 1..255 bytes, or a comma separated list of up to 64 names to multiplex; each server frame then carries its stream's list index in `fragmentIdx`) | --- diff --git a/Docs/TCPStreamer.md b/Docs/TCPStreamer.md new file mode 100644 index 0000000..32d06be --- /dev/null +++ b/Docs/TCPStreamer.md @@ -0,0 +1,204 @@ +# 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 + `_` (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 = ""`. `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 `