# TCPStreamer Source Kind Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `tcps` source kind that receives a MARTe2 `TCPStreamer` DataSource, standalone or through a `TCPStreamService`. **Architecture:** `deps/udpsclient` gets an additive TCP transport mode (connect, optional SUBSCRIBE, framed reads reusing `read_tcp_frame`). `UdpsSource` takes a transport argument so the TCP kind reuses its callbacks. A new `tcps_kind.cpp` supplies the form and registration. The headless self-check gets a `[tcps]` probe against a new Python mock server. **Tech Stack:** C99 (`deps/udpsclient`), C++17 + GTK4/libadwaita (`src/`), Python 3 + numpy (mock), CMake. Spec: `docs/superpowers/specs/2026-11-06-tcp-streamer-source-design.md`. ## Global Constraints - There is no unit test suite; verification is the app's own headless self-check under `xvfb-run` against a mock. Read `docs/claude/verification.md` (baselines) before Task 3 and run probes **short and serial**, killing the mock afterwards. - Baselines for every run: exactly **one** build warning (the pre-existing one; record the count before starting), and no `Gtk-CRITICAL`. - Every new source file starts with the two-line SPDX header: `// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari` (`#` comments in Python, after the shebang). - The GTK thread reaches a source only through `DataSource::post()`; `udps_*` names stay in `src/source/udps_*.cpp` and the new `tcps` files may include `udps_source.hpp` only. - One connection carries one stream; multiplexed `SUBSCRIBE` lists are out of scope. - Session naming for a non-UDPS kind is automatic (`session_host` -> `tcps-`, port 1, `source_kind=tcps`); do not touch `registry.cpp`'s session functions. - A kind id is `[a-z0-9]+`: `tcps`. Display name: `TCPStreamer`. Default port 44500. - Do not commit unless the task step says so. ## File Structure | File | Change | Responsibility | | --- | --- | --- | | `deps/udpsclient/udps_client.h` | modify | `use_tcp`, `stream_name` config fields | | `deps/udpsclient/udps_client.c` | modify | `connect_tcp()`, dispatch, no DISCONNECT frame in TCP mode, reorder window 0 | | `tools/mock_tcpstreamer.py` | create | Mock TCPStreamer / TCPStreamService server, reuses `mock_udpstreamer.py` frame builders | | `src/source/udps_source.hpp/.cpp` | modify | `Transport` enum, constructor argument, accessor | | `src/source/tcps_kind.cpp` | create | `tcps` `SourceKind` and its form | | `src/source/registry.hpp/.cpp` | modify | `make_tcps_kind()`, registration | | `CMakeLists.txt` | modify | add `tcps_kind.cpp` | | `src/ui_probe.cpp`, `src/ui.cpp` | modify | `[source-kinds]` sample, `[tcps]` probes and their env scheduling | | `README.md`, `docs/claude/*.md` | modify | document the kind, the upstream change, the probes | --- ### Task 1: TCP transport in `deps/udpsclient`, and the mock server **Files:** - Modify: `deps/udpsclient/udps_client.h` (struct `udps_client_config_t`, around line 263-282) - Modify: `deps/udpsclient/udps_client.c` (struct `udps_client` ~line 166, `send_control` ~1021, `do_connect` ~1076, `do_disconnect` ~1093, `udps_client_create` ~1260) - Create: `tools/mock_tcpstreamer.py` **Interfaces:** - Produces: `udps_client_config_t::use_tcp` (`int`, 0 = UDP as before) and `::stream_name` (`const char *`, NULL or empty = no SUBSCRIBE). Both default to zero/NULL through `udps_client_config_init()` (it memsets). `server_port` is the TCP port in TCP mode. - Produces: `tools/mock_tcpstreamer.py --port P [--stream NAME] [--rate R] [--batch B] [--close-after S] [--drop-every N]`. - [ ] **Step 1: Add the config fields** at the end of `udps_client_config_t` in `udps_client.h`, after `reorder_window_s`: ```c /** * Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService): * @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the * server streams CONFIG then DATA on connect, and the reorder window is forced * to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group, * @c interface_addr and @c data_port are ignored. */ int use_tcp; /** * TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is * sent right after every connect, as a TCPStreamService requires. A plain * TCPStreamer ignores it. NULL or "" sends nothing. */ const char *stream_name; ``` - [ ] **Step 2: Own a copy of the name.** In `struct udps_client`, after `char iface_addr[128];` add `char stream_name[256];`. In `udps_client_create`, next to the other `copy_str` calls add `copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);` and after the `c->cfg.interface_addr = ...` line add: ```c c->cfg.stream_name = c->stream_name; if (c->cfg.use_tcp) { c->cfg.multicast_group = NULL; c->mcast_group[0] = '\0'; c->cfg.reorder_window_s = 0.0; } ``` Also reject a name over 255 bytes: before `copy_str`, `if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) { free(c); return NULL; }` (place right after the `calloc` succeeds). - [ ] **Step 3: Add `connect_tcp()`** after `connect_multicast` (before `do_connect`): ```c /** TCP transport: connect, then SUBSCRIBE when a stream name is set. */ static int connect_tcp(udps_client_t *c) { struct timeval tv; size_t name_len = strlen(c->stream_name); c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0); if (c->tcp_fd < 0) { return fail(c, "TCP socket() failed: %s", strerror(errno)); } set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes); /* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host * cannot hold the polling thread for minutes. */ tv.tv_sec = 3; tv.tv_usec = 0; (void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv); if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa, (socklen_t)sizeof c->server_sa) != 0) { int e = errno; close_sockets(c); return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr, (unsigned)c->cfg.server_port, strerror(e)); } /* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */ tv.tv_sec = 1; (void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv); if (name_len > 0u) { uint8_t pkt[UDPS_HEADER_SIZE + 255u]; build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len); memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len); if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) != (ssize_t)(UDPS_HEADER_SIZE + name_len)) { int e = errno; close_sockets(c); return fail(c, "SUBSCRIBE failed: %s", strerror(e)); } } return 0; } ``` Add `UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */` to the packet-type enum in `udps_client.h` (add a comma after the `UDPS_PKT_DISCONNECT = 4` line). - [ ] **Step 4: Dispatch and disconnect.** Replace the first lines of `do_connect`: ```c int rc = c->cfg.use_tcp ? connect_tcp(c) : (c->mcast_group[0] != '\0') ? connect_multicast(c) : connect_unicast(c); ``` In `do_disconnect`, replace `(void)send_control(c, UDPS_PKT_DISCONNECT);` with `if (!c->cfg.use_tcp) { (void)send_control(c, UDPS_PKT_DISCONNECT); }` (a write to a peer that closed would raise SIGPIPE and kill the application; the server detects the close itself). - [ ] **Step 5: Confirm the other TCP-path assumptions by reading, not guessing.** Read `deliver_payload` (~line 848) and confirm that with `reorder_window_s == 0` a counter jump delivers the frame with `lost > 0` and `stats.counter_gaps` incremented, and that `read_tcp_frame` -> `handle_packet` needs no change. If either is false, stop and report; do not patch around it. - [ ] **Step 6: Write the mock** `tools/mock_tcpstreamer.py` (`chmod +x`): ```python #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 Martino Ferrari """A minimal MARTe2 TCPStreamer / TCPStreamService producer, for exercising udpscope. Listens on `--port`, serves one client at a time with the same frames as mock_udpstreamer.py (CONFIG, then Accumulate DATA packets of sine/square/ramp/noise), each framed UDPS-over-TCP. Without `--stream` it behaves like a standalone TCPStreamer: the stream starts at once and anything the client sends is ignored. With `--stream NAME` it behaves like a TCPStreamService: the client must open with a SUBSCRIBE frame naming NAME within 5 s, otherwise the connection is closed. ./tools/mock_tcpstreamer.py --port 44500 --stream fast --close-after 6 """ import argparse import os import select import socket import struct import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import mock_udpstreamer as m # noqa: E402 PKT_SUBSCRIBE = 5 HEADER = 17 def read_exact(conn, n, deadline): buf = b"" while len(buf) < n: left = deadline - time.monotonic() if left <= 0: return None r, _, _ = select.select([conn], [], [], left) if not r: return None chunk = conn.recv(n - len(buf)) if not chunk: return None buf += chunk return buf def subscribed_to(conn, want): """True once a SUBSCRIBE frame naming `want` arrived within 5 s.""" deadline = time.monotonic() + 5.0 head = read_exact(conn, HEADER, deadline) if head is None: return False magic, ptype, _, _, _, plen = struct.unpack(" 0 and now - started >= args.close_after: print("mock tcpstreamer: closing the connection (--close-after)", flush=True) return if now < next_send: time.sleep(min(next_send - now, 0.002)) continue pkt = m.build_data(counter, t_stream, dt, args.batch, args.freq) if not (args.drop_every > 0 and counter % args.drop_every == 0): conn.sendall(pkt) counter += 1 t_stream += args.batch * dt next_send += period def main(): ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=44500) ap.add_argument("--stream", default="", help="TCPStreamService mode: require SUBSCRIBE of this name") ap.add_argument("--rate", type=float, default=10000.0, help="samples per second") ap.add_argument("--batch", type=int, default=100, help="samples per packet") ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz") ap.add_argument("--close-after", type=float, default=0.0, metavar="S", help="close each connection after S seconds (the client must reconnect); 0 = never") ap.add_argument("--drop-every", type=int, default=0, metavar="N", help="skip sending every Nth packet but advance the counter (a server-side drop); 0 = never") args = ap.parse_args() srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", args.port)) srv.listen(4) print("mock tcpstreamer: listening on :%d (%s)" % ( args.port, "service stream '%s'" % args.stream if args.stream else "standalone"), flush=True) while True: conn, addr = srv.accept() print("mock tcpstreamer: client %s:%d" % addr, flush=True) try: if args.stream and not subscribed_to(conn, args.stream): print("mock tcpstreamer: no valid SUBSCRIBE for '%s'; closing" % args.stream, flush=True) continue if args.stream: print("mock tcpstreamer: SUBSCRIBE '%s'" % args.stream, flush=True) serve(conn, args) except (BrokenPipeError, ConnectionResetError): print("mock tcpstreamer: client went away", flush=True) finally: conn.close() if __name__ == "__main__": main() ``` - [ ] **Step 7: Build the dependency and the app** with the project's usual command (see `docs/claude/build-and-run.md`; normally `cmake --build build -j`). Expected: it builds; the warning count equals the count recorded before the task. Run `python3 tools/mock_tcpstreamer.py --help` and `python3 -c "import ast,sys;ast.parse(open('tools/mock_tcpstreamer.py').read())"`: both succeed. - [ ] **Step 8: Commit** ```bash git add deps/udpsclient/udps_client.h deps/udpsclient/udps_client.c tools/mock_tcpstreamer.py git commit -m "udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server" ``` --- ### Task 2: The `tcps` source kind **Files:** - Modify: `src/source/udps_source.hpp`, `src/source/udps_source.cpp` - Create: `src/source/tcps_kind.cpp` - Modify: `src/source/registry.hpp`, `src/source/registry.cpp`, `CMakeLists.txt` (source list near line 242-243) **Interfaces:** - Consumes: `udps_client_config_t::use_tcp` / `::stream_name` from Task 1. - Produces: `enum class UdpsSource::Transport { Udp, Tcp }`; `UdpsSource(const SourceConfig&, Transport = Transport::Udp)`; `Transport UdpsSource::transport() const`; `std::unique_ptr make_tcps_kind()`; kind id `tcps` with config keys `server` (default `127.0.0.1`), `port` (default `44500`), `stream` (optional). - [ ] **Step 1: `udps_source.hpp`.** Inside `class UdpsSource`, add before the constructor: ```cpp enum class Transport { Udp, Tcp }; ``` change the constructor to `explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);`, add `Transport transport() const { return transport_; }` in the public section, and members `Transport transport_ = Transport::Udp;` and `std::string stream_;`. - [ ] **Step 2: `udps_source.cpp`.** Replace the constructor: ```cpp UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport) : transport_(transport), server_(cfg.get("server", "127.0.0.1")), port_(static_cast( cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))), multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")), interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")), stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()), recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {} ``` Member declaration order must match the initialiser order: in the header declare `transport_` before `server_`, and `stream_` after `interface_` and before `recv_buffer_` (an out-of-order initialiser is a `-Wreorder` warning, which breaks the one-warning baseline). In `open()`, after `c.keepalive_interval_s = 1.0;` add: ```cpp if (transport_ == Transport::Tcp) { c.use_tcp = 1; c.stream_name = stream_.empty() ? nullptr : stream_.c_str(); } ``` `datagram_cb` registration stays; in TCP mode the client never fires it. Leave `set_datagram_tap` as is. - [ ] **Step 3: Create `src/source/tcps_kind.cpp`.** Copy the helper block of `src/source/udps_kind.cpp` (`ChangeCb`, `entry_row`, `on_edited`, `on_value`, `hook_value`, `hook`, `text_of`; they are in an anonymous namespace, so the copy is local, as three kinds already do) and add the kind: ```cpp // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "source/registry.hpp" #include "source/udps_source.hpp" #include #include namespace udpscope { namespace { /* ChangeCb, entry_row, on_edited, on_value, hook_value, hook, text_of: the same six helpers as udps_kind.cpp, verbatim. */ class TcpsKind final : public SourceKind { public: std::string id() const override { return "tcps"; } std::string display_name() const override { return "TCPStreamer"; } std::string identity(const SourceConfig& cfg) const override { std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500)); if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream"); return s; } std::unique_ptr make(const SourceConfig& cfg) const override { return std::make_unique(cfg, UdpsSource::Transport::Tcp); } ConfigForm make_config_form() const override { GtkWidget* server = entry_row("_Server"); GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1)); adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port"); adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE); adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500); GtkWidget* stream = entry_row("S_tream name"); ChangeCb cb = std::make_shared>(); hook(server, cb); hook_value(port, cb); hook(stream, cb); ConfigForm f; f.rows = {server, port, stream}; f.description = "Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService"; f.focus = server; f.write = [=](const SourceConfig& c) { gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str()); adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast(c.get_int("port", 44500))); gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str()); }; f.read = [=]() { SourceConfig c; c.set("server", text_of(server)); c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port))))); if (!text_of(stream).empty()) c.set("stream", text_of(stream)); return c; }; // A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma // (that would select a multiplexed list, which this kind does not support). f.valid = [=]() { const std::string s = text_of(stream); return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos; }; f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; return f; } }; } // namespace std::unique_ptr make_tcps_kind() { return std::make_unique(); } } // namespace udpscope ``` When writing the file, paste the six real helper definitions in place of the comment block. Check `udps_kind.cpp` includes `` transitively (it does through `source_kind.hpp`). - [ ] **Step 4: Register.** `registry.hpp`: add `std::unique_ptr make_tcps_kind();` after `make_udps_kind()` and add `tcps_kind.cpp` to the comment's file list. `registry.cpp`: in `register_builtin_sources()` add `register_kind(make_tcps_kind());` right after `make_udps_kind()` (so the chooser lists the two network kinds together). `CMakeLists.txt`: add `src/source/tcps_kind.cpp` after `src/source/udps_kind.cpp`. - [ ] **Step 5: Build.** `cmake --build build -j`. Expected: builds, warning count unchanged from the baseline. - [ ] **Step 6: Smoke-run against the mock** (serial, kill the mock after): ```bash python3 tools/mock_tcpstreamer.py --port 44500 & MOCK=$! UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a build/udpscope --source tcps --source-opt server=127.0.0.1 --source-opt port=44500 2>&1 | tail -20 kill $MOCK ``` Expected: no `Gtk-CRITICAL`; the mock prints `client 127.0.0.1:...`. (If the app's CLI spells the options differently, take the spelling from `docs/claude/build-and-run.md`.) Then the same with `--stream fast` on the mock and `--source-opt stream=fast` on the app: the mock prints `SUBSCRIBE 'fast'`. Then once with the wrong name (`stream=slow`): the mock prints `no valid SUBSCRIBE`, and the app keeps retrying without a stderr flood. - [ ] **Step 7: Commit** ```bash git add src/source/udps_source.hpp src/source/udps_source.cpp src/source/tcps_kind.cpp src/source/registry.hpp src/source/registry.cpp CMakeLists.txt git commit -m "sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode" ``` --- ### Task 3: Probes **Files:** - Modify: `src/ui_probe.cpp` (sample at ~line 2683 `const auto sample`; new `[tcps]` functions after `flatudp_probe`, ~line 3703), `src/ui.cpp` (env scheduling after the `UDPSCOPE_PROBE_FLATUDP_GAP` block, ~line 7657) - Declarations: wherever `flatudp_probe` / `flatudp_probe_gap` are declared (grep `flatudp_probe_gap` in `src/*.hpp`; add the two new functions beside them) **Interfaces:** - Consumes: `UdpsSource::transport()`, kind `tcps`, `a->model->stats()` fields `connected`, `frames`, `reconnects`, `a->model->signals()`, `a->capture_button`. - Produces: env vars `UDPSCOPE_PROBE_TCPS=` and `UDPSCOPE_PROBE_TCPS_RECONNECT=`; stderr lines `[tcps] stream:`, `[tcps] capture button:`, `[tcps] reconnect:`; a `[source-kinds] form tcps:` line. - [ ] **Step 1: Form round trip.** In `source_kinds_probe`'s `sample` lambda add a branch: ```cpp } else if (id == "tcps") { c.set("server", "10.0.0.7"); c.set("port", "44501"); c.set("stream", "fast"); ``` Build and run `UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep source-kinds`. Expected: `kinds=udps,tcps,e1608,flatudp,probe` and `form tcps: round_trip=1 valid=1`. The `kinds=` line changes for every run from now on, so update `docs/claude/probe-readings.md` (~line 2120-2123, 2135) and `docs/claude/verification.md` (~line 579, "Nine `[source-kinds]` lines" -> ten, "one `form` line per registered kind: `udps`, `tcps`, ...") in Task 4. - [ ] **Step 2: Add the source accessor and stream probe** in `ui_probe.cpp`, after `flatudp_probe`: ```cpp namespace { UdpsSource* tcps_source(App* a) { UdpsSource* s = a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; return s != nullptr && s->transport() == UdpsSource::Transport::Tcp ? s : nullptr; } std::uint64_t g_tcps_frames_before = 0; std::uint64_t g_tcps_reconnects_before = 0; } // namespace /// Phase 2, 1 s after the signals were enabled: connected, the mock's four channels, frames /// flowing, and the capture button present but disabled (the capture is of the UDP socket). gboolean tcps_probe_report(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const bool up = a->model->stats().connected.load(std::memory_order_relaxed); std::string names; for (const SignalInfo& s : a->model->signals()) names += (names.empty() ? "" : ",") + s.name; const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); const bool ok = up && names == "sine,square,ramp,noise" && frames > 0; std::fprintf(stderr, "[tcps] stream: connected=%d signals=%zu names=%s frames=%" G_GUINT64_FORMAT " ok=%d\n", up ? 1 : 0, a->model->signals().size(), names.c_str(), frames, ok ? 1 : 0); std::fprintf(stderr, "[tcps] capture button: present=%d sensitive=%d\n", a->capture_button != nullptr ? 1 : 0, a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0); std::fflush(stderr); return G_SOURCE_REMOVE; } /// Phase 1: enable every signal in one structural change, report a second later. gboolean tcps_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; if (tcps_source(a) == nullptr) { std::fprintf(stderr, "[tcps] no tcps source (run with --source tcps)\n"); std::fflush(stderr); return G_SOURCE_REMOVE; } std::vector all(a->model->signals().size()); for (std::size_t i = 0; i < all.size(); ++i) all[i] = i; if (!all.empty()) a->model->set_signals_enabled(all, true); g_timeout_add(1000, tcps_probe_report, a); return G_SOURCE_REMOVE; } /// After the mock closed the connection (`--close-after`): the client must have reconnected and /// the frame count moved on. Phase 2 reads 1.5 s after phase 1 recorded the baseline. gboolean tcps_probe_reconnect_report(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const bool up = a->model->stats().connected.load(std::memory_order_relaxed); const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); const std::uint64_t rc = a->model->stats().reconnects.load(std::memory_order_relaxed); const bool ok = up && frames > g_tcps_frames_before && rc > g_tcps_reconnects_before; std::fprintf(stderr, "[tcps] reconnect: connected=%d frames_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " reconnects_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " ok=%d\n", up ? 1 : 0, g_tcps_frames_before, frames, g_tcps_reconnects_before, rc, ok ? 1 : 0); std::fflush(stderr); return G_SOURCE_REMOVE; } gboolean tcps_probe_reconnect(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; g_tcps_frames_before = a->model->stats().frames.load(std::memory_order_relaxed); g_tcps_reconnects_before = a->model->stats().reconnects.load(std::memory_order_relaxed); g_timeout_add(1500, tcps_probe_reconnect_report, a); return G_SOURCE_REMOVE; } ``` Make sure `ui_probe.cpp` includes `source/udps_source.hpp` (add it beside the flatudp includes at line 11-12 if absent). The `reconnects` stat is fed from `SourceStats::reconnects` by `Acquisition`; if after Task 3 Step 4 it reads 0 while the client demonstrably reconnected, `Acquisition` does not map it for this path: report that rather than weakening the check. - [ ] **Step 3: Schedule and declare.** Add the two declarations next to `flatudp_probe_gap`'s, and in `ui.cpp` after the `UDPSCOPE_PROBE_FLATUDP_GAP` block: ```cpp if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS")) { g_timeout_add(static_cast(std::atoi(env)), tcps_probe, &app); } if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), tcps_probe_reconnect, &app); } ``` - [ ] **Step 4: Run the three scenarios**, each short and serial, mock killed after each, stderr filtered with `grep -E '^\[tcps\]|Gtk-CRITICAL|warning'`: 1. Standalone: mock `--port 44500`; app `--source tcps --source-opt port=44500`, `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_EXIT_AFTER_MS=5000`. Expected `[tcps] stream: connected=1 signals=4 names=sine,square,ramp,noise frames= ok=1` (n > 0) and `[tcps] capture button: present=1 sensitive=0`. 2. Service: mock `--port 44500 --stream fast`; app additionally `--source-opt stream=fast`. Same expectations. 3. Reconnect: mock `--port 44500 --close-after 3`; app `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_PROBE_TCPS_RECONNECT=4500 UDPSCOPE_EXIT_AFTER_MS=8000`. Expected `[tcps] reconnect: connected=1 ... ok=1`. Also check the no-server case: no mock, `UDPSCOPE_PROBE_TCPS=2500`: `connected=0 ... ok=0` is a reading, not a crash; no stderr flood (a retry every 0.5 s is acceptable, more than a few lines per second is not). - [ ] **Step 5: Baselines.** A full rebuild shows the same warning count as before the plan, and none of the runs printed `Gtk-CRITICAL`. Then re-run the source-kinds probe (Step 1) and confirm the existing `udps`/`e1608`/`flatudp`/`probe` form lines still read `round_trip=1 valid=1`. - [ ] **Step 6: Commit** ```bash git add src/ui_probe.cpp src/ui.cpp git commit -m "probes: the TCPStreamer kind end to end (standalone, service, reconnect)" ``` (Include the header that carries the probe declarations in `git add` if Step 3 touched one.) --- ### Task 4: Documentation **Files:** - Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `CLAUDE.md` (only its intro sentence if it enumerates the source kinds) - [ ] **Step 1: `docs/claude/build-and-run.md`.** The `deps/udpsclient` section says "Five things were changed". Make it six and add item 6: "**A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (3 s connect bound via `SO_SNDTIMEO`, 1 s receive bound, an optional `SUBSCRIBE` frame), the existing `read_tcp_frame()` for the frames, the reorder window forced to 0, no DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), and no datagram tap. A frame must fit `UDPS_MAX_DATAGRAM_BYTES` (the server's `MaxPayloadSize` default of 60000 does)." Also update the earlier sentence "`deps/udpsclient` gained one additive entry point" to mention the TCP mode. - [ ] **Step 2: `docs/claude/architecture.md` (Sources section).** Add a bullet: "**The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`** ... `src/source/tcps_kind.cpp` is only the form and identity (`host:port` or `host:port/stream`); there is no new `DataSource`. One stream per connection (no multiplexed `SUBSCRIBE` list: a source is single-timebase; merge on the server with `TCPStreamJoin`). `has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s silence timeout or a closed connection, and it reconnects every 0.5 s. A stream whose frames are further apart than 2 s therefore reconnects continually, as UDPS does." Fix the bullet that says `UdpsSource` "wraps `deps/udpsclient` unchanged". - [ ] **Step 3: `docs/claude/verification.md` and `probe-readings.md`.** Verification: update "Nine `[source-kinds]` lines (... `udps`, `e1608`, `flatudp`, `probe`)" to include `tcps`; add `UDPSCOPE_PROBE_TCPS` and `UDPSCOPE_PROBE_TCPS_RECONNECT` entries beside the FLATUDP ones, with the three scenarios from Task 3 Step 4 (commands, mock options, exit times). Probe readings: update `kinds=` (line ~2120 and ~2135) to `udps,tcps,e1608,flatudp,probe`, add `[source-kinds] form tcps: round_trip=1 valid=1` (and the form sample's keys: server, port, stream), and add a `## [tcps]` section after `[flatudp]` with the sample output of the three lines and what a wrong reading means (`connected=0` with a mock running: SUBSCRIBE or connect failing; `names` wrong: CONFIG not decoded; `sensitive=1`: the capture button was enabled for a kind with no datagrams; `reconnect ... ok=0`: the client did not come back after the close, or `Acquisition` does not map `reconnects`). - [ ] **Step 4: `README.md`.** Wherever it lists the source kinds or the command-line `--source` values, add `tcps` with its options (`server`, `port` default 44500, `stream` optional), one line saying it receives a MARTe2 `TCPStreamer`/`TCPStreamService`, and under known limitations: single stream per connection, no packet capture over TCP, frames up to 65 KB (the server's `MaxPayloadSize`). If `CLAUDE.md`'s intro sentence enumerates the kinds, add `tcps` there too. - [ ] **Step 5: Read-through.** `grep -rn "Nine \`\[source-kinds\]\|Five things\|unchanged" README.md CLAUDE.md docs/claude` and fix every statement the change made false. - [ ] **Step 6: Commit** ```bash git add README.md CLAUDE.md docs/claude git commit -m "docs: the TCPStreamer source kind, the client's TCP mode, and its probes" ``` --- ## Self-review (against the spec) - Wire/scope (single stream, SUBSCRIBE only with a name): Task 1 Steps 1-4, Task 2 Step 3 (`valid` rejects a comma). - `udpsclient` TCP mode (use_tcp, stream_name, connect_tcp, reorder 0, no tap, additive, recorded as a sixth change): Task 1; recording: Task 4 Step 1. - Source adapter reuse via transport argument; form rows, description, identity, session naming (automatic), registration and CMake: Task 2. - Verification (fake server, probe lines for signals/frames/reconnect, baselines, real smoke test): Task 3 uses a Python mock rather than the in-process fake server the spec named, because the existing probes all use external mocks under `tools/`. A smoke test against a real MARTe `TCPStreamer` is not in the plan; do it only if one is runnable. - Docs: Task 4. - Types: `UdpsSource::Transport`, `transport()`, `make_tcps_kind()`, `tcps_source()`, env names are used identically across tasks.