Skip to content

S28.05: SolidSyslogLwipRawTcpStream #465

Description

@DavidCozens

Parent epic: #439 (E28: lwIP Raw API support)

Third source slice of the Platform/LwipRaw/ tree. Lands the TCP
stream class — SolidSyslogLwipRawTcpStream — wrapping lwIP Raw's
tcp_new / tcp_connect / tcp_write / tcp_output / tcp_recv /
tcp_recved / tcp_close / tcp_abort against the
SolidSyslogStream vtable. Composes against the
SolidSyslogLwipRawAddress landed in S28.03 and bridges lwIP's
callback Raw TCP API to SolidSyslog's synchronous
Stream_Open / Send / Read / Close contract. Also lands
docs/integrating-lwip.md covering the threading + lwipopts.h
expectations both this story and S28.04 surface (the natural home for
the tcp_close-after-tcp_err background and the ARP_QUEUEING
recovery note carried over from S28.04).

Platform-agnostic by design. Per the epic's framing,
Platform/LwipRaw/Source/ wraps lwIP only — zero direct calls to any
host RTOS primitive. The synchronous-Open spin loop needs a bounded
sleep; that primitive is the existing SolidSyslogSleepFunction
typedef from Core/Interface/SolidSyslogSleep.h, injected via the
Create-time config. The integrator wires the platform-appropriate
impl (vTaskDelay under FreeRTOS, usleep under POSIX, a
sys_check_timeouts-plus-RX pump under bare-metal NO_SYS=1) —
Platform/LwipRaw/Source/ never sees vTaskDelay or any sibling.

Mirrors the three-TU-per-class shape established by E11 (Class.c
vtable + Initialise/Cleanup, ClassPrivate.h struct + private sigs,
ClassStatic.c pool + Create/Destroy + errors, ClassMessages.c
error-source registration). Pool exhaustion and bad-config (NULL
Sleep) resolve to the shared SolidSyslogNullStream. Core untouched
modulo the three new tunable defaults.

Design choices

1. tcp_write ownership — TCP_WRITE_FLAG_COPY set

tcp_write(pcb, data, len, flags) accepts two ownership flavours:

  • TCP_WRITE_FLAG_COPY set — lwIP copies data into its own
    pbufs before returning. Caller buffer free immediately on return.
  • TCP_WRITE_FLAG_COPY clear — lwIP queues a reference to the
    caller's buffer. The buffer must live until lwIP's sent callback
    fires (after the peer ACKs, possibly after retransmits). Cannot be
    honoured from a synchronous Stream_Send(buf, len) contract whose
    lifetime ends at return.

We use COPY. This deliberately diverges from the S28.04 Datagram
class's PBUF_REF — because udp_sendto either ships synchronously or
lets etharp_query clone-on-queue, so the caller's buffer is safe in
both branches. tcp_write defers transmission and depends on
retransmits + ACKs; the buffer must outlive every code path the
caller cannot observe. COPY costs one memcpy per send; syslog
records are small and the cost is well within budget.

tcp_write only queues into pcb->snd_buf; tcp_output triggers
the actual transmission. We call both in succession on every Send.
If tcp_output returns ERR_MEM the data is still queued — lwIP
retries on the next tcp_tmr tick — and we treat this as
Send-success (lwIP owns the bytes, matching POSIX's "kernel accepted
the data into the send buffer" semantics).

2. Synchronous Open via spin-with-injected-sleep

tcp_connect(pcb, ip, port, connected_cb) returns immediately. The
TCP SYN/SYN-ACK exchange completes when connected_cb fires. Our
SolidSyslogStream_Open(addr) contract is synchronous.

The wrapper spins on a Connected flag set by the registered
connected_cb, bounded by the existing per-instance
GetConnectTimeoutMs getter (the S12.17 pattern — default tunable
SOLIDSYSLOG_TCP_CONNECT_TIMEOUT_MS = 200 ms). Each iteration sleeps
for SOLIDSYSLOG_LWIP_RAW_TCP_CONNECT_POLL_MS (new tunable, default
10 ms) via the integrator-injected SolidSyslogSleepFunction so the
spin doesn't starve lwIP's timer / RX paths.

Semaphore-wakeup ruled out. Under NO_SYS=1 lwIP's
connected_cb runs in the same thread as the caller's Open (the
integrator's main loop drives both), so there is no other thread to
post a semaphore from. Spin-with-injected-sleep covers both
NO_SYS=1 (integrator's Sleep ticks lwIP timers + drives RX for
N ms) and NO_SYS=0 (Sleep yields to the tcpip thread via
vTaskDelay / equivalent) with one code path.

Connect failure modes:

  • tcp_connect returns non-ERR_OK immediately → abort pcb, Open
    returns false.
  • connected_cb fires with non-ERR_OK (RST received during
    handshake, etc.) → set Errored, abort pcb, Open returns false.
  • Timeout expires before either callback fires → tcp_abort the
    pcb (lwIP-idiomatic way to give up on an in-flight connect),
    Open returns false.

3. RX queue draining the tcp_recv callback

tcp_recv(pcb, recv_cb) registers a callback that fires when a pbuf
arrives. Our Stream_Read is synchronous.

Shape:

  • A bounded struct pbuf* ring on the per-pcb adapter state, sized
    by the new tunable SOLIDSYSLOG_LWIP_RAW_TCP_RX_QUEUE_SIZE
    (default 8 — sized for the typical mTLS handshake flight; the
    bound is on the number of pbufs queued, not bytes; lwIP's
    TCP_WND and MEMP_NUM_PBUF already cap the upstream byte
    volume).
  • The tcp_recv callback enqueues the incoming pbuf and returns
    ERR_OK. If the queue is full it returns non-ERR_OK so lwIP
    keeps the pbuf and replays the callback later (lwIP's flow-control
    hook).
  • Stream_Read dequeues bytes from the head pbuf, calls
    tcp_recved(pcb, n) to ACK back to lwIP's receive window. When
    the head pbuf is fully drained, pbuf_frees it and advances.
  • Peer half-close: tcp_recv fires with p == NULL. We set the
    Errored flag; next Stream_Read returns -1 (EOF — closes
    internally per the Stream contract). Drains any queued bytes
    first.

4. Peer FIN / RST — tcp_close-after-tcp_err gotcha encapsulated

lwIP's tcp_err callback fires for fatal errors (RST, OOM, ABRT)
and releases the pcb before calling our callback — calling
tcp_close on the same pcb afterwards is a use-after-free.

We encapsulate the rule inside the wrapper:

  • The tcp_err_cb we register nulls self->Pcb and sets the
    Errored flag.
  • Stream_Close checks if (self->Pcb != NULL) tcp_close(self->Pcb);
    before calling tcp_close. Idempotent and safe whether the prior
    failure was a peer RST (Pcb already null), a peer FIN (Pcb still
    valid), or a clean shutdown.

Integrators never see this — docs/integrating-lwip.md mentions it
only as background ("the wrapper owns its pcb lifecycle — don't
poke at lwIP pcbs through the abstraction").

5. Keepalive — SOF_KEEPALIVE set on every pcb

Match SolidSyslogPosixTcpStream's behaviour (PlusTcp doesn't,
because FreeRTOS+TCP doesn't expose per-pcb keepalive). After
tcp_new we set pcb->so_options |= SOF_KEEPALIVE. The idle /
interval / probe-count values are pulled from the integrator's
lwipopts.h (TCP_KEEPIDLE_DEFAULT / TCP_KEEPINTVL_DEFAULT /
TCP_KEEPCNT_DEFAULT) — we don't override them.

If the integrator compiled lwIP with LWIP_TCP_KEEPALIVE=0 the
SOF_KEEPALIVE bit is a no-op — the connection still works, just
without dead-peer detection. docs/integrating-lwip.md documents
LWIP_TCP_KEEPALIVE=1 as a recommended-on lwipopts setting.

6. Threading — integrator marshals tcpip_callback() at the SolidSyslog boundary

Under NO_SYS=0, lwIP requires all Raw-API calls from outside the
tcpip thread to go via tcpip_callback(). The wrapper does not
marshal internally — that would force LWIP_TCPIP_CORE_LOCKING=1 on
every integrator and pull tcpip.c into the build.

The integrator marshals at the SolidSyslog API boundary instead
(their Service loop calls tcpip_callback(do_service) which calls
into our adapter on the tcpip thread). docs/integrating-lwip.md
documents this rule. Same shape as S28.04 Datagram.

7. Failure mapping to the SolidSyslogStream contract

SolidSyslogStream contract (per Core/Interface/SolidSyslogStream.h):

  • Open true/false; on failure underlying state is closed
    internally, caller can Open again to retry.
  • Send true only if the entire frame is accepted in one call; any
    failure closes internally.
  • Read > 0 bytes, = 0 would-block, < 0 EOF/error (closes
    internally).
  • Close idempotent.

Mapping to lwIP:

lwIP outcome SolidSyslogStream result
tcp_new returns NULL Open false
tcp_connect immediate non-ERR_OK Errored, abort, Open false
connected_cb non-ERR_OK Errored, abort, Open false
Connect timeout abort, Open false
tcp_write non-ERR_OK close internally, Send false
tcp_output ERR_MEM after successful tcp_write Send true (queued, lwIP-retry)
tcp_output other non-ERR_OK close internally, Send false
tcp_err callback fires Pcb nulled, Errored — Send → false, Read → -1 on next call
Peer FIN (tcp_recv with p == NULL) Errored after drain — Read → -1 once queue empty

Scope

New files — Platform/LwipRaw/

Platform/LwipRaw/Interface/
    SolidSyslogLwipRawTcpStream.h          (Config + Create/Destroy)
    SolidSyslogLwipRawTcpStreamErrors.h    (POOL_EXHAUSTED + UNKNOWN_DESTROY + MAX, ErrorSource)
Platform/LwipRaw/Source/
    SolidSyslogLwipRawTcpStream.c          (vtable + Initialise/Cleanup; Open/Send/Read/Close + lwIP callbacks)
    SolidSyslogLwipRawTcpStreamMessages.c  (ErrorSource const)
    SolidSyslogLwipRawTcpStreamPrivate.h   (struct + Initialise/Cleanup sigs)
    SolidSyslogLwipRawTcpStreamStatic.c    (pool + Create/Destroy + ConfigLock-wrapped slot walks)

Platform/LwipRaw/CMakeLists.txt adds the four new sources.

TcpStream struct shape (Private.h)

struct SolidSyslogLwipRawTcpStream
{
    struct SolidSyslogStream                 Base;
    struct SolidSyslogLwipRawTcpStreamConfig Config;
    struct tcp_pcb*                          Pcb;
    bool                                     Connected;     /* set by connected_cb on ERR_OK */
    bool                                     Errored;       /* latched until Close */
    struct pbuf*                             RxQueue[SOLIDSYSLOG_LWIP_RAW_TCP_RX_QUEUE_SIZE];
    size_t                                   RxQueueHead;   /* index of next pbuf to drain */
    size_t                                   RxQueueCount;  /* number of pbufs currently queued */
    size_t                                   RxHeadOffset;  /* bytes already consumed from RxQueue[RxQueueHead] */
};

Config struct (Interface.h)

struct SolidSyslogLwipRawTcpStreamConfig
{
    SolidSyslogTcpConnectTimeoutFunction GetConnectTimeoutMs; /* NULL → SOLIDSYSLOG_TCP_CONNECT_TIMEOUT_MS tunable */
    void*                                ConnectTimeoutContext;
    SolidSyslogSleepFunction             Sleep;               /* required; NULL → NullStream fallback */
};

Sleep is required — the synchronous-Open spin loop has nowhere
to go without it. NULL Sleep is treated as bad config and Create
resolves to SolidSyslogNullStream (matches the pattern used by
SolidSyslogUdpSender for NULL Address).

Public Create / Destroy

struct SolidSyslogStream* SolidSyslogLwipRawTcpStream_Create(const struct SolidSyslogLwipRawTcpStreamConfig* config);
void                      SolidSyslogLwipRawTcpStream_Destroy(struct SolidSyslogStream* base);

New tunables — Core/Interface/SolidSyslogTunablesDefaults.h

Tunable Default Reason
SOLIDSYSLOG_LWIP_RAW_TCP_STREAM_POOL_SIZE 2U Matches SOLIDSYSLOG_POSIX_TCP_STREAM_POOL_SIZE / SOLIDSYSLOG_WINSOCK_TCP_STREAM_POOL_SIZE / SOLIDSYSLOG_PLUS_TCP_TCP_STREAM_POOL_SIZE defaults — covers the TLS-over-plain-TCP pair.
SOLIDSYSLOG_LWIP_RAW_TCP_CONNECT_POLL_MS 10 20 polls inside the 200 ms connect-timeout default; balances spin overhead against latency to notice a fast connect.
SOLIDSYSLOG_LWIP_RAW_TCP_RX_QUEUE_SIZE 8 Bound on number of pbufs queued (not bytes — lwIP's TCP_WND / MEMP_NUM_PBUF cap upstream byte volume). Sized for the typical mTLS handshake flight (ServerHello + Certificate + ServerKeyExchange + ServerHelloDone is 2-4 segments; 8 leaves margin).

Test scaffolding — Tests/Support/LwipFakes/

Extends the LwipFakes tree added in S28.04 with one new fake —
LwipTcpFake.{c,h} — covering:

  • tcp_new: counter, last-args, failure-injection (return NULL).
  • tcp_arg: counter, last-args (captures the arg we register).
  • tcp_err: counter, captures the err_cb so tests can drive it.
  • tcp_recv: counter, captures the recv_cb so tests can drive
    incoming pbufs.
  • tcp_sent: counter, captures the sent_cb (we register a no-op
    for COPY mode but lwIP requires it set).
  • tcp_connect: counter, last-args, captures the connected_cb,
    failure-injection (immediate non-ERR_OK).
  • tcp_write: counter, last-args, failure-injection.
  • tcp_output: counter, last-pcb, failure-injection.
  • tcp_recved: counter, last-args (window-update ACK).
  • tcp_close: counter, last-pcb, failure-injection.
  • tcp_abort: counter, last-pcb.
  • LwipTcpFake_OutstandingPcbCount (leak invariant — every
    tcp_new pcb reclaimed by tcp_close / tcp_abort / null-via-
    tcp_err).

Sources inline into the test exe via the existing
LWIP_FAKE_SOURCES variable in Tests/Lwip/CMakeLists.txt.

Test file — Tests/Lwip/SolidSyslogLwipRawTcpStreamTest.cpp

TEST_BASE + TEST_GROUP_BASE split between "Created-only" and
"Open + Connected" groups. Mirrors Tests/FreeRtos/SolidSyslogPlusTcpTcpStreamTest.cpp
and the S28.04 Datagram test shape (leak invariants in shared
teardown, sendBytes-style helper). Note that test names are
indicative — exact phrasings settle during the red/green/refactor
loop, and the list may grow or shrink as the design lands.

Created-only group:

  • CreateReturnsNonNullStream
  • CreateWithNullSleepReturnsFallback (Null Object — calls to
    fallback Send return false, Read returns -1, Close is no-op)
  • CreateZeroesPcbAndQueueState
  • Ten pool tests (mirror PlusTcpTcpStream's pool block 1:1 with
    names substituted: FillPool / Overflow / ExhaustedReportsError /
    FallbackVtableMethodsAreNoOps / four ConfigLock tests /
    two Destroy-of-unknown / Destroy-of-stale)
  • OpenCallsTcpNew
  • OpenReturnsFalseWhenTcpNewFails
  • OpenCallsTcpConnect
  • OpenRegistersTcpArgRecvErrSentCallbacks
  • OpenSetsKeepaliveOnPcb
  • OpenReturnsTrueWhenConnectedCallbackFiresOk
  • OpenReturnsFalseAndAbortsWhenConnectedCallbackFiresErrored
  • OpenReturnsFalseAndAbortsOnTimeout
  • OpenSleepsBetweenPolls
  • OpenRespectsRuntimeTunableConnectTimeout
  • SendBeforeOpenReturnsFalse
  • ReadBeforeOpenReturnsMinusOne
  • CloseBeforeOpenIsNoOp
  • DestroyBeforeOpenIsNoOp

Connected group (Open called in setup):

  • SendCallsTcpWriteWithCopyFlag
  • SendCallsTcpOutputAfterTcpWrite
  • SendReturnsTrueOnTcpWriteOk
  • SendReturnsFalseAndClosesOnTcpWriteFails
  • SendReturnsTrueWhenTcpOutputDefersWithErrMem
  • SendReturnsFalseAndClosesOnTcpOutputOtherError
  • ReadReturnsZeroWhenQueueEmpty
  • ReadReturnsBytesFromHeadPbuf
  • ReadCallsTcpRecvedAfterDrain
  • ReadAdvancesPastDrainedPbuf
  • ReadDrainsBeforeReportingPeerFin
  • ReadReturnsMinusOneOnPeerFinAfterDrain
  • ReadReturnsMinusOneAfterTcpErr
  • RecvCallbackBackpressuresWhenQueueFull (returns non-ERR_OK)
  • TcpErrCallbackNullsPcbAndSetsErrored
  • CloseAfterTcpErrDoesNotCallTcpClose (encapsulated gotcha,
    Decision 4)
  • CloseAfterPeerFinCallsTcpClose
  • CloseDrainsRxQueue
  • CloseIsIdempotent
  • DestroyClosesOpenPcb
  • DestroyDrainsRxQueueOnCleanup (leak invariant)
  • DestroyAfterCloseDoesNotCloseAgain

Tests/Lwip/CMakeLists.txt

New executable SolidSyslogLwipRawTcpStreamTest registered
alongside Address / Resolver / Datagram. Sources: test cpp + the
three TcpStream production TUs + the three Address production TUs.
Links LwipTcpFake + LwipPbufFake + ConfigLockFake +
ErrorHandlerFake + CppUTest.

misra_suppressions.txt

Expect at minimum
misra-c2012-11.3:Platform/LwipRaw/Source/SolidSyslogLwipRawTcpStream.c:<line>
(SelfFromBase — same shape as every other adapter, falls under
D.002). If cppcheck-MISRA surfaces additional entries, each gets a
header pointing at the matching sibling-backend entry and a
cross-reference in docs/misra-deviations.md.

docs/integrating-lwip.md — new file

Covers:

  • NO_SYS=1 vs NO_SYS=0: positioning paragraphs. SolidSyslog
    supports both; the differences are entirely in how the integrator
    drives lwIP forward (main-loop pump vs tcpip thread).
  • tcpip_callback() threading rule (NO_SYS=0 only): all
    Raw-API calls from outside the tcpip thread must be marshalled.
    Integrator marshals at the SolidSyslog API boundary
    (Service / Log entry), not inside the wrapper.
  • lwipopts.h expectations: LWIP_RAW=1, LWIP_UDP=1,
    LWIP_TCP=1, ARP_QUEUEING=1 (Datagram first-packet recovery —
    the S28.04 footgun), LWIP_TCP_KEEPALIVE=1 recommended (with
    TCP_KEEPIDLE_DEFAULT / TCP_KEEPINTVL_DEFAULT /
    TCP_KEEPCNT_DEFAULT tuning), TCP_MSS / PBUF_POOL_SIZE sizing
    notes, MEMP_NUM_TCP_PCB sizing for the pool default.
  • Sleep callback wiring for the TcpStream: integrator-supplied.
    Examples: FreeRTOS vTaskDelay(pdMS_TO_TICKS(ms)); POSIX
    usleep((useconds_t) ms * 1000U); bare-metal "tick
    sys_check_timeouts() + drive RX for ms milliseconds".
  • SolidSyslog tunables relevant to lwIP integrators:
    SOLIDSYSLOG_LWIP_RAW_*_POOL_SIZE,
    SOLIDSYSLOG_TCP_CONNECT_TIMEOUT_MS (existing per-instance
    getter), SOLIDSYSLOG_LWIP_RAW_TCP_CONNECT_POLL_MS,
    SOLIDSYSLOG_LWIP_RAW_TCP_RX_QUEUE_SIZE.
  • Background notes: pbuf strategy differs UDP (REF) vs TCP
    (COPY); the wrapper owns its pcb lifecycle so integrators never
    call tcp_close directly through the abstraction.

CLAUDE.md updates

Two new rows in the public-headers table, slotted near the existing
LwipRaw rows:

Header Audience Provides
SolidSyslogLwipRawTcpStream.h System setup code on any target using lwIP Raw API wiring a TCP sender (or a byte transport for SolidSyslogTlsStream / SolidSyslogMbedTlsStream) SolidSyslogLwipRawTcpStreamConfig (GetConnectTimeoutMs + context + Sleep), SolidSyslogLwipRawTcpStream_Create(config), _Destroy(base) (wraps tcp_new / tcp_connect / tcp_write (COPY) / tcp_output / tcp_recv / tcp_recved / tcp_close / tcp_abort; integrator-supplied Sleep drives the bounded connect spin; sets SOF_KEEPALIVE; bounded RX pbuf queue via SOLIDSYSLOG_LWIP_RAW_TCP_RX_QUEUE_SIZE). Instance struct lives in a library-internal static pool (E11); default pool size 2 to support the future TLS-over-plain-TCP pair. Pool-exhaustion and bad-config fallback is the shared SolidSyslogNullStream.
SolidSyslogLwipRawTcpStreamErrors.h Any code installing an error handler that wants to react to LwipRawTcpStream-specific events… enum SolidSyslogLwipRawTcpStreamErrors (LWIPRAWTCPSTREAM_ERROR_* + _MAX), LwipRawTcpStreamErrorSource.

Top-level CMakeLists.txt housekeeping

  • Update both message(STATUS) strings for
    SOLIDSYSLOG_FREERTOS_NET=LWIP and =BOTH: drop "TcpStream" from
    the remaining list — only "BDD arrives in S28.06" left.
  • Update the matching comment block at CMakeLists.txt:155 so its
    narrative tracks the STATUS strings (the deferred CodeRabbit nit
    from PR feat: S28.04 SolidSyslogLwipRawDatagram #464).

Acceptance

  • cmake --preset freertos-cross -DSOLIDSYSLOG_FREERTOS_NET=LWIP
    builds with the new TcpStream sources in the graph.
  • cmake --preset freertos-cross -DSOLIDSYSLOG_FREERTOS_NET=BOTH
    builds and links both PlusTcp TcpStream and LwipRaw TcpStream
    sources.
  • New host-side test SolidSyslogLwipRawTcpStreamTest passes on the
    cpputest-freertos image (the only one shipping lwIP today;
    skipped under base cpputest / cpputest-clang per the
    LWIP_PATH guard locked in at S28.03).
  • 100% line + branch coverage on the new sources.
  • All four PlusTcp host-tests (Address, Datagram, Resolver,
    TcpStream) still pass under =PLUSTCP and =BOTH.
  • analyze-tidy, analyze-cppcheck (incl. cppcheck-misra invariant),
    analyze-format, analyze-iwyu — clean.
  • grep -rn 'LwipRaw' Core/ returns only the existing
    S28.03/S28.04 tunable lines plus the three new ones added in this
    story — no other Core touched.
  • docs/integrating-lwip.md exists.
  • S28.04's STATUS messages and the comment at CMakeLists.txt:155
    align — the deferred PR feat: S28.04 SolidSyslogLwipRawDatagram #464 nit is closed.

Out of scope

  • Bdd/Targets/FreeRtosLwip/ + three new CI jobs (host-tdd-lwip,
    target-lwip, qemu-lwip) — S28.06.
  • DNS resolver — S28.07 sibling class
    SolidSyslogLwipRawDnsResolver.
  • IPv6, jumbo-frame MTU discovery, multi-netif TcpStream — out of
    epic scope.
  • Internal marshalling via tcpip_callback() from the wrapper
    (Decision 6 — integrator's responsibility).
  • A SolidSyslogSemaphore abstraction for the connect-wait
    (ruled out — would not cover NO_SYS=1).

Breaking changes

None — entirely new files behind two new headers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    storyStory issue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions