ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
tcp.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file tcp.cpp
6 * @brief Layer 4 (Transport) - TCP connection management implementation.
7 *
8 * All lwIP raw-API callbacks run in the `tcpip_thread` FreeRTOS task context.
9 * They are NOT hardware ISRs, which is why we use `xQueueSend()` (non-ISR
10 * variant) with timeout=0 rather than `xQueueSendFromISR()`.
11 *
12 * **Ring buffer write ordering**
13 * The producer (recv callback) writes payload bytes into `rx_buffer[]` and
14 * then advances `rx_head`. The consumer (worker) reads from `rx_buffer[]` at
15 * `rx_tail` and advances `rx_tail`. `rx_head`/`rx_tail` are `pc_atomic`
16 * (acquire/release): the producer's buffer writes are published by the release
17 * store of `rx_head` and observed by the consumer's acquire load, correct on
18 * either core. The ring math itself is the shared `ring.h` primitive.
19 *
20 * **Listener coupling**
21 * Each TcpConn carries `listener_id` (set at accept time by listener_accept_cb
22 * in listener.cpp). The `enqueue()` helper forwards events to
23 * `listener_enqueue()` - defined in listener.cpp - so tcp.cpp needs no
24 * direct knowledge of the Listener struct. `listener_enqueue()` is forward-
25 * declared in tcp.h to avoid a circular include.
26 */
27
28#include "tcp.h"
29#include "diffserv.h" // DiffServ DSCP marking (pc_dscp_to_tos, pc_conn_set_dscp); compiles out when off
30#include "freertos/FreeRTOS.h"
31#include "freertos/queue.h"
32#include "freertos/task.h" // xTaskGetCurrentTaskHandle() - tcpip_thread self-detection for pc_tcp_marshal
33#include "lwip/tcp.h"
34#include "services/system/clock.h" // pc_millis() pluggable monotonic clock
35
36#ifdef ARDUINO
37#include "network_drivers/session/worker.h" // pc_worker_wake() - resume a paced send when the window drains
38#endif
39
40#if PC_ENABLE_TLS
42#endif
43
44// ---------------------------------------------------------------------------
45// Observability (PC_ENABLE_OBSERVABILITY) - event hook + lock-free counters.
46// Zero cost when off: OBS_TRANSITION / OBS_NOTICE expand to nothing and their
47// arguments (incl. the pc_conn_reason names, which are only declared when the
48// feature is on) are dropped unparsed by the preprocessor.
49// ---------------------------------------------------------------------------
50#if defined(ARDUINO)
51#include "lwip/priv/tcpip_priv.h"
52#include <string.h>
53#endif
54#if PC_ENABLE_OBSERVABILITY
55#include <atomic>
56
57// All connection-observability state, owned by one instance (internal linkage): the event
58// callback and the cumulative per-reason counters (indexed 0..7). The live ConnState::CONN_CLOSING gauge
59// is not a counter - it is derived on read by scanning the pool, so it can never drift out of
60// sync with the actual slot states. One named owner, unreachable from any other TU.
61struct ObsCtx
62{
63 pc_conn_event_cb conn_event_cb = nullptr;
64 std::atomic<uint32_t> ctr[8];
65};
66static ObsCtx s_obs;
67
68void pc_conn_on_event(pc_conn_event_cb cb)
69{
70 s_obs.conn_event_cb = cb;
71}
72
73pc_conn_counters pc_conn_counters_get()
74{
75 pc_conn_counters c;
76 c.accepts = s_obs.ctr[0].load(std::memory_order_relaxed);
77 c.closes_remote = s_obs.ctr[1].load(std::memory_order_relaxed);
78 c.closes_local = s_obs.ctr[2].load(std::memory_order_relaxed);
79 c.closes_error = s_obs.ctr[3].load(std::memory_order_relaxed);
80 c.closes_timeout = s_obs.ctr[4].load(std::memory_order_relaxed);
81 c.closes_abort = s_obs.ctr[5].load(std::memory_order_relaxed);
82 c.backpressure = s_obs.ctr[6].load(std::memory_order_relaxed);
83 c.defer_drops = s_obs.ctr[7].load(std::memory_order_relaxed);
84 // Derive the live gauge from the actual pool so it cannot drift.
85 c.closing_gauge = 0;
86 for (int i = 0; i < MAX_CONNS; i++)
87 {
88 if (conn_pool[i].state == ConnState::CONN_CLOSING)
89 {
90 c.closing_gauge++;
91 }
92 }
93 return c;
94}
95
96void pc_conn_counters_reset()
97{
98 for (int i = 0; i < 8; i++)
99 {
100 s_obs.ctr[i].store(0, std::memory_order_relaxed);
101 }
102}
103
104static void obs_bump(pc_conn_reason reason)
105{
106 int idx = -1;
107 // Every pc_conn_reason enumerator has a case and there is no default label, so the compiler's
108 // implicit-default arm is unreachable for any valid enum value.
109 switch (reason) // GCOVR_EXCL_BR_LINE
110 {
111 case pc_conn_reason::PC_CONN_R_ACCEPT:
112 idx = 0;
113 break;
114 case pc_conn_reason::PC_CONN_R_CLOSE_REMOTE:
115 idx = 1;
116 break;
117 case pc_conn_reason::PC_CONN_R_CLOSE_LOCAL:
118 idx = 2;
119 break;
120 case pc_conn_reason::PC_CONN_R_ERROR:
121 idx = 3;
122 break;
123 case pc_conn_reason::PC_CONN_R_TIMEOUT:
124 idx = 4;
125 break;
126 case pc_conn_reason::PC_CONN_R_ABORT:
127 idx = 5;
128 break;
129 case pc_conn_reason::PC_CONN_R_BACKPRESSURE:
130 idx = 6;
131 break;
132 case pc_conn_reason::PC_CONN_R_DEFER_DROP:
133 idx = 7;
134 break;
135 case pc_conn_reason::PC_CONN_R_DRAINED:
136 idx = -1; // the entering close reason was already counted; DRAINED is gauge-only
137 break;
138 }
139 if (idx >= 0)
140 {
141 s_obs.ctr[idx].fetch_add(1, std::memory_order_relaxed);
142 }
143}
144
145// A real state transition: bump the reason counter and fire the callback. The
146// ConnState::CONN_CLOSING gauge is derived on read (see pc_conn_counters), so there is no
147// per-transition gauge bookkeeping to get wrong. Non-static so listener.cpp
148// (accept) can notify through the PC_OBS_TRANSITION macro declared in tcp.h.
149void pc_obs_transition(uint8_t slot, ConnState olds, ConnState news, pc_conn_reason reason)
150{
151 obs_bump(reason);
152 if (s_obs.conn_event_cb)
153 {
154 s_obs.conn_event_cb(slot, olds, news, reason);
155 }
156}
157
158// A non-transition notice (backpressure / defer-drop): bump + fire with old==new.
159void pc_obs_notice(uint8_t slot, ConnState st, pc_conn_reason reason)
160{
161 obs_bump(reason);
162 if (s_obs.conn_event_cb)
163 {
164 s_obs.conn_event_cb(slot, st, st, reason);
165 }
166}
167#endif // PC_ENABLE_OBSERVABILITY
168
169// ---------------------------------------------------------------------------
170// Cross-thread TCP serialization
171// ---------------------------------------------------------------------------
172// The raw lwIP API is not thread-safe: its callbacks run in the tcpip_thread
173// task, while this library issues writes/closes from the Arduino main-loop task.
174// Calling tcp_*() from the main loop concurrently with tcpip_thread processing
175// an inbound segment corrupts the PCB state - under a streaming upload (the peer
176// is actively sending as the server responds/closes) it trips lwIP's
177// "tcp_receive: wrong state" assert and panics.
178//
179// Arduino-esp32 ships lwIP with LWIP_TCPIP_CORE_LOCKING disabled, so
180// LOCK_TCPIP_CORE() is a no-op. The portable fix is tcpip_api_call(): it runs a
181// function *inside* tcpip_thread and blocks the caller until it completes, so
182// every main-loop-originated tcp_*() executes in the one safe context. lwIP's
183// own callbacks already run in that context and must NOT marshal again (they
184// call tcp_*() directly).
185// ConnState::CONN_CLOSING dwell helpers (defined below, near pc_conn_begin_close). Forward
186// declared so the tcpip-thread op dispatch can reach closing_check().
187static void closing_check(uint8_t slot, struct tcp_pcb *pcb);
188
189#if defined(ARDUINO)
190
191enum class pc_tcp_op : uint8_t
192{
198 PC_OP_RAWSEND, // raw tcp_write of already-encrypted bytes (TLS BIO), no TLS re-entry
199 PC_OP_CLOSE_CHECK, // in tcpip_thread: finalize a ConnState::CONN_CLOSING slot if its TX has drained
200 PC_OP_RECVED, // in tcpip_thread: tcp_recved() to reopen the window (ack-on-consume)
201#if PC_ENABLE_DIFFSERV
202 PC_OP_SET_TOS // in tcpip_thread: set pcb->tos (DiffServ DSCP) on a live connection; k->len carries the byte
203#endif
204};
205
206// TCP transport context, owned by one instance (internal linkage): the tcpip_thread FreeRTOS task
207// handle, captured the first time pc_tcp_do() runs (that op is always marshaled onto tcpip_thread).
208// pc_tcp_marshal() compares the running task against it, so a raw lwIP callback - which runs in
209// tcpip_thread but does NOT enter through pc_tcp_do, so a plain "inside pc_tcp_do" flag reads false
210// there - performs its op inline instead of re-marshaling, which would block on the very mailbox the
211// callback's thread services (self-deadlock: a close from the sent callback that sends a TLS
212// close_notify, found on hardware with a real TLS 1.3 client). One named owner, unreachable cross-TU.
214{
215 TaskHandle_t tcpip_task = nullptr;
216};
217static TransportCtx s_tp;
218
219// True when the caller may run a raw lwIP op directly instead of marshaling. lwIP has two threading
220// models and the answer differs, so branch on which one the framework built:
221static inline bool on_tcpip_thread()
222{
223#if defined(LWIP_TCPIP_CORE_LOCKING) && LWIP_TCPIP_CORE_LOCKING
224 // Core-locking (arduino-esp32 3.x / IDF 5.x): tcpip_api_call takes the core lock and runs the op
225 // INLINE on the calling task - there is no dedicated tcpip thread to marshal to - so a direct lwIP
226 // call is safe exactly when we already hold the core lock. Use lwIP's own holder query (the one
227 // LWIP_ASSERT_CORE_LOCKED checks). A task-handle compare is meaningless here (every caller runs its
228 // own tcpip_api_call inline, so the captured "tcpip task" is just whoever ran the first op), and a
229 // false positive calls tcp_write unlocked -> the "Required to lock TCPIP core functionality!" assert
230 // (found running TLS on the PSRAM/IDF-5.5 core: the handshake's record flush crashed the device).
231 return sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER);
232#else
233 // Mailbox (arduino-esp32 2.x / IDF 4.x): tcpip_api_call marshals to the single dedicated tcpip
234 // thread, so a direct call is safe exactly when we run on that thread. Captured on the first
235 // pc_tcp_do (boot / first send), which precedes every raw-callback teardown path.
236 return s_tp.tcpip_task != nullptr && xTaskGetCurrentTaskHandle() == s_tp.tcpip_task;
237#endif
238}
239
241{
242 struct tcpip_api_call_data base;
244 uint8_t slot;
245 struct tcp_pcb *pcb;
246 const void *data;
247 u16_t len;
248 bool flush; ///< pc_tcp_op::PC_OP_SEND: also tcp_output() after a successful write (coalesced write+flush)
249 err_t result; ///< outcome of the op (pc_tcp_op::PC_OP_SEND: whether the write was queued)
250};
251
252// True if @p pcb is still bound to a live connection slot. A marshalled send/output captures the
253// pcb on the worker thread (from conn_pool[slot].pcb); by the time the op runs here the connection
254// can have been torn down - teardown nulls conn_pool[slot].pcb on the worker and then frees the pcb
255// on tcpip_thread (PC_OP_CLOSE/ABORT), and a remote RST frees it via the error callback. A
256// tcp_write/tcp_output on that freed pcb trips lwIP's `tcp_output: invalid pcb` assert and panics
257// the device (found by the pentest rig: oversized request line / connection saturation). Re-check
258// against the pool here - we are in tcpip_thread, where teardown also runs, so the compare is
259// race-free. The scan (not conn_pool[slot]) is correct for PC_OP_RAWSEND too, whose slot is 0.
260static bool pcb_still_bound(const struct tcp_pcb *pcb)
261{
262 if (!pcb)
263 {
264 return false;
265 }
266 for (uint8_t i = 0; i < CONN_POOL_SLOTS; i++)
267 {
268 if (conn_pool[i].pcb == pcb)
269 {
270 return true;
271 }
272 }
273 return false;
274}
275
276// Runs in tcpip_thread (via tcpip_api_call). Performs the requested raw lwIP op
277// in the one context where it is safe; TLS record I/O (which also reaches
278// tcp_write through the BIO) is done here too.
279static err_t pc_tcp_do(struct tcpip_api_call_data *c)
280{
281 pc_tcp_call *k = (pc_tcp_call *)c;
282 k->result = ERR_OK;
283 if (!s_tp.tcpip_task) // capture the tcpip_thread task once; pc_tcp_do only ever runs in that thread
284 {
285 s_tp.tcpip_task = xTaskGetCurrentTaskHandle();
286 }
287 switch (k->op)
288 {
290 // RAWSEND (TLS BIO) carries only the pcb, not its slot, so liveness needs a pool lookup. This
291 // is the cool TLS handshake / read-pump path (not per-packet app data), and CONN_POOL_SLOTS is
292 // small + compile-time so -O2 unrolls the scan (see docs/ROADMAP: unroll loops to bitmask).
293 if (!pcb_still_bound(k->pcb)) // stale pcb (connection torn down between capture and now)
294 {
295 k->result = ERR_CLSD;
296 break;
297 }
298 k->result = tcp_write(k->pcb, k->data, k->len, TCP_WRITE_FLAG_COPY);
299 if (k->result == ERR_OK)
300 {
301 tcp_output(k->pcb);
302 }
303 break;
305 // Hot path: SEND carries the real slot, so a stale pcb is just k->pcb != the slot's live pcb.
306 // O(1), no scan - the send/flush pair runs on every HTTP response. The `k->pcb` null test is
307 // essential: a torn-down slot has pcb == null, and comparing a captured-null against a live-null
308 // (null == null) would otherwise pass the guard and tcp_write(null).
309 if (!k->pcb || k->pcb != conn_pool[k->slot].pcb)
310 {
311 k->result = ERR_CLSD; // connection torn down between capture and now; skip, do not assert
312 break;
313 }
314#if PC_ENABLE_TLS
315 if (conn_pool[k->slot].tls)
316 {
317 k->result = (pc_tls_write(k->slot, k->data, k->len) >= 0) ? ERR_OK : ERR_MEM;
318 break;
319 }
320#endif
321 k->result = tcp_write(k->pcb, k->data, k->len, TCP_WRITE_FLAG_COPY);
322 if (k->flush && k->result == ERR_OK)
323 {
324 tcp_output(k->pcb); // coalesced write+flush: one marshal for a terminal single-shot response
325 }
326 break;
328 // Hot path (O(1)): flush only if the slot still owns a LIVE pcb; else it was torn down - skip
329 // rather than tcp_output on freed memory (lwIP's "invalid pcb" assert -> panic). The `k->pcb`
330 // null test is essential and was the missing piece here: pc_conn_flush() marshals this op with
331 // conn_pool[slot].pcb, which is null for a torn-down slot, so a captured-null vs a live-null
332 // (null == null) passed the guard and called tcp_output(null) -> panic. Coredump-confirmed:
333 // slot 0, k->pcb == 0, conn_pool[0].pcb == 0, state CONN_FREE.
334 if (k->pcb && k->pcb == conn_pool[k->slot].pcb)
335 {
336 tcp_output(k->pcb);
337 }
338 else
339 {
340 k->result = ERR_CLSD;
341 }
342 break;
344#if PC_ENABLE_TLS
345 if (conn_pool[k->slot].tls)
346 {
347 pc_tls_conn_end(k->slot); // close_notify + free the TLS context
348 }
349#endif
350 if (tcp_close(k->pcb) != ERR_OK)
351 {
352 tcp_abort(k->pcb);
353 }
354 break;
356 tcp_abort(k->pcb);
357 break;
359 tcp_arg(k->pcb, nullptr);
360 break;
362 closing_check(k->slot, k->pcb); // safe pcb access: we are in tcpip_thread
363 break;
365 // Same O(1) liveness guard as SEND/OUTPUT: the worker captured the pcb when it acked consumed
366 // RX bytes, but the connection can be torn down (a remote RST frees the pcb via the error
367 // callback) before this op runs. tcp_recved on a freed pcb walks into tcp_update_rcv_ann_wnd
368 // (assert new_rcv_ann_wnd <= 0xffff) and a window-update tcp_output ("invalid pcb") - i.e. a
369 // remotely-triggerable panic under connection churn. Skip if the slot no longer owns a live pcb
370 // (the null test guards the captured-null vs live-null case, as in SEND/OUTPUT).
371 if (k->pcb && k->pcb == conn_pool[k->slot].pcb)
372 {
373 tcp_recved(k->pcb, k->len); // reopen the receive window by the consumed bytes
374 }
375 else
376 {
377 k->result = ERR_CLSD;
378 }
379 break;
380#if PC_ENABLE_DIFFSERV
381 case pc_tcp_op::PC_OP_SET_TOS:
382 // Per-connection DSCP (RFC 2474): stamp the DS field so this flow's outbound IP packets carry the
383 // requested class. Same O(1) liveness guard as SEND - the slot can be torn down between the worker
384 // capturing the pcb and this op running. k->len carries the ready-made TOS byte (DSCP << 2).
385 if (k->pcb && k->pcb == conn_pool[k->slot].pcb)
386 {
387 k->pcb->tos = (uint8_t)k->len;
388 }
389 else
390 {
391 k->result = ERR_CLSD;
392 }
393 break;
394#endif
395 }
396 return ERR_OK;
397}
398
399static inline err_t pc_tcp_marshal(pc_tcp_op op, uint8_t slot, struct tcp_pcb *pcb, const void *data, u16_t len,
400 bool flush = false)
401{
402 pc_tcp_call k;
403 memset(&k, 0, sizeof(k));
404 k.op = op;
405 k.slot = slot;
406 k.pcb = pcb;
407 k.data = data;
408 k.len = len;
409 k.flush = flush;
410 // On tcpip_thread already (a raw lwIP callback's teardown reaching a send/close): run the op inline.
411 // Re-marshaling here would call tcpip_api_call from the thread that services its mailbox and block
412 // forever (the TLS close_notify-from-sent-callback self-deadlock). Off-thread (worker): marshal.
413 if (on_tcpip_thread())
414 {
415 pc_tcp_do(&k.base);
416 }
417 else
418 {
419 tcpip_api_call(pc_tcp_do, &k.base);
420 }
421 return k.result;
422}
423#endif // ARDUINO
424
426
427// Owns the live-slot bitmask (unconditional - the state transitions it tracks happen on both targets).
428// bit i set = conn_pool[i] is CONN_FREE (available for a new accept). Kept in lock-step with every state
429// write through the single pc_conn_set_state() choke point, so the accept free-slot lookup is one ctz
430// instead of a MAX_CONNS scan (measured 20 vs 71 cyc on the S3). Atomic: CONN_FREE is written from the
431// tcpip callbacks AND the worker (check_timeouts).
433{
434 std::atomic<uint32_t> free_mask{0};
435};
436static ConnPoolCtx s_pool;
437
438static_assert(MAX_CONNS <= 32, "the free-slot bitmask (s_pool.free_mask) is a uint32; raise it to uint64_t "
439 "or fall back to a scan if MAX_CONNS ever exceeds 32");
440
441// The one place a conn_pool slot's lifecycle state is written. Keeps the free-slot bitmask (s_pool.free_mask)
442// in lock-step with the atomic state: publish availability (set the bit) only AFTER the release store to
443// CONN_FREE - the caller has already cleaned the slot - and reserve (clear the bit) BEFORE the store to any
444// non-free state, so a concurrent allocator never picks a slot that is mid-claim. The bit ops are atomic
445// because CONN_FREE is written from tcpip callbacks and from the worker (check_timeouts).
446void pc_conn_set_state(uint8_t slot, ConnState st)
447{
448 // Bound every write to the real array size up front (CONN_POOL_SLOTS = MAX_CONNS + the reserved internal
449 // slots), so the setter is memory-safe on its own rather than relying on the caller never over-indexing.
450 if (slot >= CONN_POOL_SLOTS)
451 {
452 return;
453 }
454
455#if PC_INTERNAL_SLOTS > 0
456 // Reserved internal slots (>= MAX_CONNS, e.g. PC_H3_DISPATCH_SLOT) are not part of the TCP accept pool
457 // and are never handed out by the allocator, so they carry state but no bitmask bit. Compiled out when
458 // no reserved slots exist (CONN_POOL_SLOTS == MAX_CONNS), where the test would be dead.
459 if (slot >= MAX_CONNS)
460 {
461 conn_pool[slot].state = st;
462 return;
463 }
464#endif
465 const uint32_t bit = 1u << slot;
466 if (st == ConnState::CONN_FREE)
467 {
468 conn_pool[slot].state = st; // pc_atomic release store
469 s_pool.free_mask.fetch_or(bit, std::memory_order_release);
470 }
471 else
472 {
473 s_pool.free_mask.fetch_and(~bit, std::memory_order_release);
474 conn_pool[slot].state = st; // pc_atomic release store
475 }
476}
477
478// First free slot as one ctz on the bitmask, replacing the MAX_CONNS linear scan. Returns -1 if the pool is
479// full. Runs on tcpip_thread (accept); the acquire load pairs with the release stores in pc_conn_set_state.
481{
482 const uint32_t valid = (MAX_CONNS >= 32) ? 0xFFFFFFFFu : ((1u << MAX_CONNS) - 1u);
483 uint32_t m = s_pool.free_mask.load(std::memory_order_acquire) & valid;
484 return m ? (int32_t)__builtin_ctz(m) : -1;
485}
486
487uint32_t pc_ap_ip = 0;
488
490
491// ---------------------------------------------------------------------------
492// Connection output API
493// ---------------------------------------------------------------------------
494// The single send/flush/close path for every higher layer (HTTP app, WebSocket,
495// SSE, SSH). Keeping it here means presentation and application code never call
496// lwIP directly - they hand bytes to the transport layer, which decides whether
497// they go out as plaintext (tcp_write) or through the TLS record layer. With
498// PC_ENABLE_TLS off this is byte-identical to a direct tcp_write/tcp_output.
499
500bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
501{
502 // The write target is always the slot's own pcb (ingress reads resolve it the
503 // same way) - callers no longer thread it through, so it cannot disagree.
504#if defined(ARDUINO)
505 return pc_tcp_marshal(pc_tcp_op::PC_OP_SEND, slot, conn_pool[slot].pcb, data, len) ==
506 ERR_OK; // write runs in tcpip_thread
507#else
508#if PC_ENABLE_TLS
509 if (conn_pool[slot].tls)
510 {
511 return pc_tls_write(slot, data, len) >= 0;
512 }
513#endif
514 return tcp_write(conn_pool[slot].pcb, data, len, TCP_WRITE_FLAG_COPY) == ERR_OK;
515#endif
516}
517
518bool pc_conn_send_flush(uint8_t slot, const void *data, u16_t len)
519{
520 // Terminal single-shot write: the bytes AND their tcp_output() happen in one tcpip_thread
521 // round-trip, so a small response costs one marshal instead of the send()+flush() pair (each
522 // a ~23 us marshal on-device). For a TLS slot this is identical to pc_conn_send: the record
523 // BIO already pushes ciphertext per record, so there is no separate flush to fold in.
524#if defined(ARDUINO)
525 return pc_tcp_marshal(pc_tcp_op::PC_OP_SEND, slot, conn_pool[slot].pcb, data, len, /*flush=*/true) == ERR_OK;
526#else
527#if PC_ENABLE_TLS
528 if (conn_pool[slot].tls)
529 {
530 return pc_tls_write(slot, data, len) >= 0; // TLS BIO already output the record
531 }
532#endif
533 if (tcp_write(conn_pool[slot].pcb, data, len, TCP_WRITE_FLAG_COPY) != ERR_OK)
534 {
535 return false;
536 }
537 tcp_output(conn_pool[slot].pcb);
538 return true;
539#endif
540}
541
542u16_t pc_conn_sndbuf(uint8_t slot)
543{
544 struct tcp_pcb *pcb = conn_pool[slot].pcb;
545 if (!pcb)
546 {
547 return 0;
548 }
549 u16_t avail = tcp_sndbuf(pcb);
550#if PC_ENABLE_TLS
551 // A TLS record adds header + MAC/tag overhead; report a conservative plaintext
552 // budget so a caller that fills it does not overrun the cipher's framing.
553 if (conn_pool[slot].tls)
554 {
555 avail = (avail > 64) ? (u16_t)(avail - 64) : 0;
556 }
557#endif
558 return avail;
559}
560
561void pc_conn_flush(uint8_t slot)
562{
563#if PC_ENABLE_TLS
564 if (conn_pool[slot].tls)
565 {
566 return; // ciphertext was already pushed by the TLS BIO (tcp_output per record);
567 // flush must NOT end the session - persistent TLS (wss / TLS SSE) reuses it
568 }
569#endif
570#if defined(ARDUINO)
571 pc_tcp_marshal(pc_tcp_op::PC_OP_OUTPUT, slot, conn_pool[slot].pcb, nullptr, 0);
572#else
573 tcp_output(conn_pool[slot].pcb);
574#endif
575}
576
577#if PC_ENABLE_DIFFSERV
578bool pc_conn_set_dscp(uint8_t slot, uint8_t dscp)
579{
580 if (slot >= MAX_CONNS || conn_pool[slot].pcb == nullptr)
581 {
582 return false;
583 }
584#if defined(ARDUINO)
585 // Marshalled to tcpip_thread (PC_OP_SET_TOS) - lwIP reads pcb->tos while building each outbound
586 // segment, so setting it from a worker task must not race the stack.
587 return pc_tcp_marshal(pc_tcp_op::PC_OP_SET_TOS, slot, conn_pool[slot].pcb, nullptr, pc_dscp_to_tos(dscp)) == ERR_OK;
588#else
589 conn_pool[slot].pcb->tos = pc_dscp_to_tos(dscp); // host: no tcpip thread, set directly
590 return true;
591#endif
592}
593#endif // PC_ENABLE_DIFFSERV
594
595void pc_conn_ack_consumed(uint8_t slot)
596{
597 if (slot >= MAX_CONNS)
598 {
599 return;
600 }
601 TcpConn *c = &conn_pool[slot];
602 // Only the owning worker calls this, so rx_tail/rx_acked are read race-free
603 // here; rx_head (producer) is not touched. Ack nothing for a slot that is not
604 // actively receiving (the ConnState::CONN_CLOSING discard path ACKs its own bytes).
605 if (c->state != ConnState::CONN_ACTIVE || !c->pcb)
606 {
607 return;
608 }
609 size_t tail = c->rx_tail;
610 size_t consumed = (tail + RX_BUF_SIZE - c->rx_acked) % RX_BUF_SIZE;
611 if (!consumed)
612 {
613 return;
614 }
615 c->rx_acked = tail; // advance first: the marshaled tcp_recved is the slow part
616#if defined(ARDUINO)
617 pc_tcp_marshal(pc_tcp_op::PC_OP_RECVED, slot, c->pcb, nullptr, (u16_t)consumed);
618#else
619 tcp_recved(c->pcb, (u16_t)consumed);
620#endif
621}
622
623bool pc_conn_raw_send(struct tcp_pcb *pcb, const void *data, u16_t len)
624{
625 if (!pcb)
626 {
627 return false;
628 }
629#if defined(ARDUINO)
630 // pc_tcp_marshal owns the context choice: it runs the raw write inline when already in
631 // tcpip_thread (a TLS close_notify/alert emitted from inside a raw lwIP callback) and marshals it
632 // from the worker task (the handshake / read pump), so the tcp_write neither races the lwIP thread
633 // nor self-deadlocks on the tcpip mailbox. The RAWSEND op also re-checks the pcb is still bound.
634 return pc_tcp_marshal(pc_tcp_op::PC_OP_RAWSEND, 0, pcb, data, len) == ERR_OK;
635#else
636 err_t e = tcp_write(pcb, data, len, TCP_WRITE_FLAG_COPY);
637 if (e == ERR_OK)
638 {
639 tcp_output(pcb);
640 }
641 return e == ERR_OK;
642#endif
643}
644
645void pc_conn_close(uint8_t slot)
646{
647 if (slot >= MAX_CONNS)
648 {
649 return;
650 }
651 TcpConn *c = &conn_pool[slot];
652 struct tcp_pcb *pcb = c->pcb;
653 if (!pcb)
654 {
655 return;
656 }
657 // The application-initiated close path (L4 primitive). Remote FIN, error, and
658 // timeout closes are observed at their own sites, so this is uniquely "local".
659 PC_OBS_TRANSITION(slot, ConnState::CONN_ACTIVE, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_CLOSE_LOCAL);
660 // Detach the pcb and free the slot before the close, so a late callback for
661 // this pcb finds a null arg and does nothing. The close itself targets the
662 // captured pcb (pc_tcp_op::PC_OP_CLOSE carries it), so nulling the slot first is safe.
663 pc_conn_detach(pcb);
665 c->pcb = nullptr;
666#if defined(ARDUINO)
667 pc_tcp_marshal(pc_tcp_op::PC_OP_CLOSE, slot, pcb, nullptr, 0); // TLS teardown + FIN in tcpip_thread
668#else
669#if PC_ENABLE_TLS
670 if (c->tls)
671 {
672 pc_tls_conn_end(slot);
673 }
674#endif
675 if (tcp_close(pcb) != ERR_OK)
676 {
677 tcp_abort(pcb);
678 }
679#endif
680}
681
682void pc_conn_abort_slot(uint8_t slot)
683{
684 if (slot >= MAX_CONNS)
685 {
686 return;
687 }
688 TcpConn *c = &conn_pool[slot];
689 struct tcp_pcb *pcb = c->pcb;
690 if (!pcb)
691 {
692 return;
693 }
694 PC_OBS_TRANSITION(slot, ConnState::CONN_ACTIVE, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_ABORT);
695#if PC_ENABLE_TLS
696 if (c->tls)
697 {
698 pc_tls_conn_free(slot); // abrupt: free the per-conn TLS context, no close_notify
699 }
700#endif
701 // Detach + free the slot before the RST, so a late callback finds a null arg.
702 pc_conn_detach(pcb);
704 c->pcb = nullptr;
705 pc_conn_abort(pcb);
706}
707
708void pc_conn_detach(struct tcp_pcb *pcb)
709{
710 // Disassociate the slot from this pcb's lwIP callbacks before freeing the
711 // slot, so any late callback for the pcb finds a null arg and does nothing.
712#if defined(ARDUINO)
713 pc_tcp_marshal(pc_tcp_op::PC_OP_DETACH, 0, pcb, nullptr, 0);
714#else
715 tcp_arg(pcb, nullptr);
716#endif
717}
718
719void pc_conn_abort(struct tcp_pcb *pcb)
720{
721 // Hard reset (RST) for a fatal condition - no graceful FIN.
722#if defined(ARDUINO)
723 pc_tcp_marshal(pc_tcp_op::PC_OP_ABORT, 0, pcb, nullptr, 0);
724#else
725 tcp_abort(pcb);
726#endif
727}
728
729// ---------------------------------------------------------------------------
730// ConnState::CONN_CLOSING dwell: a graceful close that holds the slot until the peer ACKs.
731// ---------------------------------------------------------------------------
732// These run in tcpip_thread context (the sent callback, or the pc_tcp_op::PC_OP_CLOSE_CHECK
733// marshaled op), so they touch the PCB directly - never marshal from here.
734
735// Finalize a ConnState::CONN_CLOSING slot: tear down the PCB and free the slot.
736static void closing_finalize(uint8_t slot, struct tcp_pcb *pcb)
737{
738 TcpConn *c = &conn_pool[slot];
739#if PC_ENABLE_TLS
740 if (c->tls)
741 {
742 pc_tls_conn_end(slot); // close_notify + free the TLS context (in-thread)
743 }
744#endif
746 c->pcb = nullptr;
747 if (pcb)
748 {
749 tcp_arg(pcb, nullptr);
750 if (tcp_close(pcb) != ERR_OK)
751 {
752 tcp_abort(pcb);
753 }
754 }
755 PC_OBS_TRANSITION(slot, ConnState::CONN_CLOSING, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_DRAINED);
756}
757
758// If the slot is ConnState::CONN_CLOSING and its TX queue has drained (peer ACKed the whole
759// response), finalize it now. Called only from tcpip_thread context.
760//
761// The early-return guard below is unreachable: closing_check() has exactly two callers,
762// pc_conn_begin_close()'s host path (which already validated slot < MAX_CONNS and just set
763// state to CONN_CLOSING immediately before this call) and lowlevel_sent_cb() (which only
764// calls here after checking slot->state == ConnState::CONN_CLOSING itself, with slot->id
765// always a valid conn_pool index by construction). Both guarantee the condition is false.
766static void closing_check(uint8_t slot, struct tcp_pcb *pcb)
767{
768 if (slot >= MAX_CONNS || conn_pool[slot].state != ConnState::CONN_CLOSING) // GCOVR_EXCL_BR_LINE - see above
769 {
770 return; // GCOVR_EXCL_LINE - see above
771 }
772 if (!pcb || pcb->snd_queuelen == 0)
773 {
774 closing_finalize(slot, pcb);
775 }
776}
777
778void pc_conn_begin_close(uint8_t slot_id)
779{
780 if (slot_id >= MAX_CONNS)
781 {
782 return;
783 }
784 TcpConn *c = &conn_pool[slot_id];
785 if (c->state != ConnState::CONN_ACTIVE) // an error during the write may have freed it
786 {
787 return;
788 }
789 struct tcp_pcb *pcb = c->pcb;
790 c->last_activity_ms = pc_millis(); // start the ConnState::CONN_CLOSING dwell clock
791 pc_conn_set_state(c->id, ConnState::CONN_CLOSING); // release store: tcpip-thread callbacks now see CLOSING
792 PC_OBS_TRANSITION(slot_id, ConnState::CONN_ACTIVE, ConnState::CONN_CLOSING, pc_conn_reason::PC_CONN_R_CLOSE_LOCAL);
793 // Finalize immediately if the response already drained, else dwell until the
794 // sent callback (or the CLOSING-timeout sweep) reclaims it. The PCB read must
795 // happen in tcpip_thread, so marshal the check on device.
796#if defined(ARDUINO)
797 pc_tcp_marshal(pc_tcp_op::PC_OP_CLOSE_CHECK, slot_id, pcb, nullptr, 0);
798#else
799 closing_check(slot_id, pcb);
800#endif
801}
802
803/**
804 * @brief Non-blocking event enqueue helper.
805 *
806 * Forwards the event to the queue owned by the connection's listener.
807 * xQueueSend with timeout=0 returns immediately if the queue is full.
808 * A full queue indicates the application is not calling server_tick() fast
809 * enough; dropped events are recoverable via the idle-timeout sweep.
810 */
811static inline void enqueue(TcpConn *slot, const TcpEvt &evt)
812{
813 if (!listener_enqueue(slot->listener_id, &evt))
814 {
815 PC_OBS_NOTICE(slot->id, slot->state, pc_conn_reason::PC_CONN_R_DEFER_DROP);
816 }
817}
818
820{
822 // Reset from a single zeroed template in BSS rather than `conn_pool[i] = {}`:
823 // the latter materializes a full sizeof(TcpConn) temporary on the caller's
824 // stack (the whole rx_buffer[RX_BUF_SIZE]), which overflows the loopTask stack
825 // at begin() once RX_BUF_SIZE is set large. Copy-assigning from the static
826 // template stays in BSS and uses pc_atomic::operator= (no atomic memset UB).
827 // GCOVR_EXCL_BR_LINE - the static-local guard's exception-cleanup arm is unreachable: the
828 // TcpConn aggregate init only value-initializes noexcept pc_atomic members, so it never throws.
829 static const TcpConn blank = {}; // GCOVR_EXCL_BR_LINE
830 for (int i = 0; i < MAX_CONNS; i++)
831 {
832 conn_pool[i] = blank;
833 conn_pool[i].id = i;
835 }
836}
837
839{
840 // Abort all active connections - listener PCBs and queues are owned by
841 // the listener layer and must be cleaned up via listener_stop_all() first.
842 for (int i = 0; i < MAX_CONNS; i++)
843 {
844 ConnState st = conn_pool[i].state;
845 if ((st == ConnState::CONN_ACTIVE || st == ConnState::CONN_CLOSING) && conn_pool[i].pcb)
846 {
847 struct tcp_pcb *pcb = conn_pool[i].pcb;
849 conn_pool[i].pcb = nullptr;
850 pc_conn_detach(pcb); // tcpip_thread-marshaled tcp_arg(null) + abort
851 pc_conn_abort(pcb);
852 PC_OBS_TRANSITION((uint8_t)i, st, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_ABORT);
853 }
855 conn_pool[i].pcb = nullptr;
856 }
857}
858
860{
861 uint8_t n = 0;
862 for (uint8_t i = 0; i < MAX_CONNS; i++)
863 {
864 if (conn_pool[i].state == ConnState::CONN_ACTIVE)
865 {
866 n++;
867 }
868 }
869 return n;
870}
871
872uint32_t pc_conn_remote_ip(uint8_t slot)
873{
874#ifdef ARDUINO
875 if (slot >= MAX_CONNS)
876 {
877 return 0;
878 }
879 TcpConn *conn = &conn_pool[slot];
880 if (conn->state == ConnState::CONN_ACTIVE && conn->pcb)
881 {
882 return ip4_addr_get_u32(ip_2_ip4(&conn->pcb->remote_ip));
883 }
884#else
885 (void)slot;
886#endif
887 return 0;
888}
889
890#ifdef ARDUINO
891// Convert an lwIP address (itself a family-tagged union) into the portable pc_ip, network-order
892// bytes preserved. The one owner of the pcb ip_addr_t -> pc_ip mapping, for the per-slot accessor
893// below and the accept callback (which has the pcb but no slot yet).
894void pc_lwip_to_ip(const ip_addr_t *ra, pc_ip *out)
895{
896#if LWIP_IPV6
897 if (IP_IS_V6(ra))
898 {
899 uint8_t b[16];
900 memcpy(b, ip_2_ip6(ra)->addr, 16); // lwIP holds the 16 bytes in network order
901 *out = pc_ip_from_v6_bytes(b);
902 return;
903 }
904#endif
905 // ip4_addr_get_u32 is network-order; on the (little-endian) ESP32 the first octet is the low
906 // byte. Peel the octets so pc_ip holds them left-to-right.
907 uint32_t be = ip4_addr_get_u32(ip_2_ip4(ra));
908 *out = pc_ip_from_v4_octets((uint8_t)be, (uint8_t)(be >> 8), (uint8_t)(be >> 16), (uint8_t)(be >> 24));
909}
910#endif
911
912bool pc_conn_remote_addr(uint8_t slot, pc_ip *out)
913{
914 if (out)
915 {
917 }
918#ifdef ARDUINO
919 if (!out || slot >= MAX_CONNS)
920 {
921 return false;
922 }
923 TcpConn *conn = &conn_pool[slot];
924 if (conn->state != ConnState::CONN_ACTIVE || !conn->pcb)
925 {
926 return false;
927 }
928 pc_lwip_to_ip(&conn->pcb->remote_ip, out);
929 return true;
930#else
931 (void)slot;
932 return false;
933#endif
934}
935
936// Refresh a slot's idle-timeout timestamp from the owning worker while a response body is still
937// being paged out (the file/chunk send pumps call this every poll they run). Such a slot is
938// actively streaming - or briefly blocked on a full send window / a transient link stall - NOT
939// idle, so the CONN_TIMEOUT_MS idle sweep must not reap it mid-transfer: that truncates any body
940// larger than one TCP window (seen on a multi-hundred-MB download as an rc=56 reset or a short
941// read). Dead-peer teardown for an in-flight response stays owned by lwIP's retransmission timers,
942// which abort a black-holed pcb through the err callback. Worker-context safe: it only writes our
943// own last_activity_ms hint (the sent callback writes the same uint32 from tcpip_thread; a torn
944// read of a timestamp is benign).
945void pc_conn_touch_active(uint8_t slot_id)
946{
947 if (slot_id >= MAX_CONNS)
948 {
949 return;
950 }
951 TcpConn *c = &conn_pool[slot_id];
953 {
955 }
956}
957
959{
960 uint32_t now = pc_millis();
961 for (int i = 0; i < MAX_CONNS; i++)
962 {
963 TcpConn *slot = &conn_pool[i];
964 if (slot->owner != worker_id) // each worker reaps only its own slots
965 {
966 continue;
967 }
968
969 // ConnState::CONN_CLOSING safety net: a graceful close whose peer never ACKs would
970 // dwell forever. After PC_CLOSING_TIMEOUT_MS, force it free so the
971 // fixed pool cannot leak. (The fast path is the sent callback finalizing
972 // on ACK; this only catches a black-holed peer.)
973 if (slot->state == ConnState::CONN_CLOSING)
974 {
975 if ((now - slot->last_activity_ms) < PC_CLOSING_TIMEOUT_MS)
976 {
977 continue;
978 }
979 struct tcp_pcb *cpcb = slot->pcb;
981 slot->pcb = nullptr;
982 if (cpcb)
983 {
984 pc_conn_detach(cpcb);
985 pc_conn_abort(cpcb);
986 }
988 pc_conn_reason::PC_CONN_R_DRAINED);
989 continue;
990 }
991
992 if (slot->state != ConnState::CONN_ACTIVE)
993 {
994 continue;
995 }
996 if ((now - slot->last_activity_ms) < conn_timeout_ms)
997 {
998 continue;
999 }
1000
1001 struct tcp_pcb *pcb = slot->pcb;
1002 /*
1003 * Clear state BEFORE calling tcp_abort so that any lwIP callback
1004 * firing on the same PCB during or after abort sees state==ConnState::CONN_FREE
1005 * and exits immediately without accessing freed memory.
1006 */
1008 slot->pcb = nullptr;
1009 if (pcb)
1010 {
1011 pc_conn_detach(pcb); // tcpip_thread-marshaled tcp_arg(null) + abort
1012 pc_conn_abort(pcb);
1013 }
1014 PC_OBS_TRANSITION((uint8_t)i, ConnState::CONN_ACTIVE, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_TIMEOUT);
1015 TcpEvt evt = {EvtType::EVT_ERROR, (uint8_t)i, 0};
1016 enqueue(slot, evt);
1017 }
1018}
1019
1020// ---------------------------------------------------------------------------
1021// lwIP callbacks - execute in tcpip_thread task context
1022// These are non-static so listener.cpp can take their address.
1023// ---------------------------------------------------------------------------
1024
1025/**
1026 * @brief lwIP receive callback - fires when data arrives on a connection.
1027 *
1028 * Copies pbuf chain bytes into the ring buffer; the window is reopened later by
1029 * pc_conn_ack_consumed() as the worker drains (ack-on-consume), not here. If the
1030 * whole segment will not fit it is refused (ERR_MEM) for lossless backpressure.
1031 * A null pbuf signals graceful remote close (FIN received).
1032 */
1033err_t lowlevel_recv_cb(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err)
1034{
1035 TcpConn *slot = (TcpConn *)arg;
1036 if (!slot)
1037 {
1038 return ERR_VAL;
1039 }
1040
1041 // While dwelling in ConnState::CONN_CLOSING we have already sent our final response and
1042 // are waiting for the ACK. Drain (and ACK) anything the peer still sends so
1043 // the window keeps moving, but do not process it. A peer FIN here just means
1044 // both sides are done - finalize on the next sent/timeout.
1045 if (slot->state == ConnState::CONN_CLOSING)
1046 {
1047 if (p)
1048 {
1049 tcp_recved(tpcb, p->tot_len);
1050 pbuf_free(p);
1051 }
1052 return ERR_OK;
1053 }
1054
1055 if (slot->state != ConnState::CONN_ACTIVE)
1056 {
1057 return ERR_VAL;
1058 }
1059
1060 if (p == nullptr)
1061 {
1062 /*
1063 * Null pbuf signals graceful remote close (FIN received).
1064 * Clear state and pcb before tcp_close so any stale callbacks
1065 * are harmless.
1066 */
1068 slot->pcb = nullptr;
1069 tcp_arg(tpcb, nullptr);
1070 if (tcp_close(tpcb) != ERR_OK)
1071 {
1072 tcp_abort(tpcb);
1073 }
1075 pc_conn_reason::PC_CONN_R_CLOSE_REMOTE);
1076 TcpEvt evt = {EvtType::EVT_DISCONNECT, slot->id, 0};
1077 enqueue(slot, evt);
1078 return ERR_OK;
1079 }
1080
1081 /*
1082 * Backpressure without data loss: if the whole segment will not fit in the
1083 * free ring space, refuse it (return ERR_MEM without freeing) so lwIP retains
1084 * it as refused_data and redelivers once the application has drained the
1085 * ring; nudge the main loop to drain. The previous code copied what fit and
1086 * dropped the rest, silently corrupting bodies larger than the ring (e.g.
1087 * streamed uploads). NOTE: needs RX_BUF_SIZE > the largest incoming segment
1088 * (TCP_MSS) so a full segment can eventually fit; smaller rings only ever see
1089 * sub-MSS requests, which always fit.
1090 */
1091 if (p->tot_len > pc_ring_free(slot->rx_head, slot->rx_tail, RX_BUF_SIZE))
1092 {
1093 PC_OBS_NOTICE(slot->id, ConnState::CONN_ACTIVE, pc_conn_reason::PC_CONN_R_BACKPRESSURE);
1094 TcpEvt evt = {EvtType::EVT_DATA, slot->id, 0}; // wake the loop so it drains the ring
1095 enqueue(slot, evt);
1096 // Do NOT refresh the idle timer here: a refused segment is redelivered by lwIP every
1097 // retransmit until the ring drains, so refreshing on refusal keeps a backpressure-stuck
1098 // connection alive forever (idle sweep never reaps it -> slot leak / pool-exhaustion DoS,
1099 // e.g. an oversized request line that fills the ring and never completes). The timer is
1100 // refreshed below only when data is actually ACCEPTED (real progress), so a connection
1101 // that makes no progress times out and is reaped.
1102 return ERR_MEM; // do NOT pbuf_free(p): lwIP keeps it and redelivers
1103 }
1104
1105 uint32_t rx_now = pc_millis();
1106 slot->last_activity_ms = rx_now; // accepted data = progress: refresh the idle timer
1107 if (slot->req_start_ms == 0) // first byte of a new request: arm the completion deadline (slow-loris)
1108 {
1109 slot->req_start_ms = rx_now ? rx_now : 1;
1110 }
1111
1112 // Bulk-copy the segment into the ring via the shared producer primitive: a
1113 // contiguous memcpy per pbuf (two across the wrap), advancing a LOCAL head and
1114 // publishing rx_head once at the end (one release store for the whole segment).
1115 // The free-space check above guarantees it fits, so head can never overrun tail.
1116 size_t head = slot->rx_head; // sole producer of head; one acquire load
1117 for (struct pbuf *q = p; q != nullptr; q = q->next)
1118 {
1119 head = pc_ring_write_span(slot->rx_buffer, RX_BUF_SIZE, head, (const uint8_t *)q->payload, q->len);
1120 }
1121 slot->rx_head = head; // single release store: publishes the whole segment at once
1122 size_t bytes_copied = p->tot_len; // the whole segment fit (checked above)
1123
1124 // Do NOT tcp_recved() here: the window is reopened by pc_conn_ack_consumed()
1125 // as the worker drains the ring (ack-on-consume), so the advertised window
1126 // tracks ring occupancy and a slow consumer cannot overflow the ring. ACKing
1127 // on copy decoupled the window from drainage and deadlocked streamed uploads
1128 // once RX_BUF_SIZE < TCP_WND (the refused segment past one ring-full stalled).
1129 pbuf_free(p);
1130
1131 if (bytes_copied > 0)
1132 {
1133 TcpEvt evt = {EvtType::EVT_DATA, slot->id, bytes_copied};
1134 enqueue(slot, evt);
1135 }
1136
1137 return ERR_OK;
1138}
1139
1140/**
1141 * @brief lwIP sent callback - fires after the stack acknowledges sent bytes.
1142 *
1143 * Refreshes the idle-timeout timestamp so an active sender is not reaped while its
1144 * responses are in flight, and - for a slot dwelling in ConnState::CONN_CLOSING - finalizes
1145 * the close once the response has fully drained (the peer ACKed everything).
1146 */
1147err_t lowlevel_sent_cb(void *arg, struct tcp_pcb *tpcb, u16_t len)
1148{
1149 TcpConn *slot = (TcpConn *)arg;
1150 if (slot)
1151 {
1152 slot->last_activity_ms = pc_millis();
1153 if (slot->state == ConnState::CONN_CLOSING)
1154 {
1155 closing_check(slot->id, tpcb); // drained? -> tear down + free the slot
1156 }
1157#ifdef ARDUINO
1158 // The send window just freed: wake the owning worker so a paced response
1159 // (e.g. a large file) resumes now rather than on the next idle sweep.
1160 else
1161 {
1162 pc_worker_wake(slot->owner);
1163 }
1164#endif
1165 }
1166 (void)len;
1167 return ERR_OK;
1168}
1169
1170/**
1171 * @brief lwIP error callback - fires when the stack detects a fatal error.
1172 *
1173 * By the time this fires the PCB is already gone internally, so we must NOT
1174 * call tcp_close() or tcp_abort(). Null out the slot's pcb pointer and post
1175 * EvtType::EVT_ERROR so the session layer resets the protocol state.
1176 */
1177void lowlevel_err_cb(void *arg, err_t err)
1178{
1179 TcpConn *slot = (TcpConn *)arg;
1180 if (!slot)
1181 {
1182 return;
1183 }
1184
1185 /*
1186 * When lwIP fires the error callback the PCB has already been freed
1187 * internally. We must NOT call tcp_close/tcp_abort here - just null
1188 * out our pointer to prevent any future access.
1189 */
1190 ConnState old = slot->state;
1192 slot->pcb = nullptr;
1193
1194 // A slot that errored while dwelling in ConnState::CONN_CLOSING is already done from the
1195 // session's view (its response was sent and the protocol state reset). Just
1196 // release the slot + the CLOSING gauge; do not re-post a close event.
1197 if (old == ConnState::CONN_CLOSING)
1198 {
1199 PC_OBS_TRANSITION(slot->id, ConnState::CONN_CLOSING, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_DRAINED);
1200 (void)err;
1201 return;
1202 }
1203
1204 PC_OBS_TRANSITION(slot->id, ConnState::CONN_ACTIVE, ConnState::CONN_FREE, pc_conn_reason::PC_CONN_R_ERROR);
1205 TcpEvt evt = {EvtType::EVT_ERROR, slot->id, 0};
1206 enqueue(slot, evt);
1207 (void)err;
1208}
#define RX_BUF_SIZE
Definition c2_defaults.h:50
#define MAX_CONNS
Definition c2_defaults.h:47
static void check_timeouts(int worker_id=0)
Scan the pool and force-close connections idle for > conn_timeout_ms.
Definition tcp.cpp:958
static void pool_init(const WebServerConfig *cfg=nullptr)
Initialize the connection pool and store the runtime config.
Definition tcp.cpp:819
static uint32_t conn_timeout_ms
Runtime connection-idle timeout in milliseconds.
Definition tcp.h:228
static void stop()
Abort all active connections and reset the pool to ConnState::CONN_FREE.
Definition tcp.cpp:838
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Layer 4 (Transport) - DiffServ QoS marking (RFC 2474) for outbound traffic.
pc_ip pc_ip_from_v4_octets(uint8_t a, uint8_t b, uint8_t c, uint8_t d)
Build a v4 pc_ip from four octets (a.b.c.d).
Definition ip.cpp:607
pc_ip pc_ip_from_v6_bytes(const uint8_t bytes[16])
Build a v6 pc_ip from 16 address bytes in network (big-endian) order.
Definition ip.cpp:619
@ PC_IP_NONE
empty / unparsed
bool listener_enqueue(uint8_t listener_id, const TcpEvt *evt)
Post evt to the queue owned by listener listener_id.
Definition listener.cpp:334
#define CONN_POOL_SLOTS
#define CONN_TIMEOUT_MS
Compile-time default for connection idle timeout in milliseconds.
#define PC_CLOSING_TIMEOUT_MS
Upper bound (ms) a slot may dwell in ConnState::CONN_CLOSING after a graceful close before the idle s...
std::atomic< uint32_t > free_mask
Definition tcp.cpp:434
A single TCP connection context.
Definition tcp.h:72
uint32_t req_start_ms
Definition tcp.h:77
struct tcp_pcb * pcb
lwIP PCB; null when slot is free.
Definition tcp.h:75
uint32_t last_activity_ms
millis() timestamp of last TX/RX event.
Definition tcp.h:76
uint8_t id
Fixed slot index (0 … MAX_CONNS-1).
Definition tcp.h:73
uint8_t rx_buffer[RX_BUF_SIZE]
Ring buffer storage.
Definition tcp.h:81
uint8_t owner
Worker that owns this slot (round-robin at accept). Always 0 at N=1.
Definition tcp.h:89
pc_atomic< size_t > rx_tail
Consumer read index (worker context).
Definition tcp.h:83
uint8_t listener_id
Index into listener_pool[]; set at accept time.
Definition tcp.h:88
uint8_t tls
Non-zero when this connection is TLS (set at accept time).
Definition tcp.h:94
size_t rx_acked
Definition tcp.h:84
pc_atomic< ConnState > state
Lifecycle state; acquire/release for inter-task visibility.
Definition tcp.h:74
pc_atomic< size_t > rx_head
Producer write index (lwIP/tcpip context).
Definition tcp.h:82
Event record posted from lwIP callbacks to the session layer.
Definition tcp.h:161
TaskHandle_t tcpip_task
Definition tcp.cpp:215
Runtime-tunable server parameters.
A v4 or v6 address in network (big-endian) byte order.
Definition ip.h:52
pc_ip_family family
address family tag
Definition ip.h:53
u16_t len
Definition tcp.cpp:247
err_t result
outcome of the op (pc_tcp_op::PC_OP_SEND: whether the write was queued)
Definition tcp.cpp:249
bool flush
pc_tcp_op::PC_OP_SEND: also tcp_output() after a successful write (coalesced write+flush)
Definition tcp.cpp:248
pc_tcp_op op
Definition tcp.cpp:243
const void * data
Definition tcp.cpp:246
struct tcpip_api_call_data base
Definition tcp.cpp:242
struct tcp_pcb * pcb
Definition tcp.cpp:245
uint8_t slot
Definition tcp.cpp:244
void pc_conn_touch_active(uint8_t slot_id)
Refresh slot's idle-timeout timestamp while a response body is in flight.
Definition tcp.cpp:945
void pc_conn_set_state(uint8_t slot, ConnState st)
The single conn_pool[slot].state write path. Writes the state (release) and keeps the free-slot bitma...
Definition tcp.cpp:446
int32_t pc_conn_alloc_free()
First CONN_FREE slot via a ctz on the bitmask (replaces the MAX_CONNS scan); -1 if the pool is full....
Definition tcp.cpp:480
void pc_conn_detach(struct tcp_pcb *pcb)
Detach pcb from its slot's lwIP callbacks before the slot is freed.
Definition tcp.cpp:708
void pc_conn_abort_slot(uint8_t slot)
Hard-abort connection slot (RST) for a fatal condition. The transport owns the teardown order: free t...
Definition tcp.cpp:682
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:425
void pc_lwip_to_ip(const ip_addr_t *ra, pc_ip *out)
A stable per-peer 32-bit identity key for slot (the v4 address, or an FNV-1a hash of a v6 address)....
Definition tcp.cpp:894
err_t lowlevel_recv_cb(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err)
lwIP receive callback - fires when data arrives on a connection.
Definition tcp.cpp:1033
bool pc_conn_raw_send(struct tcp_pcb *pcb, const void *data, u16_t len)
Write raw bytes straight to pcb (no TLS), context-safe.
Definition tcp.cpp:623
void pc_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:561
uint32_t pc_ap_ip
softAP IPv4 address (network byte order) for STA/AP interface tagging.
Definition tcp.cpp:487
u16_t pc_conn_sndbuf(uint8_t slot)
Bytes that can currently be queued for sending on slot.
Definition tcp.cpp:542
uint32_t pc_conn_remote_ip(uint8_t slot)
Raw source IPv4 of the connection in slot, or 0 if the slot has no active pcb (or on host builds)....
Definition tcp.cpp:872
void pc_conn_ack_consumed(uint8_t slot)
Reopen the TCP receive window by however much slot has drained.
Definition tcp.cpp:595
void pc_conn_close(uint8_t slot)
Close connection slot gracefully (tcp_close), aborting if the FIN cannot be queued....
Definition tcp.cpp:645
err_t lowlevel_sent_cb(void *arg, struct tcp_pcb *tpcb, u16_t len)
lwIP sent callback - fires after the stack acknowledges sent bytes.
Definition tcp.cpp:1147
void lowlevel_err_cb(void *arg, err_t err)
lwIP error callback - fires when the stack detects a fatal error.
Definition tcp.cpp:1177
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
uint8_t pc_conn_active_count()
Number of server connection slots currently in the CONN_ACTIVE state.
Definition tcp.cpp:859
void pc_conn_begin_close(uint8_t slot_id)
Begin a graceful close that dwells in ConnState::CONN_CLOSING until the peer ACKs.
Definition tcp.cpp:778
pc_tcp_op
Definition tcp.cpp:192
@ PC_OP_CLOSE_CHECK
bool pc_conn_remote_addr(uint8_t slot, pc_ip *out)
The connected peer's address as a family-tagged pc_ip (IPv4 or IPv6).
Definition tcp.cpp:912
bool pc_conn_send_flush(uint8_t slot, const void *data, u16_t len)
Send len bytes on slot and flush in a single tcpip_thread round-trip.
Definition tcp.cpp:518
void pc_conn_abort(struct tcp_pcb *pcb)
Hard-abort pcb (RST) for a fatal condition; no graceful FIN.
Definition tcp.cpp:719
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.
void pc_conn_set_state(uint8_t slot, ConnState st)
The single conn_pool[slot].state write path. Writes the state (release) and keeps the free-slot bitma...
Definition tcp.cpp:446
void pc_conn_detach(struct tcp_pcb *pcb)
Detach pcb from its slot's lwIP callbacks before the slot is freed.
Definition tcp.cpp:708
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:425
#define PC_OBS_NOTICE(slot, st, reason)
Definition tcp.h:529
@ EVT_DATA
Data received; bytes are already in the ring buffer.
@ EVT_DISCONNECT
Remote peer closed the connection gracefully.
@ EVT_ERROR
lwIP reported an error (PCB may already be freed).
#define PC_OBS_TRANSITION(slot, olds, news, reason)
Definition tcp.h:528
ConnState
Lifecycle state of a connection pool slot.
Definition tcp.h:58
@ CONN_CLOSING
FIN sent; waiting for final ACK (reserved).
@ CONN_ACTIVE
Live connection; PCB is valid.
@ CONN_FREE
Slot is available; no PCB is attached.
void pc_conn_abort(struct tcp_pcb *pcb)
Hard-abort pcb (RST) for a fatal condition; no graceful FIN.
Definition tcp.cpp:719
Deterministic TLS engine: mbedTLS over a static memory pool (PC_ENABLE_TLS).
void pc_worker_wake(int worker_id)
Wake worker worker_id so it services a freshly-queued event now.
Definition worker.cpp:161
Layer 5 (Session) - server worker identity.