Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// ═══════════════════════════════════════════════════════════
// PatternFlow - low-heap PROGMEM page sender
//
// WebServer's send_P delivered every console page for a year — until a
// resident module put internal heap near 7 KB, where big sends silently
// truncate at whatever the socket manages (~5.6 KB) and the page arrives
// with its script cut mid-statement. The old answer was to EVICT the module
// before serving (pause the pattern for the console). This is the new one:
// copy slices out of flash and hand-feed the socket, tolerating a full TCP
// window instead of giving up on it. The difference from send_P is not
// chunking (send_P also writes in pieces); it is the retry. Measured: 24 KB
// delivers reliably this way at heaps where send_P loses 3/4 of a 20 KB page.
//
// WHERE this runs decides what giving up may cost, and that changed in
// 3.9.1. Until then the drain ran inside loop(): nothing rendered while a
// page went out, and a HARD 5 s budget was the difference between a slow
// page and a panel that reads as dead. Since 3.9.1 handlers run on the
// network task (core_net_task.h, Core 0) and the render never sees a page
// send — but the budget was kept, and on a slow link it became the bug it
// had guarded against. Measured 2026-09-06 on a panel whose link moved
// 2–5 KB/s — round trips of ~0.4 s with retransmissions, and the Wi-Fi
// driver's eight transmit buffers shared with a MIDI stream; the TCP window
// alone would have allowed ten times that — every page over ~15 KB arrived
// cut short with the header already promising the full Content-Length:
// 25088 of 26551 bytes for /, 18432 of 37520 for /patterns, never more than
// 41 KB of a 63 KB feature page. The budget did exactly what it said, five
// seconds past the point where it made anything better.
//
// So there are two policies now, chosen by PFLoopSync::onLoopTask():
// - On the loop task (the single-core fallback when the network task
// could not be created, or a handler that came through
// PFLoopSync::run) the render still pays for every millisecond spent
// here, and the 5 s cap stays.
// - On the network task nobody waits but the browser. A send is
// abandoned only when it stops making PROGRESS — no byte accepted for
// ~20 s — or reaches a generous ceiling, 120 s, which exists so a peer
// trickling one segment a minute cannot hold the one-connection
// server forever.
// In both cases an abandoned send CUTS the connection, so the peer sees the
// transfer die (curl: "transfer closed with N bytes remaining"; a browser: a
// network error and a retry) instead of rendering a page whose script ends
// mid-word.
//
// Pages go out gzip-compressed (gz() below; the arrays are generated right
// after each page's literal, in the same header): a third of the bytes, so
// a third of the round trips, on exactly the links where round trips are
// the whole cost.
//
// And since the console UX pass, most of them do not go out at all. The
// cheapest page on a slow link is the one the browser already has, so gz()
// also decides how a page may be cached (the policies below): a page URL
// that names the running build (?v=, core_build.h) is immutable, a bare one
// revalidates with an ETag and costs a 304, and the chrome is keyed by its
// own CRC (?h=). Every page handler gets that without passing anything.
//
// License: MIT
// ═══════════════════════════════════════════════════════════
#pragma once
#include <Arduino.h>
#include <pgmspace.h>
#include <lwip/sockets.h>
#include <errno.h>
#include "core_net_maintenance.h"
#include "webserver/WebServer.h" // vendored: fixes the 5 s final-chunk stall (see src/webserver/VENDORED.md)
#include "core_loop_sync.h" // onLoopTask(): which of the two budgets applies
#include "core_build.h" // PFBuild::id(): the build a page's ?v= must name
namespace PFSend {
// One TCP segment (TCP_MSS 1436 in this sdkconfig) per write(), up from 512.
// Each write hands lwIP a segment it can send whole instead of three it has
// to coalesce, and a 17 KB page is 13 syscalls instead of 34. The buffer is
// on the caller's stack: the network task has 8 KB with ~5.9 KB never used
// (netStackMin in /api/status), so the extra 924 bytes are paid for.
constexpr size_t SLICE_BYTES = 1436;
// Loop task: the render is stalled for the duration, and five seconds covers
// every page at module-resident heap (measured 0.5–4.4 s before gzip made
// each of them smaller). Checked between nonblocking send attempts.
constexpr uint32_t LOOP_BUDGET_MS = 5000;
// Network task: give up on a peer that has accepted nothing for this long...
constexpr uint32_t NET_STALL_MS = 20000;
// ...or on a transfer that has outlived any honest link, however slowly it
// is still moving.
constexpr uint32_t NET_CEILING_MS = 120000;
// End a response the peer must not mistake for complete. Nothing lingers:
// the vendored WebServer lets go of its client the moment the handler
// returns (its keep-the-client-around branch is commented out), lwIP queues
// the FIN behind whatever the window still holds, and the peer gets a body
// shorter than the Content-Length it was promised — curl says "transfer
// closed with N bytes remaining", a browser reports the mismatch — instead
// of a page whose script ends mid-word looking complete. stop() here only
// drops this copy's handle and marks it disconnected, which is what ends the
// drain loop; the log line is the part that matters.
inline void cut(WiFiClient& client, size_t sent, size_t total, uint32_t elapsedMs) {
Serial.printf("[SEND] cut at %u/%u bytes after %lu ms\n",
(unsigned)sent, (unsigned)total, (unsigned long)elapsedMs);
client.stop();
}
// The drain shared by progmem() and gz(): the headers are already out, and
// `total` bytes at `src` (flash or RAM) still have to reach the socket.
inline void drain(WebServer& server, const uint8_t* src, size_t total) {
WiFiClient client = server.client();
const bool onLoop = PFLoopSync::onLoopTask();
const uint32_t startedMs = millis();
uint32_t lastProgressMs = startedMs;
size_t offset = 0;
uint8_t slice[SLICE_BYTES];
while (offset < total && client.connected()) {
PFNetMaintenance::poll();
const uint32_t now = millis();
const bool giveUp = onLoop
? (now - startedMs > LOOP_BUDGET_MS)
: (now - lastProgressMs > NET_STALL_MS || now - startedMs > NET_CEILING_MS);
if (giveUp) {
cut(client, offset, total, now - startedMs);
return;
}
size_t n = total - offset;
if (n > sizeof(slice)) n = sizeof(slice);
memcpy_P(slice, src + offset, n);
// WiFiClient::write can retry internally for ~10 s. A nonblocking socket
// send gives this loop ownership of the existing progress/stall budgets.
const int wrote = ::send(client.fd(), slice, n, MSG_DONTWAIT);
if (wrote < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
cut(client, offset, total, millis() - startedMs);
return;
}
if (wrote <= 0) {
// Preserve slow-link tolerance without starving maintenance or IDLE0.
delay(5);
continue;
}
offset += wrote;
lastProgressMs = millis();
if (offset >= total) {
const uint32_t took = millis() - startedMs;
if (took > 500) Serial.printf("[SEND] %u bytes in %lu ms\n", (unsigned)total, (unsigned long)took);
}
// A tick between slices rather than yield(): yield() gives way only to
// tasks of equal or higher priority, and the one that has to get a turn
// is IDLE0, which the watchdog watches. Costs a 17 KB page ~13 ms.
delay(1);
}
}
// Serve a PROGMEM literal as-is. Still the right call for a body that is not
// precompressed (a feature page whose header has no gzip array yet).
// cacheControl lets immutable assets keep their max-age.
inline void progmem(WebServer& server, const char* pgm,
const char* contentType = "text/html",
const char* cacheControl = "no-store") {
const size_t total = strlen_P(pgm);
server.sendHeader("Cache-Control", cacheControl);
server.setContentLength(total);
server.send(200, contentType, "");
drain(server, reinterpret_cast<const uint8_t*>(pgm), total);
}
// ── Cache policies for gz() ────────────────────────────────────────────
//
// gz()'s last argument is one of these two, or a literal Cache-Control
// value, which is sent as is with no ETag and never answered with a 304 (the
// behaviour every caller had before the policies). They are told apart by
// address, never by content.
//
// PAGE an HTML page; the default, so every page handler, core or
// feature, has it without being edited. ?v= names a build:
// empty -> no-cache + ETag (typed URLs, bookmarks and ?src=
// handoffs revalidate, so they are never stale); the running
// build -> immutable + ETag; any other -> 302 to the same URL
// with v= the running build.
// STAMPED the console chrome. ?h= is its CRC, stamped into each page by
// console_pages.py: its own CRC -> immutable + ETag; another ->
// the current bytes with no-store and no ETag, so a mismatched
// copy is never kept under the old key; none -> no-cache + ETag.
//
// Neither lets a browser keep anything unless the Host can only be the panel
// (hostCacheable; otherwise no-store, no ETag, a plain 200). On the hotspot
// every DNS name resolves to the panel, and a phone must never store the
// console under the origin of a site it was trying to reach.
//
// The ETag is weak and names the uncompressed payload (the CRC and length
// from the gzip trailer), so Range must never be implemented for these
// bodies: a byte range needs a strong validator over the bytes that are sent,
// and these are the compressed ones.
inline constexpr char PAGE[] = "(page policy)";
inline constexpr char STAMPED[] = "(stamped policy)";
constexpr const char* CC_IMMUTABLE = "public, max-age=31536000, immutable";
constexpr const char* CC_REVALIDATE = "no-cache";
constexpr const char* CC_NO_STORE = "no-store";
// CRC-32 and ISIZE, the last eight bytes of a gzip member (little-endian).
// False when the array is too short to be one: 10 bytes of header, at least
// an empty deflate block, 8 of trailer.
struct Trailer {
uint32_t crc;
uint32_t isize;
};
inline bool trailerOf(const uint8_t* gzBytes, size_t len, Trailer& out) {
if (!gzBytes || len < 18) return false;
uint8_t b[8];
memcpy_P(b, gzBytes + len - 8, 8);
out.crc = (uint32_t)b[0] | (uint32_t)b[1] << 8 | (uint32_t)b[2] << 16 | (uint32_t)b[3] << 24;
out.isize = (uint32_t)b[4] | (uint32_t)b[5] << 8 | (uint32_t)b[6] << 16 | (uint32_t)b[7] << 24;
return true;
}
// W/"<crc %08x>-<isize %x>": at most 22 bytes with the NUL.
constexpr size_t ETAG_BYTES = 24;
inline void formatEtag(const Trailer& t, char* out, size_t outSize) {
snprintf(out, outSize, "W/\"%08x-%x\"", (unsigned)t.crc, (unsigned)t.isize);
}
// If-None-Match against our tag, by weak comparison (W/ ignored on both
// sides). The header may list several tags; "*" matches anything.
inline bool inmMatches(const char* inm, const char* etag) {
if (!inm || !etag) return false;
if (etag[0] == 'W' && etag[1] == '/') etag += 2;
const size_t want = strlen(etag);
const char* p = inm;
while (*p) {
while (*p == ' ' || *p == '\t' || *p == ',') ++p;
if (!*p) break;
if (*p == '*') return true;
if (p[0] == 'W' && p[1] == '/') p += 2;
if (*p == '"') {
const char* close = strchr(p + 1, '"');
if (!close) return false;
if ((size_t)(close - p) + 1 == want && memcmp(p, etag, want) == 0) return true;
p = close + 1;
}
while (*p && *p != ',') ++p; // the rest of a malformed entry
}
return false;
}
inline bool isIpv4(const char* s, size_t n) {
int parts = 0, digits = 0, value = 0;
for (size_t i = 0; i <= n; ++i) {
if (i == n || s[i] == '.') {
if (digits == 0 || value > 255) return false;
++parts;
digits = value = 0;
} else if (s[i] >= '0' && s[i] <= '9' && digits < 3) {
value = value * 10 + (s[i] - '0');
++digits;
} else {
return false;
}
}
return parts == 4;
}
// Can a response to this Host header be the panel's own, to keep? An IPv4
// literal, a name without a dot (NetBIOS `patternflow`, an IPv6 literal) or
// a .local name: yes. Anything else, or no Host at all: no. The vendored
// parser does not clear the Host between requests, so a request without
// one is judged by the previous request's; every browser sends Host.
inline bool hostCacheable(const char* host) {
if (!host || !*host) return false;
size_t n = strlen(host);
const char* colon = strrchr(host, ':');
if (colon && colon[1]) {
bool digits = true;
for (const char* p = colon + 1; *p; ++p) {
if (*p < '0' || *p > '9') digits = false;
}
// "[::1]:80" has its port after the bracket; a bare "fe80::1" has none.
const bool port = host[0] == '['
? colon > host && colon[-1] == ']'
: memchr(host, ':', (size_t)(colon - host)) == nullptr;
if (digits && port) n = (size_t)(colon - host);
}
if (n && host[n - 1] == '.') --n; // "patternflow.local." is the same name
if (!n) return false;
if (isIpv4(host, n)) return true;
if (!memchr(host, '.', n)) return true;
static const char kLocal[] = ".local";
const size_t k = sizeof(kLocal) - 1;
if (n <= k) return false;
for (size_t i = 0; i < k; ++i) {
char c = host[n - k + i];
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
if (c != kLocal[i]) return false;
}
return true;
}
enum class Reply : uint8_t { Full, NotModified, Redirect };
struct Decision {
Reply reply;
const char* cacheControl; // Full and NotModified
bool etag; // send the ETag with it
};
// The whole policy as one pure function, decided before a single header is
// queued: `param` is the request's v (PAGE) or h (STAMPED), empty when
// absent, and `expected` what it must equal to be immutable — the running
// build, or this asset's own CRC.
inline Decision decide(bool stamped, bool cacheable, const char* param,
const char* expected, const char* inm, const char* etag) {
if (!cacheable) return {Reply::Full, CC_NO_STORE, false};
const bool bare = !param || !*param;
if (!bare && strcmp(param, expected) != 0) {
// A page moves to the running build's URL; the chrome has no URL to
// move to (the page names it), so it goes out current and unkept.
return {stamped ? Reply::Full : Reply::Redirect, CC_NO_STORE, false};
}
const char* cc = bare ? CC_REVALIDATE : CC_IMMUTABLE;
return {inmMatches(inm, etag) ? Reply::NotModified : Reply::Full, cc, true};
}
// Query-component escaping: unreserved characters as they are, the rest %XX.
template <class Str>
inline void appendEscaped(Str& out, const char* s) {
static const char hex[] = "0123456789ABCDEF";
for (; *s; ++s) {
const uint8_t c = (uint8_t)*s;
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~') {
out += (char)c;
} else {
const char esc[4] = {'%', hex[c >> 4], hex[c & 15], '\0'};
out += esc;
}
}
}
// PAGE with a v naming another build: send the browser to the running one,
// the same path with every other query argument kept, in order. Relative, so
// the Host is whatever the browser used. Only a GET's arguments are the
// query; a body's never go into a URL.
inline void redirectToBuild(WebServer& server) {
auto location = server.uri();
bool first = true, sawV = false;
const int count = server.method() == HTTP_GET ? server.args() : 0;
for (int i = 0; i < count; ++i) {
const auto key = server.argName(i);
location += first ? '?' : '&';
first = false;
appendEscaped(location, key.c_str());
location += '=';
if (strcmp(key.c_str(), "v") == 0) {
location += PFBuild::id();
sawV = true;
} else {
appendEscaped(location, server.arg(i).c_str());
}
}
if (!sawV) {
location += first ? '?' : '&';
location += "v=";
location += PFBuild::id();
}
server.sendHeader("Location", location);
server.sendHeader("Cache-Control", CC_NO_STORE);
server.send(302, "text/plain", "");
}
// Serve precompressed bytes with Content-Encoding: gzip (header before
// send(), the way WebServer::serveStatic announces a .gz file). Sent to
// every client regardless of Accept-Encoding: every browser decodes gzip,
// and honouring the header would keep the raw literal referenced — and in
// the image, at 2.5–3.5× the size — for the sake of curl without
// --compressed.
//
// A 304 carries the Content-Length a 200 would have: the vendored server
// writes Content-Length: 0 when none is set, and CONTENT_LENGTH_UNKNOWN
// would switch it to chunked framing, which on a 304 puts a terminating
// chunk where no body may be. With a length set, send() writes the header
// block and nothing else, and _prepareHeader() empties the queued headers,
// so none of these reach the next response.
inline void gz(WebServer& server, const uint8_t* gzBytes, size_t len,
const char* contentType = "text/html",
const char* policy = PAGE) {
const bool managed = policy == PAGE || policy == STAMPED;
Trailer trailer;
if (!managed || !trailerOf(gzBytes, len, trailer)) {
server.sendHeader("Cache-Control", managed ? CC_NO_STORE : policy);
server.sendHeader("Content-Encoding", "gzip");
server.setContentLength(len);
server.send(200, contentType, "");
drain(server, gzBytes, len);
return;
}
const bool stamped = policy == STAMPED;
char etag[ETAG_BYTES];
formatEtag(trailer, etag, sizeof(etag));
char crc[9];
snprintf(crc, sizeof(crc), "%08x", (unsigned)trailer.crc);
const auto host = server.hostHeader();
const auto param = server.arg(stamped ? "h" : "v");
const auto inm = server.header("If-None-Match");
const Decision d = decide(stamped, hostCacheable(host.c_str()), param.c_str(),
stamped ? crc : PFBuild::id(), inm.c_str(), etag);
if (d.reply == Reply::Redirect) {
redirectToBuild(server);
return;
}
if (d.reply == Reply::NotModified) {
server.sendHeader("ETag", etag);
server.sendHeader("Cache-Control", d.cacheControl);
server.setContentLength(len);
server.send(304, contentType, "");
return;
}
server.sendHeader("Cache-Control", d.cacheControl);
if (d.etag) server.sendHeader("ETag", etag);
server.sendHeader("Content-Encoding", "gzip");
server.setContentLength(len);
server.send(200, contentType, "");
drain(server, gzBytes, len);
}
// A 204 kept for a year, under the same Host rule as pages: an empty answer
// stored under another site's origin would hide that site's own for as long.
// The vendored server writes Content-Length: 0 on it; browsers accept that.
inline void noContent(WebServer& server) {
server.sendHeader("Cache-Control",
hostCacheable(server.hostHeader().c_str()) ? CC_IMMUTABLE : CC_NO_STORE);
server.send(204);
}
} // namespace PFSend