diff options
| author | Luke Hoersten <[email protected]> | 2026-06-20 10:30:24 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-20 10:30:24 -0500 |
| commit | 496da49dd5ce4a3bfc4b5df84ce5c7f7bdbcf504 (patch) | |
| tree | 1aecd053ebf9d82f1e23037cb992dad1c0def64f | |
| parent | cebd60d4be74ddac9c18a7b08eef9ec00cbc31f5 (diff) | |
"paint the latest, drop the rest" — FIONREAD skip + Scrypted backpressure-blind + lwIP TCP window bump
Three changes that work together to make the stream "always paint
what's freshest, never sit on a stale frame":
#1 — Firmware FIONREAD skip in stream_server
Right after the body of frame N comes off the wire (and before we
unlock the decoder + spend ~6ms on decode + paint), check the
kernel receive buffer with ioctl(FIONREAD). If at least one more
header (8 bytes) is queued, frame N is no longer the freshest
possible — skip its decode + paint and loop back to read frame
N+1. The TCP recv cost is unavoidable (bytes still have to cross
the wire) but the decoder + paint cost is saved on every superseded
frame. Glass-to-glass latency on the latest frame drops by however
many frames had backed up.
#2 — Scrypted: keep writing past kernel-buffer backpressure
Previously: when sock.write() returned false we dropped the next
ffmpeg frame at source. New: we keep writing through. Node buffers
internally; under our load (~16 MB/s ffmpeg → ~5-7 MB/s firmware)
the buffer rarely exceeds a frame or two. With the firmware now
silently skipping decode on backed-up frames (#1), excess frames
get shed for free on the device side. Scrypted's job is just to
hand the firmware the freshest bytes as fast as possible.
socketBackpressured is still tracked for the diagnostic log.
#3 — lwIP TCP window bump (revisiting earlier regression)
The previous attempt at LWIP_TCP_WND_DEFAULT=32k regressed under
HTTP because every /frame opened a fresh socket and we paid the
slow-start cost repeatedly. The streaming pivot eliminated that:
the socket is long-lived, slow-start runs exactly once, then we
ride the full window for the rest of the session. Bumping to
65535 (max for stock lwIP), SND_BUF to match, RECVMBOX to 16, SACK
on. Expected: recv throughput ceiling moves up from ~5.3 MB/s,
which directly raises the fps ceiling (recv is currently 37ms of
the 43ms per-frame total).
| -rw-r--r-- | main/stream_server.c | 14 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 21 | ||||
| -rw-r--r-- | sdkconfig.defaults | 19 |
3 files changed, 40 insertions, 14 deletions
diff --git a/main/stream_server.c b/main/stream_server.c index 7876968..10d1e1b 100644 --- a/main/stream_server.c +++ b/main/stream_server.c @@ -9,6 +9,7 @@ #include "freertos/task.h" #include "lwip/netdb.h" #include "lwip/sockets.h" +#include "sys/ioctl.h" #include "display.h" #include "jpeg_decoder.h" @@ -132,6 +133,19 @@ static void handle_client(int fd, const char *peer) continue; } + // "Always paint the latest" — if the socket already has more + // bytes queued in the kernel receive buffer, at least one more + // header is on the way. The frame we just finished receiving + // is no longer the freshest possible; skip its decode+paint + // (saves ~6ms per skip) and loop straight to the next header. + // This trades some intermediate frames for lower + // glass-to-glass latency on the latest one. + int queued = 0; + if (ioctl(fd, FIONREAD, &queued) == 0 && queued >= HEADER_BYTES) { + jpeg_decoder_unlock(); + continue; + } + size_t back_size = 0; void *back = display_back_buffer(&back_size); uint16_t w = 0, h = 0; diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index f25f557..fddfd28 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 = "521de7e"; +const SCRIPT_VERSION = "pending"; // // Architecture // ------------ @@ -947,12 +947,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase workBuf = workBuf.subarray(eoi + 2); if (frame.length < 4 || frame[0] !== 0xff || frame[1] !== 0xd8) continue; - // Drop if the socket isn't connected yet (initial - // open) or if the kernel send buffer is full - // (firmware can't ingest as fast as ffmpeg emits). - // Frame is gone forever — TCP doesn't queue what - // we don't write. - if (!socketReady || socketBackpressured) { + // Drop only when the socket isn't connected yet + // (initial-open race) — once it's up we just keep + // writing. Node buffers internally if the kernel + // send buffer is full; under 16 MB/s ffmpeg output + // and ~5-7 MB/s firmware ingest the buffer rarely + // exceeds a frame or two. The firmware's FIONREAD- + // skip ensures it always paints the LATEST queued + // frame, so any backlog gets shed there for free. + if (!socketReady) { droppedFrames++; continue; } @@ -969,7 +972,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase if (writeLatencies.length > 200) writeLatencies.shift(); bytesSent += 8 + frame.length; sentFrames++; + // Track but don't gate on backpressure — the metric + // is still useful as a "kernel buffer was full" + // indicator for diagnostics. if (!ok) socketBackpressured = true; + else socketBackpressured = false; } }); diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 4c50a23..1ba8375 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -29,13 +29,18 @@ CONFIG_LWIP_IPV4=y CONFIG_LWIP_IPV6=y CONFIG_MDNS_MAX_INTERFACES=1 -# TCP receive-window tuning attempted (WND/SND_BUF=32k, RECVMBOX=16, -# SACK on). Measured net-negative on the /frame path: ttfb regressed -# from ~350µs to ~1.2ms, body unchanged, and periodic 250+ms stalls -# appeared. Reason: Scrypted's per-frame fetch() opens a fresh TCP -# socket each time, so the larger window just slows slow-start. -# Reverted; revisit once we switch to HTTP keep-alive on the Scrypted -# side. Keeping the lwIP defaults. +# TCP receive-window + buffer tuning. Earlier attempt under HTTP +# regressed because every /frame opened a fresh socket and we ate +# the slow-start cost repeatedly. Under the streaming pivot the +# socket is long-lived: slow-start happens once, then we run with +# the full window for the rest of the session — which is exactly +# the workload these knobs are designed for. Larger WND lets the +# sender keep more bytes in flight per ACK and pushes our recv +# throughput ceiling up from ~5.3 MB/s. +CONFIG_LWIP_TCP_WND_DEFAULT=65535 +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=65535 +CONFIG_LWIP_TCP_RECVMBOX_SIZE=16 +CONFIG_LWIP_TCP_SACK_OUT=y # HTTP server CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 |
