feat(agent): global bandwidth cap — the agent can't saturate the desktop's network

One shared TokenBucket (default 8 MB/s; BANDWIDTH_LIMIT_MB_S, 0 = unlimited;
live MB/s dial + net readout in the control UI) is charged by every still
download (streamed chunk reads) and every ffmpeg video stream (metered from
outside via /proc/<pid>/io and SIGSTOP/SIGCONTed into budget).

Why: D1 re-measurement 2026-07-02 — the idle link moves ~38 MB/s, but 8
unthrottled downloaders bufferbloated it to ~1-1.5 MB/s PER STREAM (operator's
browser included). Capping the aggregate keeps the desktop usable and still
beats the collapsed sweep throughput it replaces. Agent build 2026-07-02.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 11:20:45 -04:00
parent 31c416bc7b
commit 95d2ae1d58
8 changed files with 304 additions and 8 deletions
+49 -1
View File
@@ -4,12 +4,15 @@ instances, each with a timestamp."""
import io
import logging
import os
import signal
import subprocess
import tempfile
import time
from PIL import Image, ImageFile
from .throttle import PidReadMeter
log = logging.getLogger("fc_agent.media")
# Load slightly-truncated images (a few missing trailing bytes) instead of
@@ -111,6 +114,12 @@ def _collect_frames(
def _terminate(proc: subprocess.Popen) -> None:
"""Stop an ffmpeg cleanly, then hard-kill if it ignores SIGTERM."""
try:
# A bandwidth-paused (SIGSTOPped) process can't receive SIGTERM until it
# resumes — always CONT first so termination is prompt, not queued.
proc.send_signal(signal.SIGCONT)
except OSError:
pass
proc.terminate()
try:
proc.wait(timeout=2)
@@ -122,9 +131,33 @@ def _terminate(proc: subprocess.Popen) -> None:
pass
def _pause(proc: subprocess.Popen, seconds: float, should_stop) -> bool:
"""SIGSTOP ffmpeg for ~`seconds` of bandwidth debt, staying responsive to
Stop. While paused, the kernel socket buffer fills and TCP flow control
stalls curator's send side — that's the throttle. SIGCONT is ALWAYS sent
before returning. False = a Stop arrived mid-pause."""
try:
proc.send_signal(signal.SIGSTOP)
except OSError:
return True # already exited — nothing to pause
try:
end = time.monotonic() + seconds
while (left := end - time.monotonic()) > 0:
if should_stop and should_stop():
return False
time.sleep(min(0.5, left))
return True
finally:
try:
proc.send_signal(signal.SIGCONT)
except OSError:
pass
def sample_frames_from_url(
url: str, interval_seconds: float, max_frames: int,
*, headers: str = "", timeout: float = 1200.0, should_stop=None,
governor=None,
) -> tuple[list[tuple[float, Image.Image]], str | None]:
"""Sample frames by pointing ffmpeg STRAIGHT at the media URL — it Range-reads
only the video index + up to max_frames worth of content, so the agent never
@@ -133,7 +166,10 @@ def sample_frames_from_url(
the timeout is the per-video ceiling (a slow/reconnecting stream can otherwise
run for minutes). `should_stop` is polled while ffmpeg runs so a Stop KILLS the
subprocess at once — otherwise a downloader stuck in a long decode keeps the
agent "working" long after Stop.
agent "working" long after Stop. `governor` (the worker's shared TokenBucket)
meters ffmpeg's network reads from outside via /proc/<pid>/io and SIGSTOPs
the process into budget, so video streaming honors the same aggregate
bandwidth cap as still downloads.
Returns (frames, reason): frames is empty on failure/stop/timeout, and
`reason` then carries the SPECIFIC cause (ffmpeg's stderr tail / timeout) so
@@ -168,6 +204,7 @@ def sample_frames_from_url(
cmd, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=errf,
)
meter = PidReadMeter(proc.pid) if governor is not None else None
# Poll rather than block, so a Stop (or the per-video timeout) can
# kill a slow/wedged ffmpeg promptly instead of waiting it out.
start = time.monotonic()
@@ -184,6 +221,17 @@ def sample_frames_from_url(
log.warning("ffmpeg timed out after %.0fs: %s",
timeout, url)
return [], f"ffmpeg timed out after {timeout:.0f}s"
if meter is not None:
read = meter.delta()
if read is None: # /proc gone → stop governing
meter = None
elif (debt := governor.charge(read)) > 0:
# Over budget: pause ffmpeg until the bucket
# recovers. Pause time counts toward `timeout`
# (it stays the wedge backstop either way).
if not _pause(proc, debt, should_stop):
_terminate(proc)
return [], "stopped"
except (OSError, ValueError) as exc:
return [], f"ffmpeg not runnable: {exc}"
frames = _collect_frames(tmp, interval, cap)