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:
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user