Bound remote pool and rate-limiter memory

Both tables grew one entry per distinct key and never shrank, so a long-running
SDK client or a public serve process accumulated state for every endpoint or
identity it had ever seen. GC cannot reclaim them while the pool and limiter
still reference them.

Cap the pool at 64 endpoint buckets and the limiter at 4096 buckets. Both evict
useless state first: connections past the idle timeout the server has likely
dropped anyway, and buckets that have fully refilled, which carry no throttling
information. Only then fall back to evicting the oldest entry.

Evicting a limiter bucket resets throttling for that identity, which is the
deliberate trade: an attacker cycling identities faster than they go idle can
regain tokens, but unbounded growth would take the process down instead.
This commit is contained in:
2026-08-09 20:49:35 +02:00
parent 1b32410575
commit 541b950519
4 changed files with 103 additions and 1 deletions
+33
View File
@@ -43,6 +43,39 @@ def test_checkin_caps_pool_size():
b.close()
pool.close_all()
def test_checkin_caps_endpoint_buckets():
pool.close_all()
peers = []
try:
for i in range(pool._MAX_ENDPOINTS + 5):
a, b = _socketpair()
peers.append(b)
pool.checkin(f"host-{i}:443", pool.PooledConnection(a, b"secret"))
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
finally:
for peer in peers:
peer.close()
pool.close_all()
def test_checkin_prunes_stale_endpoint_buckets():
pool.close_all()
old_a, old_b = _socketpair()
old = pool.PooledConnection(old_a, b"secret")
pool.checkin("old:443", old)
old.last_used -= pool._MAX_IDLE_SECONDS + 1
peers = [old_b]
try:
for i in range(pool._MAX_ENDPOINTS):
a, b = _socketpair()
peers.append(b)
pool.checkin(f"new-{i}:443", pool.PooledConnection(a, b"secret"))
assert "old:443" not in pool._POOL
assert len(pool._POOL) <= pool._MAX_ENDPOINTS
finally:
for peer in peers:
peer.close()
pool.close_all()
def test_session_inner_message_strips_auth_fields():
msg = {
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
+17
View File
@@ -181,6 +181,23 @@ def test_rate_limiter_is_per_key():
assert limiter.allow("a") is False
assert limiter.allow("b") is False
def test_rate_limiter_caps_identity_buckets():
limiter = RateLimiter(rate=0.0001, burst=1, max_buckets=3)
for i in range(10):
assert limiter.allow(f"key-{i}") is True
assert len(limiter._buckets) <= 3
def test_rate_limiter_prunes_refilled_idle_buckets(monkeypatch):
current = 1000.0
monkeypatch.setattr("browser_cli.serve.security.time.monotonic", lambda: current)
limiter = RateLimiter(rate=1, burst=2, max_buckets=2)
assert limiter.allow("old") is True
current += 120.0
assert limiter.allow("a") is True
assert limiter.allow("b") is True
assert "old" not in limiter._buckets
assert len(limiter._buckets) <= 2
# ── ServeSecurity ────────────────────────────────────────────────────────────────
def test_effective_policy_prefers_per_key_override():