From 5fc898ad7ec9cbe6b0afe5b2e67b934069e86c23 Mon Sep 17 00:00:00 2001 From: David Cozens Date: Fri, 29 May 2026 20:32:38 +0100 Subject: [PATCH 1/3] fix: S28.10 LwipRawTcpStream Send fails on peer close so StreamSender reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamSender reconnects only when Send returns false; it never Reads. On a graceful peer FIN (RecvCallback with NULL p) the adapter set Errored but left the pcb non-NULL, and Send guarded only on IsOpen — so tcp_write kept returning ERR_OK into a doomed half-closed connection and the sender never reconnected after the server recovered. Gate Send with a new intent-named predicate IsWritable (IsOpen && !Errored) so a post-FIN send fails, prompting StreamSender to close and reconnect on the next message. Read still runs while Errored (drain queued bytes, then close on EOF). Mirrors FreeRTOS-Plus-TCP's send-side closed-socket detection. Driven red->green by SendReturnsFalseAfterPeerFin (Tests/Lwip, 56 tests green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LwipRaw/Source/SolidSyslogLwipRawTcpStream.c | 13 ++++++++++++- Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c b/Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c index b85da8a3..6c3f0f85 100644 --- a/Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c +++ b/Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c @@ -51,6 +51,7 @@ static inline struct SolidSyslogLwipRawTcpStream* LwipRawTcpStream_SelfFromArg(v static inline struct LwipRawTcpStreamCall* LwipRawTcpStreamCallFromContext(void* context); static inline bool LwipRawTcpStream_ConfigProvidesGetter(const struct SolidSyslogLwipRawTcpStreamConfig* config); static inline bool LwipRawTcpStream_IsOpen(const struct SolidSyslogLwipRawTcpStream* self); +static inline bool LwipRawTcpStream_IsWritable(const struct SolidSyslogLwipRawTcpStream* self); static inline bool LwipRawTcpStream_HasQueuedRx(const struct SolidSyslogLwipRawTcpStream* self); static inline bool LwipRawTcpStream_RxQueueIsFull(const struct SolidSyslogLwipRawTcpStream* self); static inline bool LwipRawTcpStream_HasCloseWork(const struct SolidSyslogLwipRawTcpStream* self); @@ -263,7 +264,7 @@ static bool LwipRawTcpStream_Send(struct SolidSyslogStream* base, const void* bu { struct SolidSyslogLwipRawTcpStream* self = LwipRawTcpStream_SelfFromBase(base); bool sent = false; - if (LwipRawTcpStream_IsOpen(self)) + if (LwipRawTcpStream_IsWritable(self)) { struct LwipRawTcpStreamCall call = {.Self = self, .SendBuffer = buffer, .Length = size}; SolidSyslogLwipRaw_Marshal(LwipRawTcpStream_DoSend, &call); @@ -272,6 +273,16 @@ static bool LwipRawTcpStream_Send(struct SolidSyslogStream* base, const void* bu return sent; } +/* Sendable means the pcb is live AND no peer close / error has been observed. + * A peer FIN (RecvCallback with NULL p) sets Errored but leaves the pcb non-NULL, + * so IsOpen alone would keep writing into a doomed connection; failing the send + * lets StreamSender close and reconnect. Read deliberately still runs while + * Errored — it must drain queued bytes and then close on EOF. */ +static inline bool LwipRawTcpStream_IsWritable(const struct SolidSyslogLwipRawTcpStream* self) +{ + return LwipRawTcpStream_IsOpen(self) && !self->Errored; +} + static void LwipRawTcpStream_DoSend(void* context) { struct LwipRawTcpStreamCall* call = LwipRawTcpStreamCallFromContext(context); diff --git a/Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp b/Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp index 910465f4..5478932d 100644 --- a/Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp +++ b/Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp @@ -629,6 +629,17 @@ TEST(SolidSyslogLwipRawTcpStreamConnected, ReadAdvancesPastDrainedPbufToNextInQu LONGS_EQUAL(2, LwipPbufFake_PbufFreeCallCount()); } +TEST(SolidSyslogLwipRawTcpStreamConnected, SendReturnsFalseAfterPeerFin) +{ + pushPeerFin(); + + /* A peer half-close (FIN) leaves the pcb alive but the connection doomed. + * Send must report failure — without it the StreamSender keeps writing into + * the dead connection and never reconnects after the server recovers. */ + CHECK_FALSE(sendBytes()); + CALLED_FAKE(LwipTcpFake_TcpWrite, NEVER); +} + TEST(SolidSyslogLwipRawTcpStreamConnected, ReadReturnsMinusOneAfterPeerFinAndDrainsBeforeEof) { struct pbuf p = {}; From abb4a2559544d677cd15b4c100d2f58849d14a1b Mon Sep 17 00:00:00 2001 From: David Cozens Date: Fri, 29 May 2026 20:32:39 +0100 Subject: [PATCH 2/3] feat: S28.10 FreeRtosLwip TCP via octet-framed StreamSender over LwipRawTcpStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire a real RFC 6587 octet-framed SolidSyslogStreamSender over SolidSyslogLwipRawTcpStream into the SwitchingSender's TCP slot (TLS stays NullSender until S28.11). Shares the resolver + endpoint callbacks with UDP (syslog-ng oracle listens TCP on the same :5514); RtosSleep drives the bounded synchronous connect. Flip the advisory lwIP lane filter to (@udp or @tcp) — enables tcp_transport, tcp_reconnect, and the UDP->TCP switching_transport scenario. Oracle green: 11 features / 28 scenarios / 116 steps, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- Bdd/Targets/FreeRtosLwip/README.md | 12 ++++--- Bdd/Targets/FreeRtosLwip/main.c | 44 ++++++++++++++++++++++---- DEVLOG.md | 51 ++++++++++++++++++++++++++++++ ci/docker-compose.bdd.yml | 16 +++++----- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/Bdd/Targets/FreeRtosLwip/README.md b/Bdd/Targets/FreeRtosLwip/README.md index 82711b14..33420c60 100644 --- a/Bdd/Targets/FreeRtosLwip/README.md +++ b/Bdd/Targets/FreeRtosLwip/README.md @@ -32,11 +32,13 @@ oracle. ## Scope -UDP for S28.09. The `SwitchingSender` keeps TCP / TLS slots wired to the shared -`NullSender` so `set transport tcp|tls` resolves cleanly (drops on the floor) -rather than crashing; the real LwipRaw TCP / TLS senders land in later E28 -stories. `lwipopts.h` runs `NO_SYS=0` (tcpip thread + the contrib FreeRTOS -`sys_arch`); `FreeRTOSConfig.h` mirrors the +TCP networking config. +UDP (S28.09) + TCP (S28.10). The `SwitchingSender` carries a real UDP sender +(`SolidSyslogLwipRawDatagram`) and a real RFC 6587 octet-framed TCP sender +(`SolidSyslogStreamSender` over `SolidSyslogLwipRawTcpStream`); the TLS slot +stays wired to the shared `NullSender` so `set transport tls` resolves cleanly +(drops on the floor) until S28.11 wires the LwipRaw TLS sender. `lwipopts.h` +runs `NO_SYS=0` (tcpip thread + the contrib FreeRTOS `sys_arch`); +`FreeRTOSConfig.h` mirrors the +TCP networking config. ## Build diff --git a/Bdd/Targets/FreeRtosLwip/main.c b/Bdd/Targets/FreeRtosLwip/main.c index aebaffb9..8e4b45b8 100644 --- a/Bdd/Targets/FreeRtosLwip/main.c +++ b/Bdd/Targets/FreeRtosLwip/main.c @@ -11,10 +11,10 @@ * drains over UDP, and BddTargetInteractive drives `send N` / `set ` / * `quit` over the QEMU -serial stdio UART. * - * Scope is UDP this story. The SwitchingSender keeps TCP / TLS slots so - * `set transport tcp|tls` resolves cleanly (to the shared NullSender, which - * drops on the floor) rather than crashing; the real LwipRaw TCP / TLS senders - * land in later E28 stories. + * Scope is UDP (S28.09) + TCP (S28.10). The SwitchingSender carries a real UDP + * sender (LwipRawDatagram) and a real octet-framed TCP sender (StreamSender over + * LwipRawTcpStream); the TLS slot still resolves to the shared NullSender (drops + * on the floor) until S28.11 wires the LwipRaw TLS sender. * * Static IPv4 (10.0.2.15) on the QEMU slirp network, host reachable at the * slirp gateway 10.0.2.2 — numeric, because slirp has no route to the docker @@ -42,6 +42,7 @@ #include "SolidSyslogLwipRawDatagram.h" #include "SolidSyslogLwipRawMarshal.h" #include "SolidSyslogLwipRawResolver.h" +#include "SolidSyslogLwipRawTcpStream.h" #include "SolidSyslogMetaSd.h" #include "SolidSyslogMutex.h" #include "SolidSyslogNullSender.h" @@ -49,6 +50,7 @@ #include "SolidSyslogOriginSd.h" #include "SolidSyslogPrival.h" #include "SolidSyslogStdAtomicCounter.h" +#include "SolidSyslogStreamSender.h" #include "SolidSyslogSwitchingSender.h" #include "SolidSyslogTimeQuality.h" #include "SolidSyslogTimeQualitySd.h" @@ -131,6 +133,9 @@ static struct SolidSyslogResolver* resolver = NULL; static struct SolidSyslogDatagram* datagram = NULL; static struct SolidSyslogAddress* udpAddress = NULL; static struct SolidSyslogSender* udpSender = NULL; +static struct SolidSyslogStream* tcpStream = NULL; +static struct SolidSyslogAddress* tcpAddress = NULL; +static struct SolidSyslogSender* tcpSender = NULL; static struct SolidSyslogSender* switchingSender = NULL; static struct SolidSyslogBuffer* buffer = NULL; static struct SolidSyslogMutex* bufferMutex = NULL; @@ -492,12 +497,34 @@ static void InteractiveTask(void* argument) }; udpSender = SolidSyslogUdpSender_Create(&udpConfig); + /* Plain TCP path: RFC 6587 octet-framed StreamSender over the LwipRaw TCP + * stream adapter. Shares the resolver and the UDP endpoint callbacks because + * the syslog-ng oracle listens on the same host:port for both transports. + * RtosSleep drives the stream's bounded synchronous-connect spin; the + * connect timeout comes from the SOLIDSYSLOG_TCP_CONNECT_TIMEOUT_MS tunable + * (GetConnectTimeoutMs NULL). */ + struct SolidSyslogLwipRawTcpStreamConfig tcpStreamConfig = { + .GetConnectTimeoutMs = NULL, + .ConnectTimeoutContext = NULL, + .Sleep = RtosSleep, + }; + tcpStream = SolidSyslogLwipRawTcpStream_Create(&tcpStreamConfig); + tcpAddress = SolidSyslogLwipRawAddress_Create(); + struct SolidSyslogStreamSenderConfig tcpConfig = { + .Resolver = resolver, + .Stream = tcpStream, + .Address = tcpAddress, + .Endpoint = GetEndpoint, + .EndpointVersion = GetEndpointVersion, + }; + tcpSender = SolidSyslogStreamSender_Create(&tcpConfig); + /* SwitchingSender lets `set transport ` flip transport at - * runtime. UDP is wired; TCP / TLS route to the shared NullSender (drop on - * the floor) until later E28 stories wire the LwipRaw TCP / TLS senders. */ + * runtime. UDP and TCP are wired; TLS routes to the shared NullSender (drop + * on the floor) until S28.11 wires the LwipRaw TLS sender. */ static struct SolidSyslogSender* inners[BDD_TARGET_SWITCH_COUNT]; inners[BDD_TARGET_SWITCH_UDP] = udpSender; - inners[BDD_TARGET_SWITCH_TCP] = SolidSyslogNullSender_Get(); + inners[BDD_TARGET_SWITCH_TCP] = tcpSender; inners[BDD_TARGET_SWITCH_TLS] = SolidSyslogNullSender_Get(); struct SolidSyslogSwitchingSenderConfig switchConfig = { .Senders = inners, @@ -608,7 +635,10 @@ static void TeardownAll(void) lifecycleMutex = NULL; SolidSyslogSwitchingSender_Destroy(switchingSender); SolidSyslogUdpSender_Destroy(udpSender); + SolidSyslogStreamSender_Destroy(tcpSender); + SolidSyslogLwipRawTcpStream_Destroy(tcpStream); SolidSyslogLwipRawAddress_Destroy(udpAddress); + SolidSyslogLwipRawAddress_Destroy(tcpAddress); SolidSyslogLwipRawDatagram_Destroy(datagram); SolidSyslogLwipRawResolver_Destroy(resolver); } diff --git a/DEVLOG.md b/DEVLOG.md index ac43dd48..59a04cb5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12957,6 +12957,57 @@ MISRA rule — different category, doesn't set precedent. every untriaged style finding becomes a hard gate. Worth E24 discussion. +## 2026-05-29 — S28.10 FreeRtosLwip TCP (octet-framed StreamSender over LwipRawTcpStream) + +### Decisions + +- **Mostly wiring — but TCP did not come entirely "for free".** The conditional + #473 asked whether TCP fell out of S28.09; it didn't quite. Plain delivery and + runtime UDP→TCP switching were pure wiring (a `SolidSyslogStreamSender` over + `SolidSyslogLwipRawTcpStream` in the SwitchingSender's TCP slot, sharing the + resolver + endpoint callbacks; `RtosSleep` already matched + `SolidSyslogSleepFunction`; the adapter sources were already compiled in). But + `tcp_reconnect` exposed a real Tier-2 gap (below), so S28.10 earned its keep. + +- **`LwipRawTcpStream_Send` now fails on a peer close (the reconnect fix).** + Root cause of the `tcp_reconnect` failure: `StreamSender` only reconnects when a + `Send` returns false (or the endpoint version changes) — it never `Read`s. On a + graceful server FIN (syslog-ng config-reload closes the connection), lwIP's + `RecvCallback(NULL)` set `Errored` but left the pcb non-NULL, and `Send` guarded + only on `IsOpen` (pcb != NULL) — so `tcp_write` kept returning `ERR_OK` into a + doomed half-closed connection and the sender never reconnected. Fix: a new + intent-named predicate `LwipRawTcpStream_IsWritable` (`IsOpen && !Errored`) gates + `Send`, so a post-FIN send fails, `StreamSender` closes + reconnects on the next + message, and the recovered message is delivered. `Read` deliberately still runs + while `Errored` (it must drain queued bytes then close on EOF). Driven red→green + by a new `Tests/Lwip` unit test (`SendReturnsFalseAfterPeerFin`); this matches + FreeRTOS-Plus-TCP's send-side closed-socket detection, which is why the +TCP lane + already passed the same scenario. + +- **Lane scope: `(@udp or @tcp)`, TLS still excluded.** The lwIP lane filter went + from `@udp and not @tcp …` to `(@udp or @tcp) and not @tls and not @mtls and not + @store`. That enables `tcp_transport`, `tcp_reconnect`, and the UDP→TCP + `switching_transport` scenario; `tcp_singletask` (`@windows_wip`) and the TCP→TLS + switch (`@tls`) stay excluded. TLS lands in S28.11. + +- **Local-run gotcha recorded.** Any `cmake` configure regenerates the in-tree + `Bdd/features/steps/solidsyslog_tunables.py`; an intermediate host `debug` build + (default 2048) clobbered the lwIP target's 512, so a stray oracle run *ran* the + `@requires_message_size_1500` `udp_mtu` scenario (which must skip on a 512-byte + target) and failed spuriously. Re-running the cross-lwip configure restored 512 + and the lane is green. CI is immune — each lane downloads its own + `bdd-tunables-` artifact. + +### Verification + +- Oracle (`ci/docker-compose.bdd.yml`, lwIP pair): **11 features / 28 scenarios / + 116 steps passed, 0 failed** — `tcp_transport`, `tcp_reconnect`, and UDP→TCP + switch green alongside the full UDP set; TLS/mTLS/store/1500-MTU correctly + skipped. `Tests/Lwip/SolidSyslogLwipRawTcpStreamTest` 56 tests green. Tree + clang-format clean. + +--- + ## 2026-05-29 — S28.09 FreeRtosLwip LAN9118 netif + NO_SYS=0 UDP on QEMU ### Decisions diff --git a/ci/docker-compose.bdd.yml b/ci/docker-compose.bdd.yml index a59c9696..09b3776c 100644 --- a/ci/docker-compose.bdd.yml +++ b/ci/docker-compose.bdd.yml @@ -123,13 +123,13 @@ services: # solidsyslog_user_tunables.h changes. command: behave --junit --junit-directory Bdd/junit --tags='not @wip and not @freertoswip and not @rtc and not @windows_wip and (@udp or @tcp or @tls or @mtls)' Bdd/features/ - # FreeRTOS + lwIP pair (S28.09). Same shape as the freertos (+TCP) pair — - # its own syslog-ng on its own bridge network, behave sharing the netns so - # QEMU's slirp gateway 10.0.2.2 NATs to the pair's loopback where syslog-ng - # listens on 0.0.0.0:5514. The only differences are EXAMPLE_BINARY (the lwIP - # ELF) and the tag filter: this advisory lane runs UDP only for S28.09 — - # TCP / TLS / mTLS / store land in later E28 stories, and @freertoslwipwip - # is the per-scenario escape hatch while the netif is bedding in. + # FreeRTOS + lwIP pair (S28.09 UDP, S28.10 TCP). Same shape as the freertos + # (+TCP) pair — its own syslog-ng on its own bridge network, behave sharing the + # netns so QEMU's slirp gateway 10.0.2.2 NATs to the pair's loopback where + # syslog-ng listens on 0.0.0.0:5514. The only differences are EXAMPLE_BINARY + # (the lwIP ELF) and the tag filter: this advisory lane runs UDP + TCP — + # TLS / mTLS / store land in later E28 stories, and @freertoslwipwip is the + # per-scenario escape hatch while the netif is bedding in. syslog-ng-freertos-lwip: image: balabit/syslog-ng:4.8.2 volumes: @@ -178,7 +178,7 @@ services: # ELF rides the existing freertos driver — only the binary path differs. - BDD_TARGET=freertos - EXAMPLE_BINARY=build/freertos-cross-lwip/Bdd/Targets/FreeRtosLwip/SolidSyslogBddTargetLwip.elf - command: behave --junit --junit-directory Bdd/junit --tags='not @wip and not @freertoswip and not @freertoslwipwip and not @rtc and not @windows_wip and @udp and not @tcp and not @tls and not @mtls and not @store' Bdd/features/ + command: behave --junit --junit-directory Bdd/junit --tags='not @wip and not @freertoswip and not @freertoslwipwip and not @rtc and not @windows_wip and (@udp or @tcp) and not @tls and not @mtls and not @store' Bdd/features/ volumes: bdd-output-linux: From 4b7956b41717c735225e9eebd7a1da39c37749dc Mon Sep 17 00:00:00 2001 From: David Cozens Date: Fri, 29 May 2026 20:46:45 +0100 Subject: [PATCH 3/3] fix: S28.10 re-anchor MISRA suppressions after LwipRawTcpStream line shift The IsWritable forward declaration shifted the existing 11.3 / 11.5 cast suppressions in SolidSyslogLwipRawTcpStream.c down one line (129->130, 138->139, 146->147), so cppcheck-misra re-fired previously-deviated findings. Re-anchored via scripts/misra_renumber.py --apply; the gate exits 0 again. Co-Authored-By: Claude Opus 4.8 (1M context) --- misra_suppressions.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/misra_suppressions.txt b/misra_suppressions.txt index eca76ef0..75795f2d 100644 --- a/misra_suppressions.txt +++ b/misra_suppressions.txt @@ -42,7 +42,7 @@ misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawAddressPrivate.h:31 misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawAddressStatic.c:34 misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawAddressStatic.c:54 misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawDatagram.c:61 -misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:129 +misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:130 misra-c2012-11.3:Platform/PlusTcp/Source/SolidSyslogPlusTcpAddress.c:10 misra-c2012-11.3:Platform/PlusTcp/Source/SolidSyslogPlusTcpAddressPrivate.h:19 misra-c2012-11.3:Platform/PlusTcp/Source/SolidSyslogPlusTcpAddressPrivate.h:26 @@ -76,8 +76,8 @@ misra-c2012-11.3:Platform/Windows/Source/SolidSyslogWinsockDatagram.c:94 misra-c2012-11.3:Platform/Windows/Source/SolidSyslogWinsockTcpStream.c:181 misra-c2012-11.5:Core/Source/SolidSyslogUdpSender.c:205 misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawDatagram.c:70 -misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:138 -misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:146 +misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:139 +misra-c2012-11.5:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:147 misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:271 misra-c2012-11.5:Platform/MbedTls/Source/SolidSyslogMbedTlsStream.c:283 misra-c2012-11.5:Platform/Windows/Source/SolidSyslogWinsockDatagram.c:138