fix(wecombot): throttle intermediate stream frames in WS mode

Drop intermediate stream frames that land inside the pacing window so a fast
runner cannot flood the WeCom wire. The first frame of a stream is released
immediately because there is no prior timestamp to compare against, and final
frames always bypass the throttle.

Frames carry complete snapshots since a2b7f6b7, so dropping an intermediate
frame loses no content: the next frame already carries everything.
This commit is contained in:
Smilewithoutfalling
2026-10-28 12:26:37 +08:00
parent f837083781
commit 76873c85e7
4 changed files with 210 additions and 2 deletions
@@ -64,6 +64,11 @@ _FEEDBACK_CACHE_MAX = 4096
_PENDING_FORM_MAX = 1024
_PENDING_FORM_TTL_SECONDS = 1800
_MAX_STREAM_CONTENT_CHARS = 200000
# Minimum spacing between two intermediate stream frames. WeCom replaces the
# whole bubble on every frame and every frame must carry the complete snapshot,
# so forwarding each runner chunk re-uploads the growing answer again and
# again. Final chunks bypass the throttle, so the reply still ends promptly.
_STREAM_PUSH_MIN_INTERVAL = 0.3
_MAX_CALLBACK_TASKS = 100
_MAX_REPLY_WORKERS = 100
_MAX_REPLY_QUEUE_SIZE = 100
@@ -135,6 +140,8 @@ class WecomBotWsClient:
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# Throttle: msg_id -> monotonic timestamp of the last frame sent
self._stream_last_push_at: dict[str, float] = {}
# Preserve completion identity after removing active stream mappings.
# A runner may emit both a final delta and a completed message snapshot.
self._completed_stream_content: dict[str, str] = {}
@@ -173,6 +180,7 @@ class WecomBotWsClient:
self._stream_sessions.pop(msg_id, None)
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_last_push_at.pop(msg_id, None)
task_id = self._task_id_by_msg.pop(msg_id, None)
if task_id:
self._pending_forms_by_task.pop(task_id, None)
@@ -253,6 +261,7 @@ class WecomBotWsClient:
self._callback_tasks.clear()
self._stream_ids.clear()
self._stream_last_content.clear()
self._stream_last_push_at.clear()
self._stream_sessions.clear()
self._feedback_sessions.clear()
self._msg_feedback_ids.clear()
@@ -437,6 +446,7 @@ class WecomBotWsClient:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_last_push_at.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True, stream_id, None
@@ -472,6 +482,7 @@ class WecomBotWsClient:
# push_stream_chunk calls for this msg_id become no-ops.
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_last_push_at.pop(msg_id, None)
# Keep _stream_sessions so the button callback can still resolve
# user/chat context; it gets cleaned up when the click fires.
@@ -713,6 +724,21 @@ class WecomBotWsClient:
if not _re.sub(r'[\s\u200b\u200c\u200d\ufeff]', '', next_content):
return True
# Throttle intermediate frames. Every frame carries the complete
# snapshot, so dropping one loses nothing; the next frame already
# contains everything that was skipped. The final chunk is never
# throttled, so a reply always terminates promptly.
if not is_final:
now = time.monotonic()
last_push_at = self._stream_last_push_at.get(msg_id)
# A missing entry means "nothing sent yet", not "sent at 0.0":
# time.monotonic() has an arbitrary origin, so on a freshly
# booted host a raw reading can be smaller than the interval
# and the first frame of a message would be dropped.
if last_push_at is not None and now - last_push_at < _STREAM_PUSH_MIN_INTERVAL:
return True
self._stream_last_push_at[msg_id] = now
# Generate feedback_id for final chunk
feedback_id = ''
if is_final:
@@ -734,6 +760,7 @@ class WecomBotWsClient:
self._cap_mapping(self._completed_stream_content, _STREAM_CACHE_MAX)
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_last_push_at.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True
except Exception: