src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-19 19:15:28 -0500
committerLuke Hoersten <[email protected]>2026-06-19 19:15:28 -0500
commit504360a77afc6cd87d806047179343138f9b8593 (patch)
tree57a79088e874474b5a82405c7bbaec9714d3b13a
parenta6a8b6bc7f31b76617d7b43e033d3cfcf7e63715 (diff)
disable Nagle on /frame socket + add min/max/worst-frame to script log
#1: TCP_NODELAY for /frame ESP-IDF lwIP defaults Nagle ON. /frame is the worst Nagle workload: ~140KB body POST followed by an empty 204 response, no follow-up data either way. Both the final partial-MTU body packet and the response packet can sit in the kernel send buffer up to 40ms waiting for an ACK that the other side is delaying-ACKing — silently adding tens of ms to every frame's wall time and showing up as a fat net_up bucket on the Scrypted side. setsockopt(TCP_NODELAY) at handler entry on every /frame. Cheap and idempotent. Includes the /state and /config sockets too — those are infrequent but small responses also benefit. #2: min/max + worst-frame decomposition in the Scrypted log The p50/p95 line answered "what's typical" but not "what happened in the worst frame this window". Now every 10-fetch log adds: - min and max columns alongside p50/p95 per bucket - a worst-frame breakdown row: identifies the single slowest fetch in the window and decomposes its wall time across all stages. Answers "did the slow frame get gated by emit→post (ffmpeg backed up), net_up (TCP/handshake spike), or fw_dec (large JPEG)?" directly, instead of us inferring from percentiles. Together these are the prerequisites for evaluating whether further optimization (HTTP keep-alive, chunked streaming) is worth pursuing.
-rw-r--r--main/http_api.c14
-rw-r--r--scrypted/scrypted-viewport.ts48
2 files changed, 48 insertions, 14 deletions
diff --git a/main/http_api.c b/main/http_api.c
index 69e555e..0192f7d 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -10,6 +10,7 @@
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_timer.h"
+#include "lwip/sockets.h" // TCP_NODELAY setsockopt for /frame socket
#include "display.h"
#include "jpeg_decoder.h"
@@ -334,6 +335,19 @@ static uint32_t s_last_painted_seq;
static esp_err_t frame_post_handler(httpd_req_t *req)
{
+ // Nagle off on this socket. /frame is a single large POST followed
+ // by a tiny (empty 204) response — the worst case for Nagle. The
+ // last partial-MTU packet of the body, and the response packet,
+ // both sit in the kernel send buffer up to 40ms waiting for an
+ // ACK that the peer's also delaying-ACKing. setsockopt is cheap
+ // and idempotent; if keep-alive is ever added the flag persists
+ // for the connection's life.
+ int sockfd = httpd_req_to_sockfd(req);
+ if (sockfd >= 0) {
+ int one = 1;
+ (void)setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
+ }
+
// Content-Type must be image/jpeg.
char ct[40] = {0};
if (httpd_req_get_hdr_value_str(req, "Content-Type", ct, sizeof(ct)) != ESP_OK ||
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index e93ea60..d6ebd76 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -6,7 +6,7 @@
// short git hash of the commit that added this constant — if the
// hash in the log doesn't match the HEAD this file came from, the
// Scrypted Script editor is still on stale code.
-const SCRIPT_VERSION = "846e4db";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -1112,21 +1112,41 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const sorted = arr.slice().sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
};
- const fmt = (arr: number[]) => arr.length
- ? `p50=${p(arr, 0.5).toFixed(1)}ms p95=${p(arr, 0.95).toFixed(1)}ms`
- : `(no data)`;
+ const stats = (arr: number[]) => {
+ if (!arr.length) return null;
+ let mn = arr[0], mx = arr[0];
+ for (const v of arr) { if (v < mn) mn = v; if (v > mx) mx = v; }
+ return { min: mn, p50: p(arr, 0.5), p95: p(arr, 0.95), max: mx };
+ };
+ const fmt = (arr: number[]) => {
+ const s = stats(arr);
+ if (!s) return `(no data)`;
+ return `min=${s.min.toFixed(1)} p50=${s.p50.toFixed(1)} p95=${s.p95.toFixed(1)} max=${s.max.toFixed(1)}ms`;
+ };
+ // Identify the worst-wall sample in the window and decompose
+ // it into its own stages — answers "what made the slowest
+ // frame slow?" without us having to guess from percentiles.
+ let worstIdx = 0;
+ for (let i = 1; i < b.wall.length; i++) if (b.wall[i] > b.wall[worstIdx]) worstIdx = i;
+ const at = (arr: number[], i: number) =>
+ (i < arr.length && arr[i] != null) ? arr[i].toFixed(1) : "n/a";
this.console.log(
`fetch "${v.name}" #${n} (jpeg=${(jpeg.length / 1024).toFixed(0)}KB)\n` +
- ` wall ${fmt(b.wall)}\n` +
- ` emit→post ${fmt(b.emit)} (queue wait before fetch() called)\n` +
- ` req ${fmt(b.req)} (fetch start → Response headers)\n` +
- ` net_up ${fmt(b.net_up)} (req − fw_total: TCP setup + body wire + dispatch)\n` +
- ` fw_recv ${fmt(b.fw_recv)} (firmware body read off the wire)\n` +
- ` fw_dec ${fmt(b.fw_dec)} (hardware JPEG → BGR888)\n` +
- ` fw_paint${fmt(b.fw_paint)} (backbuffer flip)\n` +
- ` fw_post ${fmt(b.fw_post)} (state counters + unlock)\n` +
- ` body-read ${fmt(b.bread)} (Response → drained)\n` +
- ` inflight d0=${b.depths.d0} d1=${b.depths.d1} d2=${b.depths.d2} (queue depth at fetch start)\n` +
+ ` wall ${fmt(b.wall)}\n` +
+ ` emit→post ${fmt(b.emit)} (queue wait before fetch() called)\n` +
+ ` req ${fmt(b.req)} (fetch start → Response headers)\n` +
+ ` net_up ${fmt(b.net_up)} (req − fw_total: TCP setup + body wire + dispatch)\n` +
+ ` fw_recv ${fmt(b.fw_recv)} (firmware body read off the wire)\n` +
+ ` fw_dec ${fmt(b.fw_dec)} (hardware JPEG → BGR888)\n` +
+ ` fw_paint ${fmt(b.fw_paint)} (backbuffer flip)\n` +
+ ` fw_post ${fmt(b.fw_post)} (state counters + unlock)\n` +
+ ` body-read ${fmt(b.bread)} (Response → drained)\n` +
+ ` inflight d0=${b.depths.d0} d1=${b.depths.d1} d2=${b.depths.d2} (queue depth at fetch start)\n` +
+ ` worst (#${worstIdx + 1}/${b.wall.length} in window): wall=${at(b.wall, worstIdx)}ms = ` +
+ `emit→post ${at(b.emit, worstIdx)} + net_up ${at(b.net_up, worstIdx)} + ` +
+ `fw_recv ${at(b.fw_recv, worstIdx)} + fw_dec ${at(b.fw_dec, worstIdx)} + ` +
+ `fw_paint ${at(b.fw_paint, worstIdx)} + fw_post ${at(b.fw_post, worstIdx)} + ` +
+ `body-read ${at(b.bread, worstIdx)}\n` +
` stale-drops=${b.staleDrops}`);
this.fetchSamples.delete(v.nativeId!); // reset window
}