/** * 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 #include #include // snprintf, for the one response-head formatter #include #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 #include #include #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(n) < cap) ? static_cast(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=` * 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(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(a[i] + 32) : a[i]; const char cb = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast(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://` -- 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(n) < cap) ? static_cast(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(*_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(*_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(*_plain) || !_plain->available()) return 0; const int n = _plain->read(dst, cap); return n > 0 ? static_cast(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(*_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(*_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(_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(*_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