src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 12:20:48 -0500
committerLuke Hoersten <[email protected]>2026-06-20 12:20:48 -0500
commite3bcccaf63cb5feafb048cc32b1ca7bf6688fba1 (patch)
treeeb040e658beafffd04e8c99543d943d585c87d00
parentfb4e900636dc0828eba2e70f1bbd0ef53a0bcabd (diff)
scrypted: max_node_buf_mb cap → destroy+reconnect socket to drop entire backlog
User's intent: "we want to see what's going on right now, not all the shit we throttled and backlogged in the buffer." Dropping new arrivals when the buffer is full doesn't help — the already-queued frames still get processed in order, painting 5+ seconds of stale content before catching up to live. Better: when the buffer exceeds the configured cap, blow away the whole queue by destroying the socket. The firmware's accept_task picks up the next reconnect within ~500ms and the pipeline restarts with the freshest ffmpeg frame as seq=1. Trade a sub-second visual gap for clearing the entire backlog instantly. Per-viewport setting "Max Scrypted-side buffer (MB)" under Display. Default 20 MB (≈5s buffering ceiling at our measured ~3.5 MB/s ffmpeg → firmware throughput). Range 1-200. Lower for tighter g2g at cost of more frequent reconnect gaps under sustained backpressure; higher to tolerate longer backlogs before flushing. flushCount tracked per-stream and surfaced in the unified log line alongside drops, plus node_buf shown vs the cap so the user can see how close they're running to the threshold. Demux loop now: if !socketReady → drop. Else if writableLength > cap*MB → log + sock.destroy() + increment flushes. Else normal write.
-rw-r--r--scrypted/scrypted-viewport.ts67
1 files changed, 57 insertions, 10 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 0ed89e1..3afa6d6 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 = "439dadb";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -127,6 +127,22 @@ class Viewport extends ScryptedDeviceBase implements Settings {
const parsed = v ? parseInt(v, 10) : NaN;
return Number.isFinite(parsed) ? Math.max(1, Math.min(31, parsed)) : 1;
}
+ // Hard cap on Node's TCP send buffer for the stream socket, in MB.
+ // ffmpeg pumps faster than firmware can ingest so socket.write
+ // returning false (kernel buffer full) causes Node to accumulate
+ // the surplus in its own internal queue — which is unbounded by
+ // default. Without this cap, a long-running stream's heap grows
+ // monotonically and every frame painted on the panel becomes
+ // progressively staler (the buffer depth = display lag). When the
+ // measured node_buf exceeds the cap, the demux loop drops new
+ // ffmpeg frames at source instead of queueing them. Lower =
+ // tighter latency + more drops under sustained backpressure;
+ // higher = more memory + worse latency but less visual choppiness.
+ get maxNodeBufMb(): number {
+ const v = this.storage.getItem("max_node_buf_mb");
+ const parsed = v ? parseInt(v, 10) : NaN;
+ return Number.isFinite(parsed) ? Math.max(1, Math.min(200, parsed)) : 20;
+ }
// Which camera-event types wake this viewport. Empty = tap-only,
// never woken by Scrypted. Default = all three (doorbell + motion +
// person detection).
@@ -197,6 +213,14 @@ class Viewport extends ScryptedDeviceBase implements Settings {
value: this.jpegQuality,
} as any,
{
+ group: "Display",
+ key: "max_node_buf_mb",
+ title: "Max Scrypted-side buffer (MB)",
+ description: "Hard cap on Node's TCP send queue for the stream socket. When exceeded, the socket is destroyed and reconnected — drops the entire backlog so the next frame painted is fresh. Lower = tighter glass-to-glass at cost of brief reconnect gaps under sustained backpressure. Default 20.",
+ type: "number",
+ value: this.maxNodeBufMb,
+ } as any,
+ {
group: "Actions",
key: "action_wake",
title: "Wake now",
@@ -800,6 +824,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
let droppedFrames = 0;
let sentFrames = 0;
let bytesSent = 0;
+ let flushCount = 0; // socket destroy+reconnect count due to buffer cap
let lastLogUs = Date.now();
let workBuf: Buffer = Buffer.alloc(0);
@@ -898,17 +923,39 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}
// 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.
+ // (initial-open race) — once it's up we keep writing
+ // through normal backpressure and let the firmware's
+ // FIONREAD skip shed superseded frames before decode.
if (!socketReady) {
droppedFrames++;
continue;
}
+
+ // Runaway-buffer guard. ffmpeg outpaces firmware
+ // ingest by ~0.5 MB/s; Node's socket write queue
+ // is unbounded by default and would grow to MB of
+ // stale frames over a long stream. Each queued
+ // byte is a frame the firmware hasn't seen yet, so
+ // the buffer depth literally IS the steady-state
+ // glass-to-glass lag.
+ //
+ // When the queue exceeds the per-viewport cap,
+ // destroy the socket: the firmware's accept loop
+ // picks up the next reconnect within ~500ms and
+ // the pipeline restarts on a live frame. Trade a
+ // sub-second reconnect gap for clearing seconds
+ // of stale backlog.
+ const queued = sock.writableLength ?? 0;
+ if (queued > v.maxNodeBufMb * 1024 * 1024) {
+ flushCount++;
+ this.console.log(
+ `stream "${v.name}": buffer ${(queued / (1024 * 1024)).toFixed(1)}MB > ` +
+ `${v.maxNodeBufMb}MB cap — destroying socket to drop backlog (flush #${flushCount})`);
+ try { sock.destroy(); } catch {}
+ // close event triggers openStreamSocket reconnect.
+ droppedFrames++;
+ continue;
+ }
seq++;
// 16-byte v1 header. Magic "VPRT" (0x56505254) lets
// the firmware autodetect old-vs-new clients during
@@ -1036,10 +1083,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const nodeBufMs = sentBps > 0 ? (nodeBufBytes / sentBps * 1000).toFixed(0) : "?";
this.console.log(
`stream "${v.name}": sent=${sentRate.toFixed(1)}fps painted=${painted}fps ` +
- `(fw-skipped=${skipped}fps, drops=${droppedFrames}) ` +
+ `(fw-skipped=${skipped}fps, drops=${droppedFrames}, flushes=${flushCount}) ` +
`${mbPerSec.toFixed(2)}MB/s sent / ${paintedMb}MB/s painted | ` +
`socket.write p50=${p50}ms p95=${p95}ms max=${max}ms backpressured=${socketBackpressured} ` +
- `node_buf=${(nodeBufBytes / 1024).toFixed(0)}KB≈${nodeBufMs}ms | ` +
+ `node_buf=${(nodeBufBytes / 1024).toFixed(0)}KB≈${nodeBufMs}ms/${v.maxNodeBufMb}MB cap | ` +
`recv=${recvStr}us dec=${decStr}us paint=${paintStr}us idle=${idleStr}us | g2g=${g2g}`);
droppedFrames = 0;