diff options
| author | Luke Hoersten <[email protected]> | 2026-06-15 09:32:57 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-15 09:32:57 -0500 |
| commit | 0ff5d03b760d726f6151cd5f3f23ef1a1039d319 (patch) | |
| tree | 2e0e907bea9129d0e32f4f3f6602cd3dcfb31988 | |
| parent | 28a6335576cfec144dc3860bc47f7f6d87274651 (diff) | |
add X-Frame-Seq monotonic ordering for pipelined /frame POSTs
With two /frame POSTs in flight on separate sockets, the firmware's
JPEG decoder mutex serialises decode but FreeRTOS semaphore
acquisition is not FIFO — under jitter the later-arriving older
frame can grab the lock first and paint over the newer one,
producing brief "panel travels backward in time" glitches.
Scrypted side:
- Per-viewport monotonic frameSeq counter, sent as X-Frame-Seq on
every /frame POST. Reset on stopStream so each new stream starts
at 1.
Firmware side:
- s_last_painted_seq tracks the highest seq we've painted. Frame
arrives, mutex acquired, body received — if seq <= s_last_painted_seq
the frame is dropped (200 OK + X-Frame-Drop: stale-seq header) and
the mutex released without touching the back buffer.
- s_last_painted_seq resets to 0 on POST /state {wake} so the next
stream's seq=1 isn't rejected because the previous session reached
a higher counter.
- Missing/zero X-Frame-Seq (legacy clients) skips the check entirely
— preserves pre-pipelining behaviour.
| -rw-r--r-- | main/http_api.c | 45 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 20 |
2 files changed, 58 insertions, 7 deletions
diff --git a/main/http_api.c b/main/http_api.c index 4e03868..c490a85 100644 --- a/main/http_api.c +++ b/main/http_api.c @@ -283,6 +283,10 @@ static esp_err_t state_post_handler(httpd_req_t *req) viewport_run_state_t target; if (strcmp(j->valuestring, "wake") == 0) { target = VIEWPORT_STATE_AWAKE; + // Reset the pipelined-POST sequence comparator so the next + // stream's first frame isn't rejected as stale just because + // the previous stream's counter happened to be higher. + s_last_painted_seq = 0; } else if (strcmp(j->valuestring, "sleep") == 0) { target = VIEWPORT_STATE_ASLEEP; } else { @@ -315,7 +319,18 @@ static esp_err_t respond_status(httpd_req_t *req, const char *status, const char // Tracks when the previous /frame response finished so we can log the // idle gap until the next request enters. Large idle = Scrypted/network // upstream is the bottleneck; small idle = we're saturating the link. -static int64_t s_last_post_us; +static int64_t s_last_post_us; +// Highest X-Frame-Seq value we've actually painted. With two /frame +// POSTs pipelined over separate sockets the firmware's decoder mutex +// is the serialisation point, but FreeRTOS semaphore acquisition is +// not FIFO — under jitter task B can grab the lock for frame N+1 +// before task A grabs it for frame N. Without a sequence check, the +// later-arriving older frame would paint over the newer one and the +// panel would briefly travel backwards in time. Frames with seq <= +// s_last_painted_seq are acknowledged 200 OK but not painted. Reset +// to 0 on every device wake — Scrypted's counter starts fresh per +// stream so we don't drag a stale comparison forward. +static uint32_t s_last_painted_seq; static esp_err_t frame_post_handler(httpd_req_t *req) { @@ -345,6 +360,16 @@ static esp_err_t frame_post_handler(httpd_req_t *req) int64_t t_entry = esp_timer_get_time(); + // Read the optional X-Frame-Seq header used to keep pipelined + // POSTs in monotonic paint order. Missing or zero header = no + // ordering enforcement (treat every frame as newer than the + // previous one, same behaviour we had before pipelining). + uint32_t seq = 0; + char seq_str[16] = {0}; + if (httpd_req_get_hdr_value_str(req, "X-Frame-Seq", seq_str, sizeof(seq_str)) == ESP_OK) { + seq = (uint32_t)strtoul(seq_str, NULL, 10); + } + // Single in-flight frame. Concurrent posts get 503 (spec). if (!jpeg_decoder_try_lock(0)) { return respond_status(req, "503 Service Unavailable", "frame in flight"); @@ -370,6 +395,22 @@ static esp_err_t frame_post_handler(httpd_req_t *req) int64_t t_recv = esp_timer_get_time(); + // Pipelined-POST stale-frame check. With the X-Frame-Seq header + // set, drop anything we've already advanced past — we hold the + // decoder lock, so re-checking here is race-free. Without the + // header (seq==0) every frame paints, preserving pre-pipelining + // behaviour. + if (seq != 0 && seq <= s_last_painted_seq) { + jpeg_decoder_unlock(); + // 200 OK with an empty body keeps the keep-alive socket + // healthy and lets Scrypted's per-fetch wall-clock log + // stay accurate; we just won't count this toward + // frames_received. + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_hdr(req, "X-Frame-Drop", "stale-seq"); + return httpd_resp_send(req, NULL, 0); + } + // Decode straight into the panel's back framebuffer — no scratch // buffer, no later memcpy. The flip below is a cache writeback + // index swap inside the IDF DPI driver. @@ -416,6 +457,8 @@ static esp_err_t frame_post_handler(httpd_req_t *req) uint64_t fr = st->frames_received; viewport_state_unlock(); + if (seq != 0) s_last_painted_seq = seq; + jpeg_decoder_unlock(); // Track the bookkeeping tail too so we can see if anything between diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index ae99e09..e91a510 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -869,11 +869,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase if (s.interval) clearInterval(s.interval); clearTimeout(s.timeout); this.streams.delete(name); - if (sendSleep) { - const v = this.findByName(name); - if (v?.host) { - this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); - } + const v = this.findByName(name); + if (v) this.frameSeq.delete(v.nativeId!); + if (sendSleep && v?.host) { + this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); } } @@ -882,12 +881,21 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // 10 fetches alongside the wall-clock duration of the fetch itself. private fetchCount = new Map<string, number>(); + // Monotonic per-viewport sequence number paired with X-Frame-Seq + // so the firmware can drop pipelined-out-of-order frames. Reset + // on every stopStream so each stream session starts fresh; the + // firmware also resets its comparator on /state {wake}, so the + // two stay in step. + private frameSeq = new Map<string, number>(); + private async pushStreamFrame(v: Viewport, jpeg: Buffer, abort: AbortController) { if (abort.signal.aborted) return; + const seq = (this.frameSeq.get(v.nativeId!) || 0) + 1; + this.frameSeq.set(v.nativeId!, seq); const t0 = Date.now(); const res = await fetch(`http://${v.host}/frame`, { method: "POST", - headers: { "Content-Type": "image/jpeg" }, + headers: { "Content-Type": "image/jpeg", "X-Frame-Seq": String(seq) }, body: jpeg, signal: abort.signal, // @ts-ignore - undici dispatcher: keep-alive + 2 connections |
