3a54578f0a
Two causes made a shared shell's PTY stream lag for non-host viewers and then arrive "all at once": 1. Nagle's algorithm — PTY echo/keystrokes/chat are tiny frames; Nagle + delayed-ACK (amplified by Tailscale RTT) coalesced them into bursts. Set TCP_NODELAY on the client websocket (Plain/--no-tls path) and on each server connection's socket (via ws.io_proto.transport). 2. Head-of-line blocking — ConnectionManager.broadcast awaited each send() serially under a global lock, so the slowest viewer gated delivery to everyone AND stalled the server's read of the broker's next frame, backing the stream up. Give each connection its own bounded outbound queue + writer task; broadcast now only enqueues and never waits on a socket. A peer that overflows its backlog (4096) is evicted (close 1013) and resyncs on reconnect rather than buffering without bound or being fed a gapped terminal stream. Init snapshot is enqueued as the guaranteed-first frame (send_state -> state_frame). Adds tests/test_manager.py (FIFO, slow-client isolation, wedged eviction, reconnect replacement). Also fmt-clean sbx.rs/theme.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
58 lines
1.9 KiB
Python
58 lines
1.9 KiB
Python
import os
|
|
import time
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from dataclasses import asdict
|
|
import json
|
|
from sanic import Request, Sanic, Websocket
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# Only honor X-Forwarded-For when explicitly told we sit behind a trusted proxy
|
|
# (TRUST_PROXY=1). Otherwise a direct client can spoof the header to forge a
|
|
# source IP and dodge the per-IP rate limiter, so we use the real peer address.
|
|
_TRUST_PROXY = os.environ.get("TRUST_PROXY", "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def get_client_ip(request: Request) -> str:
|
|
if _TRUST_PROXY:
|
|
if forwarded := request.headers.get("x-forwarded-for"):
|
|
return forwarded.split(",")[0].strip()
|
|
return request.ip
|
|
|
|
|
|
def state_frame(app: Sanic) -> str:
|
|
"""Build the per-client `init` snapshot (message backlog + roster) as a JSON
|
|
string. Returned (not sent) so it can be enqueued as the connection's first
|
|
outbound frame, guaranteeing it precedes any later broadcast."""
|
|
messages = app.ctx.message_store.get_all()
|
|
users = app.ctx.session_store.get_all()
|
|
return json.dumps(
|
|
{
|
|
"type": "init",
|
|
"messages": [asdict(m) for m in messages],
|
|
"users": [
|
|
{"user_id": u.user_id, "username": u.username} for u in users
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
|
self.max_requests = max_requests
|
|
self.window = window_seconds
|
|
self._requests: dict[str, list[float]] = defaultdict(list)
|
|
|
|
def is_allowed(self, key: str) -> bool:
|
|
now = time.monotonic()
|
|
timestamps = self._requests[key]
|
|
timestamps[:] = [t for t in timestamps if now - t < self.window]
|
|
if len(timestamps) >= self.max_requests:
|
|
return False
|
|
timestamps.append(now)
|
|
return True
|