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:
@@ -0,0 +1,111 @@
|
||||
"""Global download-bandwidth governor (one token bucket for the whole agent).
|
||||
|
||||
The agent lives on someone's desktop and shares that desktop's network —
|
||||
typically WiFi, where saturating the link doesn't just slow other apps: it
|
||||
bufferbloats the airtime (RTT 21→45ms) and collapses EVERY connection,
|
||||
the operator's browser included. Measured 2026-07-02: the idle link moved
|
||||
~38 MB/s single-stream, but under the 8-downloader sweep every stream on the
|
||||
machine crawled at ~1-1.5 MB/s. So the cap is on the AGGREGATE, not per
|
||||
stream: still downloads pump their chunks through take(), and ffmpeg video
|
||||
streams — whose sockets live in a subprocess we can't wrap — are metered from
|
||||
outside via /proc/<pid>/io and paused (SIGSTOP) into budget using charge()'s
|
||||
debt signal; TCP flow control then stalls the sender while ffmpeg sleeps.
|
||||
|
||||
Accounting is post-paid (charge the bytes first, then wait out any debt): the
|
||||
bytes have already crossed the network by the time we count them, and it means
|
||||
a chunk larger than one second of budget can never deadlock the bucket.
|
||||
Stdlib-only on purpose — unit-tested in CI, where the agent's ML deps
|
||||
don't exist.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Thread-safe token bucket in bytes/second. rate 0 = unlimited.
|
||||
|
||||
`consumed` is the monotonic total of bytes charged (throttled or not) —
|
||||
the worker's rate loop derives the UI's "net MB/s" readout from it.
|
||||
"""
|
||||
|
||||
def __init__(self, rate_bytes_per_s: float = 0.0):
|
||||
self._cond = threading.Condition()
|
||||
self._rate = max(0.0, float(rate_bytes_per_s))
|
||||
# Burst = one second of budget: enough that chunked reads stay smooth,
|
||||
# small enough that a burst can't meaningfully lift the average.
|
||||
self._level = self._rate
|
||||
self._stamp = time.monotonic()
|
||||
self.consumed = 0
|
||||
|
||||
@property
|
||||
def rate(self) -> float:
|
||||
return self._rate
|
||||
|
||||
def set_rate(self, rate_bytes_per_s: float) -> None:
|
||||
"""Retune live (the UI dial). Waiters re-check immediately, so raising
|
||||
the cap (or lifting it with 0) unblocks a mid-download wait at once."""
|
||||
with self._cond:
|
||||
self._refill_locked() # settle elapsed time at the OLD rate
|
||||
self._rate = max(0.0, float(rate_bytes_per_s))
|
||||
self._level = min(self._level, self._rate)
|
||||
self._cond.notify_all()
|
||||
|
||||
def _refill_locked(self) -> None:
|
||||
now = time.monotonic()
|
||||
self._level = min(self._rate, self._level + (now - self._stamp) * self._rate)
|
||||
self._stamp = now
|
||||
|
||||
def take(self, n: int) -> None:
|
||||
"""Charge n bytes and block until the budget recovers (stills path)."""
|
||||
with self._cond:
|
||||
self.consumed += n
|
||||
if self._rate <= 0:
|
||||
return
|
||||
self._refill_locked()
|
||||
self._level -= n
|
||||
while self._level < 0:
|
||||
# Wake early on set_rate; cap the wait so a big debt is paid in
|
||||
# re-checked slices rather than one long uninterruptible sleep.
|
||||
self._cond.wait(min(-self._level / self._rate, 0.5))
|
||||
if self._rate <= 0:
|
||||
return
|
||||
self._refill_locked()
|
||||
|
||||
def charge(self, n: int) -> float:
|
||||
"""Charge n bytes WITHOUT blocking; return seconds of debt (0 = within
|
||||
budget). The ffmpeg governor can't block the subprocess's own reads, so
|
||||
it SIGSTOPs the process for (about) the returned debt instead."""
|
||||
with self._cond:
|
||||
self.consumed += n
|
||||
if self._rate <= 0:
|
||||
return 0.0
|
||||
self._refill_locked()
|
||||
self._level -= n
|
||||
return max(0.0, -self._level / self._rate)
|
||||
|
||||
|
||||
class PidReadMeter:
|
||||
"""Cumulative read-bytes meter for a subprocess, via /proc/<pid>/io.
|
||||
|
||||
`rchar` counts every read() syscall's bytes — for a streaming ffmpeg the
|
||||
network reads dominate, so the delta is a good-enough aggregate-bandwidth
|
||||
signal (it's a governor, not a billing meter). Returns None when /proc is
|
||||
unavailable (process exited, or a non-Linux host): the caller then simply
|
||||
doesn't govern — degrade to unthrottled rather than break video sampling.
|
||||
"""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self._path = f"/proc/{pid}/io"
|
||||
self._last = 0
|
||||
|
||||
def delta(self) -> int | None:
|
||||
try:
|
||||
with open(self._path, "rb") as f:
|
||||
for line in f:
|
||||
if line.startswith(b"rchar:"):
|
||||
total = int(line.split()[1])
|
||||
d, self._last = total - self._last, total
|
||||
return max(0, d)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
Reference in New Issue
Block a user