Files
keyrelay/src/conn.h
T
ducoterra c43a088ce8 phase: 04_controller_page_responsive
**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.

**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.

**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).

**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.

**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.

**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
2026-11-09 11:00:27 -05:00

664 lines
29 KiB
C++

/**
* keyrelay -- the transport view: one byte pipe, two implementations, and the tables that
* decide what the *plaintext* port is allowed to say.
*
* Why this file exists
* --------------------
* `ControlServer` used to carry two HTTP paths: one that wrote straight to a `WiFiClient`
* and one that staged bytes for an mbed TLS session and pumped them across ticks. Two
* paths that can disagree about a route is the failure mode this project keeps having to
* fix (AGENTS.md, invariant 8: both read-only routes go through `serveReadonly()` "so they
* cannot drift apart"), and a half-encrypted server is the shape that drifts. So the
* transport stops being a routing question: the routing code asks a `Conn` to read, write
* and drain, and does not know -- and must not care -- which of the two it is holding.
*
* What is pure, and where each half is proven
* -------------------------------------------
* The PURE half is everything that decides rather than I/Os: the HTTP response head, the
* `:80` answer table (`plaintextRoute`), the redirect target builder (which is the one
* place a client-supplied `Host:` and request path become bytes a browser will navigate
* to), and the flash-body send cursor. It includes no Arduino, no IDF and no socket
* header, so tests/test_conn.cpp walks it under sanitisers exactly like tls_pem.h.
*
* The LIBRARY half is `Conn` itself, compiled only where `WiFiClient` exists. It is proven
* the way tls_session.h is: both firmware environments link, and .agents/HW_VERIFICATION.md
* records what a real client did with it (page byte-exact over TLS, `:80` redirecting, a
* plaintext `/ws` upgrade refused with 426).
*
* Tagged union, not virtual -- measured, then chosen
* --------------------------------------------------
* `Conn` is a kind tag plus two pointers, not a base class with two derived classes.
* Measured on the target toolchain (`xtensa-esp32s3-elf-g++ -Os`, two probe translation
* units performing the same dispatch): the virtual form emits `l32i` + `l32i` + `callx8`
* -- two dependent loads and an indirect call -- and came out *smaller* at the call site,
* 82 B against the tag switch's 98 B. So this choice is not about the cost of one dispatch,
* and pretending it were would be a comment rationalising a decision instead of recording
* it. It is about three other things:
*
* - a Conn is RE-BOUND between transports as slots come and go (`bindPlain` on an accept
* at :80, `bindTls` on an accept at :443, the same slot object either way). A vtable
* expresses that only by placement-new over a live object, or by holding both derived
* objects and a pointer to the live one -- which is this union, plus an indirection and
* a lifetime question nobody has to answer;
* - `callx8` is not inlinable. `writeTextFrame()` -> `push()` -> `pump()` -> `writeRaw()`
* is the road a keystroke frame leaves on, and every hop of a virtual chain there is a
* call, while a branch on a value that never changes between a bind and a reset is one
* the predictor sees coming and the inliner can see through;
* - no vptr per object and no vtable to place: static RAM is the scarce resource on this
* board (no PSRAM) and `Conn` lives inside an object that is already ~11.7 kB of it.
*
* The write contract
* ------------------
* `pump()` is resumable on every transport and never blocks `loop()`:
* - plaintext asks lwIP whether the socket will take bytes *this instant* (zero-timeout
* select) and returns "nothing written" rather than entering the Arduino write helper,
* which retries a blocking select ten times with a one-second timeout each;
* - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which
* is that library's documented resume rule and is enforced in tls::Session;
* - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and
* a body that lives in flash is copied through a bounded RAM chunk, so a ~58 KB page
* leaves in bounded pieces across ticks instead of in one call that can wait.
* Nothing here ever waits. A peer that stops reading is dropped by the caller's stall
* deadline, which is the same rule that already applied to a stalled *request*.
*
* What this file must never become
* --------------------------------
* A Conn is a byte pipe plus one opinion: what the plaintext port is allowed to say. It
* protects nothing else, and it must not grow. No route, no credential check, no arming
* decision and no "but this one is over TLS" may land here -- the transport is not an authority
* (README, Safety model), which is the same sentence that keeps a `wss://` controller from being
* privileged and a `:80` socket from being trusted. Two specific decisions are load-bearing and
* have tests: a token-carrying request and a `/ws` upgrade on the plaintext port are answered
* 426 rather than 301, because a `Location:` that echoes `?token=` leaks the passphrase twice on
* the wire the redirect is escaping; and exactly one formatter in `src/` writes an HTTP status
* line, so the encrypted answer and the plaintext answer cannot disagree about anything except
* which of them they are (AGENTS.md invariant 8).
*/
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h> // snprintf, for the one response-head formatter
#include <string.h>
#include "config.h"
#include "tls_session.h" // tls::TxRing, tls::Handshake (+ Session behind KR_TLS && ESP_PLATFORM)
// WiFiClient is a typedef of NetworkClient in this core, so it cannot be forward-declared;
// including it is the only way to name it. Guarded, exactly like the mbed TLS includes in
// tls_session.h, so a host translation unit (tests/test_conn.cpp) can include THIS header
// and walk the pure half with no Arduino installed.
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#include <sys/select.h>
#include <sys/time.h>
#endif
namespace kr {
// ---------------------------------------------------------------------------
// Pure: the HTTP response head
// ---------------------------------------------------------------------------
// The two content paths, named once. The redirect builder below and the route table in
// control_server.cpp both need them, and a second spelling of "/index.html" is a second
// answer to "what does this board serve" -- which is the drift this phase exists to remove.
constexpr char kPathIndex[] = "/";
constexpr char kPathIndexHtml[] = "/index.html";
/**
* One response formatter for every route, on every transport, in every build. It lives
* here (pure) rather than in the server because a header block is a wire format whose
* failure mode is silent: `snprintf` truncating it used to mean `headLen = 0`, which meant
* the client got a closed socket and no explanation. The cap it is given,
* `kHttpResponseHeadCap` (config.h, with the arithmetic), is asserted in
* tests/test_conn.cpp against the *widest legal* response -- a 63-character authority and
* the longest path a redirect carries over -- not believed.
*
* Returns the length written, or 0 when `cap` cannot hold the whole block (never a partial
* head: half an HTTP response is a desynchronised stream).
*/
inline size_t renderHttpResponseHead(char *out, size_t cap, const char *status,
const char *contentType, size_t bodyLen,
const char *extra = nullptr) {
if (!out || cap < kHttpResponseHeadCap) return 0;
const int n = snprintf(out, cap,
"HTTP/1.1 %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %u\r\n"
"Cache-Control: no-store\r\n"
"X-Content-Type-Options: nosniff\r\n"
"%s"
"Connection: close\r\n\r\n",
status, contentType, (unsigned)bodyLen, extra ? extra : "");
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
}
// ---------------------------------------------------------------------------
// Pure: what :80 is allowed to answer when this build serves TLS
// ---------------------------------------------------------------------------
/**
* `Serve` is the pre-TLS (and `-D KR_TLS=0`) behaviour: answer the request here.
* `Redirect` is a 301 to the same path over `https://`.
* `UpgradeRequired` is 426: the client is asking for something this port refuses to do at
* all, and the truthful answer is "use TLS", not a redirect.
*
* Why 426 and not a redirect for two of the shapes (the assumption this task was written
* against, and the reason is a leak): a `Location:` that echoes `?token=<passphrase>`
* exposes the passphrase TWICE -- once in the plaintext request and once in the plaintext
* redirect the browser dutifully re-issues -- and would also train operators to paste the
* passphrase into a `http://` bar. A plaintext `/ws` upgrade gets the same code because
* silently accepting the weaker thing is never the default (invariant 5): the browser must
* fail, read the answer, and reconnect to `https://`.
*/
enum class PlainRoute : uint8_t { Serve, Redirect, UpgradeRequired };
/**
* `token=` present as a *key* in the query string. The value is never copied -- this
* answers "is the passphrase in this URL", and copying it here would put the thing the
* whole response exists to protect into a buffer for no reason. Same boundary rule as
* ws::tokenFromQuery(): at the start of the query or straight after a '&', so a path like
* `/status?mytoken=x` is not a token-carrying request.
*/
inline bool queryCarriesToken(const char *query) {
static const char kKey[] = "token=";
const size_t kl = sizeof(kKey) - 1;
if (!query) return false;
// Bounded by the length, not by the NUL: memcmp() of a 6-byte key against the last two
// bytes of "a=1" reads four bytes past the buffer. The sanitiser found exactly that on
// the first run of this file, which is why the boundary is written as `i + kl <= len`
// (the same shape ws::tokenFromQuery uses) and why tests/test_conn.cpp feeds it every
// short prefix of the key.
const size_t len = strlen(query);
for (size_t i = 0; i + kl <= len; i++) {
if (i != 0 && query[i - 1] != '&') continue;
if (memcmp(query + i, kKey, kl) == 0) return true;
}
return false;
}
inline PlainRoute plaintextRoute(bool upgradeWebsocket, const char *query, bool tlsBuild) {
if (!tlsBuild) return PlainRoute::Serve; // the rollback build IS the plaintext server
if (upgradeWebsocket) return PlainRoute::UpgradeRequired;
if (queryCarriesToken(query)) return PlainRoute::UpgradeRequired;
return PlainRoute::Redirect;
}
// ---------------------------------------------------------------------------
// Pure: the redirect target
// ---------------------------------------------------------------------------
/**
* The authority for a `Location:` header, from the client's own `Host:`.
*
* Not a cosmetic decision: `Location` is bytes a browser navigates to. `Host` is attacker
* text on an attacker-watched port, and `http://keyrelay.local@evil.com/` puts `evil.com`
* in it -- which would make this board an open redirect onto a phishing page, on the one
* port whose whole purpose is to say "go here instead". So the Host is accepted only when
* it is a name this board can actually be reached at: the certificate's name, the bare
* hostname, or a private-use dotted quad (the README's "IP if the name fails" path, which
* is why a bare IP has to keep working). Anything else falls back to `kCertCommonName`,
* which is always right about this board.
*
* A `:port` suffix is stripped (a redirect keeps the scheme's default port). Returns false
* only when even the fallback would not fit `cap`, which cannot happen with the names in
* config.h -- and is still checked, because that is how a name change stays honest.
*/
inline bool dottedQuadPrivate(const char *h, size_t n) {
// Every index into octets[] is guarded BEFORE the write, not at the end: the first
// version validated the octet count when a dot arrived, so "192.168.1.1.1" wrote
// octets[4] -- a stack overflow on the way to answering "is this a private address?".
// ASan found it on the first run; tests/test_conn.cpp keeps it found.
unsigned octets[4] = {0, 0, 0, 0};
size_t oi = 0, digits = 0;
for (size_t i = 0; i <= n; i++) {
if (i == n || h[i] == '.') {
if (digits == 0 || oi >= 4) return false;
oi++;
digits = 0;
continue;
}
if (h[i] < '0' || h[i] > '9' || oi >= 4) return false;
octets[oi] = octets[oi] * 10u + static_cast<unsigned>(h[i] - '0');
if (octets[oi] > 255u) return false; // bound it where it is built, not later
digits++;
}
if (oi != 4) return false;
// 10/8, 172.16/12, 192.168/16, link-local, loopback. Nothing else is a name this board
// is plausibly reached at by number, and an attacker-supplied public IP never is.
return octets[0] == 10 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) ||
(octets[0] == 192 && octets[1] == 168) || octets[0] == 169 || octets[0] == 127;
}
inline bool ciEqN(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
const char ca = (a[i] >= 'A' && a[i] <= 'Z') ? static_cast<char>(a[i] + 32) : a[i];
const char cb = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast<char>(b[i] + 32) : b[i];
if (ca != cb || ca == '\0') return false;
}
return true;
}
inline size_t strLenN(const char *s, size_t max) {
size_t n = 0;
while (n < max && s[n]) n++;
return n;
}
inline bool redirectAuthority(const char *headerHost, char *out, size_t cap) {
const char *fallback = kCertCommonName;
char cand[80];
size_t n = headerHost ? strLenN(headerHost, sizeof(cand) - 1) : 0;
if (headerHost && n) {
memcpy(cand, headerHost, n);
cand[n] = '\0';
// strip :port -- but a second colon means an IPv6 literal or garbage, not a name
for (size_t i = 0; i < n; i++) {
if (cand[i] != ':') continue;
bool digits = i + 1 < n;
for (size_t j = i + 1; j < n; j++)
if (cand[j] < '0' || cand[j] > '9') digits = false;
if (!digits) {
n = 0;
break;
}
cand[i] = '\0';
n = i;
break;
}
bool ok = n > 0 && n < sizeof(cand);
for (size_t i = 0; ok && i < n; i++) {
const char c = cand[i];
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '-';
}
ok = ok && cand[0] != '.' && cand[0] != '-' && cand[n - 1] != '.' && cand[n - 1] != '-';
const size_t flen = strlen(kCertCommonName);
const size_t hlen = strlen(kHostname);
const bool isCertName = n == flen && ciEqN(cand, kCertCommonName, n);
const bool isHostname = n == hlen && ciEqN(cand, kHostname, n);
ok = ok && (isCertName || isHostname || dottedQuadPrivate(cand, n));
// Accepted: emit the canonical spelling, not the caller's. A DNS name is
// case-insensitive, so `KEYRELAY.LOCAL` matched -- and echoing a client's mixed case
// into a header this board is authoring buys nothing worth the ambiguity.
if (isCertName) fallback = kCertCommonName;
else if (isHostname) fallback = kHostname;
else if (ok) fallback = cand;
else n = 0;
}
const size_t len = strlen(fallback);
if (!out || cap < len + 1) return false;
memcpy(out, fallback, len + 1);
return true;
}
/**
* `https://<authority><path>` -- the whole `Location` value.
*
* Only the two paths this board actually has are carried over (`/` and `/index.html`);
* anything else redirects to `/`. That is both the honest answer -- the board has two
* content paths and everything else is a 404 on the encrypted port too -- and the safe
* one: the request target is client text, and a bare CR or LF in it would inject headers
* into the response this board is building. The character check below stays in as defence
* in depth for whoever reuses this function with a longer path. The query is NEVER carried
* over: the only query this board answers is `/status?token=`, and that request gets 426
* precisely so its query does not travel twice.
*
* Returns the length written (the caller puts it in a body or a log), 0 if it would not fit.
*/
inline size_t renderRedirectTarget(const char *headerHost, const char *path, char *out,
size_t cap) {
if (!out || cap == 0) return 0;
char authority[80];
if (!redirectAuthority(headerHost, authority, sizeof(authority))) return 0;
const char *safe = "/";
if (path && path[0] == '/' && path[1]) {
bool ok = true;
for (size_t i = 1; path[i]; i++) {
const char c = path[i];
ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '-' || c == '_' || c == '~' || c == '/' || c == '%' ||
c == '+' || c == '=';
if (!ok) break;
}
// ... and one of the two paths this board serves. Both halves are load-bearing: the
// character check stops injection, this one bounds the head and refuses to echo an
// arbitrary target back as a promise.
if (ok && (strcmp(path, kPathIndexHtml) == 0 || strcmp(path, kPathIndex) == 0))
safe = path;
}
const int n = snprintf(out, cap, "https://%s%s", authority, safe);
return (n > 0 && static_cast<size_t>(n) < cap) ? static_cast<size_t>(n) : 0;
}
// ---------------------------------------------------------------------------
// Pure: a body that lives in flash, drained in bounded pieces
// ---------------------------------------------------------------------------
/**
* The controller page is a PROGMEM raw string of several tens of kilobytes and no socket
* call here may be handed all of it at once. This is the cursor that decides what leaves
* next: at most `kPageChunkBytes` per transport write (the copy that bounds one call), and
* at most `kPageBytesPerTick` per tick (the budget that bounds one loop() iteration).
*
* The bytes are COPIED into a bounded RAM buffer rather than passed through, even though on
* ESP32 flash is memory-mapped and readable in place: the same code serves a transport that
* reads the pointer directly (plaintext lwIP) and one that encrypts it (mbed TLS), and a
* copy of one MSS is cheaper than a rule about what a transport may dereference. It also
* keeps the resume arguments -- the bytes a partial write re-hands -- stable and small.
*
* `sent`/`total` are the per-connection send cursor the routing code keeps, and they stay
* readable after the send completes so a caller can report "the page left intact".
*/
class FlashSend {
public:
void start(const char *src, size_t len) {
_src = (src && len) ? src : nullptr;
_total = _src ? len : 0;
_sent = 0;
}
void stop() {
_src = nullptr;
_total = 0;
_sent = 0;
}
bool armed() const { return _src != nullptr; } // more bytes to copy
bool done() const { return _sent >= _total; } // everything has been handed over
size_t sent() const { return _sent; }
size_t total() const { return _total; }
size_t remaining() const { return _total - _sent; }
// How many bytes to copy for the next write: 0 when finished, when no budget is left, or
// when the chunk would be empty. Never more than one chunk, never more than the budget.
size_t next(size_t chunkCap, size_t budgetLeft) const {
if (!armed() || chunkCap == 0 || budgetLeft == 0) return 0;
size_t n = remaining();
if (n > chunkCap) n = chunkCap;
if (n > budgetLeft) n = budgetLeft;
return n;
}
// Copy `n` bytes at the cursor. On ESP32 `_src` is in flash and a plain memcpy reads it
// (there is no Harvard split to mind, unlike AVR's pgm_read_*); on the host it is memory.
void copyTo(char *dst, size_t n) const {
if (!armed() || !dst) return;
if (n > remaining()) n = remaining();
memcpy(dst, _src + _sent, n);
}
// Called with what the transport actually accepted. Completing the send clears the source
// (so armed() goes false and the caller can close the link) but keeps sent()/total().
void advance(size_t n) {
if (!armed()) return;
const size_t left = remaining();
_sent += (n < left) ? n : left;
if (_sent >= _total) _src = nullptr;
}
private:
const char *_src = nullptr;
size_t _total = 0;
size_t _sent = 0;
};
// ---------------------------------------------------------------------------
// The transport itself (ESP only -- this is the half that touches sockets)
// ---------------------------------------------------------------------------
#if defined(ESP_PLATFORM)
/**
* One control link, either transport, seen as a byte pipe with a resumable outbound queue.
*
* Not a base class (see the header comment). Not copyable: it is a *view* of a socket that
* somebody else owns -- `ControlServer` owns the `WiFiClient` and the `tls::Session`
* objects, and a copy of the view would be a second owner of the same close().
*
* Outbound bytes are queued, never written from the caller's stack frame: `push()` takes a
* whole frame or nothing (a half-queued WebSocket frame corrupts the stream; a dropped
* status frame is one second of staleness), and `pump()` drains what the transport will
* take this tick. Inbound is a straight `read()` -- the caller's buffer IS the framing
* state, which is why requests need no queue.
*/
class Conn {
public:
Conn() = default;
Conn(const Conn &) = delete;
Conn &operator=(const Conn &) = delete;
void bindPlain(WiFiClient *client, uint8_t *ringBuf, size_t ringCap) {
resetOut();
_kind = Kind::Plain;
_plain = client;
_ring.reseat(ringBuf, ringCap);
}
#if KR_TLS
void bindTls(tls::Session *session, uint8_t *ringBuf, size_t ringCap) {
resetOut();
_kind = Kind::Tls;
_tls = session;
_ring.reseat(ringBuf, ringCap);
}
#endif
// Forget the transport without closing it: the slot is being handed to a different socket
// object, and whatever was queued for the old one is not coming.
void unbind() {
resetOut();
_kind = Kind::None;
_plain = nullptr;
#if KR_TLS
_tls = nullptr;
#endif
}
bool isTls() const { return _kind == Kind::Tls; }
bool bound() const { return _kind != Kind::None; }
/** A socket is held, handshake pending or not. */
bool attached() const {
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain);
#if KR_TLS
if (_kind == Kind::Tls) return _tls && _tls->alive();
#endif
return false;
}
/** ... and the peer is still there. This is the question a controller link asks. */
bool alive() const {
if (_kind == Kind::Plain) return _plain && static_cast<bool>(*_plain) && _plain->connected();
#if KR_TLS
if (_kind == Kind::Tls) return _tls && _tls->alive();
#endif
return false;
}
/** Application bytes may flow. Plaintext is ready the moment it is attached; TLS not
* until the handshake completes. */
bool ready() const {
if (_kind == Kind::Plain) return attached();
#if KR_TLS
if (_kind == Kind::Tls) return _tls && _tls->ready();
#endif
return false;
}
/** One non-blocking step of the handshake. Plaintext has none, and says so. */
tls::Handshake poll() {
if (_kind == Kind::Plain) return tls::Handshake::Ready;
#if KR_TLS
if (_kind == Kind::Tls && _tls) return _tls->poll();
#endif
return tls::Handshake::Failed;
}
/** Application bytes in. 0 means "nothing right now" (or "gone"), never "spin". */
size_t read(uint8_t *dst, size_t cap) {
if (!dst || cap == 0) return 0;
if (_kind == Kind::Plain) {
if (!_plain || !static_cast<bool>(*_plain) || !_plain->available()) return 0;
const int n = _plain->read(dst, cap);
return n > 0 ? static_cast<size_t>(n) : 0;
}
#if KR_TLS
if (_kind == Kind::Tls && _tls) return _tls->read(dst, cap);
#endif
return 0;
}
/** Close the transport. Never waits; the queue goes with it. */
void stop() {
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->stop();
#if KR_TLS
else if (_kind == Kind::Tls && _tls) _tls->close();
#endif
resetOut();
}
/**
* Last-chance flush before a close, plaintext only: without it the FIN that follows a
* WebSocket close frame often arrives as an RST and the peer never sees the goodbye.
* Over TLS there is nothing to flush -- the close_notify rides the same queue, and
* tls::Session::close() sends what it can and moves on.
*/
void flushBeforeClose() {
if (_kind == Kind::Plain && _plain && static_cast<bool>(*_plain)) _plain->flush();
}
// ---- outbound -------------------------------------------------------------
/** Whole frame or nothing: false leaves the queue untouched. */
bool push(const uint8_t *src, size_t len) {
if (!src || len == 0) return len == 0;
return _ring.push(src, len);
}
/**
* Two segments queued atomically -- an HTTP head followed by a body that lives in RAM
* (a status JSON, a one-line error). Either both fit or neither is queued, because a
* head without its body is a response that lies about Content-Length.
*/
bool pushPair(const uint8_t *a, size_t alen, const uint8_t *b, size_t blen) {
if (alen + blen > _ring.capacity() - _ring.count()) return false;
if (alen && !_ring.push(a, alen)) return false;
if (blen && !_ring.push(b, blen)) return false; // cannot happen: sized above
return true;
}
/**
* A head in RAM followed by a body in flash: the page, and nothing else is this big.
* The head is queued, the body is armed here and copied through the chunk buffer as the
* pump gets to it.
*/
bool pushFlashBody(const uint8_t *head, size_t headLen, const char *body, size_t bodyLen) {
if (!body || bodyLen == 0) return push(head, headLen) || headLen == 0;
if (headLen && headLen > _ring.capacity() - _ring.count()) return false;
if (headLen && !_ring.push(head, headLen)) return false;
_flash.start(body, bodyLen);
return true;
}
bool busyOut() const {
return _ring.count() != 0 || _pendLen != 0 || _flash.armed();
}
size_t queuedOut() const { return _ring.count() + _pendLen + _flash.remaining(); }
const FlashSend &flashSend() const { return _flash; }
/**
* Hand the transport as much as it will take, up to `budgetBytes` this call, one write of
* at most `kConnWriteChunk` at a time. True means the queue is empty; false means "not
* yet" -- including "the peer is not reading", which the caller owns as a stall deadline.
* Never waits, never spins, and a partial write resumes from the identical bytes.
*/
bool pump(size_t budgetBytes) {
while (budgetBytes > 0) {
if (_pendLen == 0) {
size_t n = 0;
const size_t cap = budgetBytes < kConnWriteChunk ? budgetBytes : kConnWriteChunk;
const uint8_t *p = _ring.head(cap, &n);
if (p && n) {
_pendPtr = p;
_pendIsChunk = false;
_pendLen = n;
} else if (_flash.armed()) {
// The ring first, always: the head precedes the body it describes.
const size_t c = _flash.next(kPageChunkBytes, budgetBytes);
if (c == 0) return false; // budget spent; the rest goes next tick
_flash.copyTo(_chunk, c);
_pendPtr = reinterpret_cast<const uint8_t *>(_chunk);
_pendIsChunk = true;
_pendLen = c;
} else {
return true;
}
}
const size_t acc = writeRaw(_pendPtr, _pendLen);
if (acc == 0) return false;
if (_pendIsChunk) _flash.advance(acc);
else _ring.advance(acc);
_pendPtr += acc;
_pendLen -= acc;
budgetBytes -= (acc < budgetBytes) ? acc : budgetBytes;
}
return !busyOut();
}
/** Drop everything queued. Used when the link is going away, never mid-response. */
void resetOut() {
_ring.reset();
_pendPtr = nullptr;
_pendIsChunk = false;
_pendLen = 0;
_flash.stop();
}
private:
/** What the transport accepted this call: whole, partial or nothing. Never waits. */
size_t writeRaw(const uint8_t *p, size_t len) {
if (!p || len == 0) return 0;
if (_kind == Kind::Plain) {
if (!_plain || !static_cast<bool>(*_plain)) return 0;
const int fd = _plain->fd();
if (fd < 0) return 0;
// Never enter NetworkClient::write() unless the socket will take bytes THIS instant.
// That helper retries a blocking select() up to WIFI_CLIENT_MAX_WRITE_RETRY (10) times
// with WIFI_CLIENT_SELECT_TIMEOUT_US (1 s) each, which is how one laptop that stopped
// reading -- locked tab, asleep phone, closed lid -- could hold loop(), and therefore
// the keystroke path, for ten seconds inside a single page write. A zero-timeout
// select turns "not now" into a 0 return and a retry on the next tick, which is what
// pump() already handles for TLS.
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(fd, &wfds);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
if (select(fd + 1, nullptr, &wfds, nullptr, &tv) <= 0) return 0;
if (!FD_ISSET(fd, &wfds)) return 0;
return _plain->write(p, len); // may be partial; the caller keeps the cursor
}
#if KR_TLS
if (_kind == Kind::Tls && _tls) return _tls->write(p, len);
#endif
return 0;
}
enum class Kind : uint8_t { None, Plain, Tls };
Kind _kind = Kind::None;
WiFiClient *_plain = nullptr; // owned by ControlServer, outlives every use here
#if KR_TLS
tls::Session *_tls = nullptr; // likewise
#endif
tls::TxRing _ring{nullptr, 1};
const uint8_t *_pendPtr = nullptr;
bool _pendIsChunk = false;
size_t _pendLen = 0;
FlashSend _flash;
char _chunk[kPageChunkBytes]; // the bounded RAM copy of the flash body
};
#endif // ESP_PLATFORM
} // namespace kr