136 lines
5.6 KiB
Python
136 lines
5.6 KiB
Python
"""Per-session event sequencing + bounded replay for WS reconnects.
|
|
|
|
Every event frame through :func:`server.write_json` (hence ``_emit``) gets a per-session monotonic
|
|
``seq`` and lands in a small ring per session; a reconnecting client calls ``session.events.since``
|
|
with its last seen seq and gets everything newer. Invariants: stdio TUI unaffected (``seq`` only on
|
|
event frames; Ink ignores unknown keys); one lock guards counters + buffers, and write_json already
|
|
serializes per-transport writes so stamping cannot reorder frames; memory bound =
|
|
_REPLAY_BUFFER_MAX events x _REPLAY_SESSIONS_MAX sessions, oldest session evicted FIFO.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import uuid
|
|
from collections import OrderedDict, deque
|
|
|
|
# Seq counters live in-process, so a restart resets them to 1 while clients hold high
|
|
# watermarks — events_since(sid, 97) would return [] with truncated=False forever. The
|
|
# epoch lets clients detect the restart and reset their watermarks.
|
|
_REPLAY_EPOCH = uuid.uuid4().hex
|
|
|
|
# Count limits protect control-frame churn; byte limits protect large tool results.
|
|
_REPLAY_BUFFER_MAX = 512
|
|
_REPLAY_SESSIONS_MAX = 64
|
|
_REPLAY_BUFFER_BYTES_MAX = 4 * 1024 * 1024
|
|
_REPLAY_PROCESS_BYTES_MAX = 64 * 1024 * 1024
|
|
|
|
_replay_lock = threading.Lock()
|
|
# sid -> deque of (seq, params dict, serialized bytes).
|
|
_replay_buffers: "OrderedDict[str, deque]" = OrderedDict()
|
|
_replay_buffer_bytes: dict[str, int] = {}
|
|
_replay_evicted_through: dict[str, int] = {}
|
|
_replay_total_bytes = 0
|
|
_replay_next_seq: dict[str, int] = {}
|
|
|
|
|
|
def replay_epoch() -> str:
|
|
"""Opaque token identifying this server process's seq numbering."""
|
|
return _REPLAY_EPOCH
|
|
|
|
|
|
def _stamp_event(obj: dict) -> None:
|
|
"""Stamp one outgoing event frame (mutates obj in place) and record it."""
|
|
if obj.get("method") != "event":
|
|
return
|
|
params = obj.get("params")
|
|
if not isinstance(params, dict):
|
|
return
|
|
sid = params.get("session_id") or ""
|
|
if not sid:
|
|
# Session-less global events (skin.changed etc.) are re-fetchable via their own RPCs.
|
|
return
|
|
with _replay_lock:
|
|
global _replay_total_bytes
|
|
seq = _replay_next_seq.get(sid, 0) + 1
|
|
_replay_next_seq[sid] = seq
|
|
params["seq"] = seq
|
|
size = len(json.dumps(params, ensure_ascii=False, separators=(",", ":")).encode(
|
|
"utf-8", errors="surrogatepass"))
|
|
buf = _replay_buffers.get(sid)
|
|
if buf is None:
|
|
buf = _replay_buffers[sid] = deque()
|
|
_replay_buffer_bytes[sid] = 0
|
|
while len(_replay_buffers) > _REPLAY_SESSIONS_MAX:
|
|
oldest_sid, oldest_buf = _replay_buffers.popitem(last=False)
|
|
_replay_total_bytes -= _replay_buffer_bytes.pop(oldest_sid, 0)
|
|
_replay_next_seq.pop(oldest_sid, None)
|
|
_replay_evicted_through.pop(oldest_sid, None)
|
|
if size > _REPLAY_BUFFER_BYTES_MAX or size > _REPLAY_PROCESS_BYTES_MAX:
|
|
_replay_evicted_through[sid] = seq
|
|
return
|
|
buf.append((seq, params, size))
|
|
_replay_buffer_bytes[sid] += size
|
|
_replay_total_bytes += size
|
|
while len(buf) > _REPLAY_BUFFER_MAX or _replay_buffer_bytes[sid] > _REPLAY_BUFFER_BYTES_MAX:
|
|
evicted_seq, _event, evicted_size = buf.popleft()
|
|
_replay_buffer_bytes[sid] -= evicted_size
|
|
_replay_total_bytes -= evicted_size
|
|
_replay_evicted_through[sid] = max(_replay_evicted_through.get(sid, 0), evicted_seq)
|
|
while _replay_total_bytes > _REPLAY_PROCESS_BYTES_MAX:
|
|
for evict_sid, evict_buf in _replay_buffers.items():
|
|
if evict_buf:
|
|
evicted_seq, _event, evicted_size = evict_buf.popleft()
|
|
_replay_buffer_bytes[evict_sid] -= evicted_size
|
|
_replay_total_bytes -= evicted_size
|
|
_replay_evicted_through[evict_sid] = max(_replay_evicted_through.get(evict_sid, 0), evicted_seq)
|
|
break
|
|
|
|
|
|
def events_since(sid: str, last_seen: int) -> list[dict]:
|
|
"""Recorded EVENT OBJECTS (each frame's ``params`` dict) with seq > last_seen for *sid*.
|
|
|
|
Returning the full JSON-RPC envelope would make every replayed event fail the
|
|
client's ``event.type`` gate and be silently dropped.
|
|
"""
|
|
with _replay_lock:
|
|
buf = _replay_buffers.get(sid or "")
|
|
return [event for seq, event, _size in buf if seq > last_seen] if buf else []
|
|
|
|
|
|
def is_truncated(sid: str, last_seen: int) -> bool:
|
|
"""True when events between *last_seen* and the ring's oldest retained seq were
|
|
evicted — the client must refetch history instead of trusting the replay."""
|
|
with _replay_lock:
|
|
return last_seen < _replay_evicted_through.get(sid or "", 0)
|
|
|
|
|
|
def latest_seq(sid: str) -> int:
|
|
"""Current highest stamped seq for *sid* (0 when unknown)."""
|
|
with _replay_lock:
|
|
return _replay_next_seq.get(sid or "", 0)
|
|
|
|
|
|
def reset_replay_state() -> None:
|
|
"""Test hook."""
|
|
with _replay_lock:
|
|
global _replay_total_bytes
|
|
_replay_buffers.clear()
|
|
_replay_buffer_bytes.clear()
|
|
_replay_evicted_through.clear()
|
|
_replay_next_seq.clear()
|
|
_replay_total_bytes = 0
|
|
|
|
|
|
def replay_stats() -> dict:
|
|
"""Telemetry: buffer occupancy for the ops/debug surface."""
|
|
with _replay_lock:
|
|
return {
|
|
"sessions": len(_replay_buffers),
|
|
"events": sum(len(buffer) for buffer in _replay_buffers.values()),
|
|
"bytes": _replay_total_bytes,
|
|
"max_per_session": _REPLAY_BUFFER_MAX,
|
|
"max_bytes_per_session": _REPLAY_BUFFER_BYTES_MAX,
|
|
"max_bytes_process": _REPLAY_PROCESS_BYTES_MAX}
|