-
Notifications
You must be signed in to change notification settings - Fork 0
fix(telegram): close reconnect races that leave adapter half-destroyed #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -412,7 +412,6 @@ def __init__(self, config: PlatformConfig): | |||||||
| ) | ||||||||
| self._pending_text_batches: Dict[str, MessageEvent] = {} | ||||||||
| self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} | ||||||||
| self._drop_delayed_deliveries = False | ||||||||
| self._polling_error_task: Optional[asyncio.Task] = None | ||||||||
| self._polling_conflict_count: int = 0 | ||||||||
| self._polling_network_error_count: int = 0 | ||||||||
|
|
@@ -500,27 +499,6 @@ def __init__(self, config: PlatformConfig): | |||||||
| # same key edit the same message instead of appending new ones (#30045). | ||||||||
| self._status_message_ids: Dict[tuple, str] = {} | ||||||||
|
|
||||||||
| def _mark_connected(self) -> None: | ||||||||
| self._drop_delayed_deliveries = False | ||||||||
| super()._mark_connected() | ||||||||
|
|
||||||||
| def _mark_disconnected(self) -> None: | ||||||||
| self._drop_delayed_deliveries = True | ||||||||
| super()._mark_disconnected() | ||||||||
|
|
||||||||
| def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None: | ||||||||
| self._drop_delayed_deliveries = True | ||||||||
| super()._set_fatal_error(code, message, retryable=retryable) | ||||||||
|
|
||||||||
| def _should_drop_delayed_delivery(self) -> bool: | ||||||||
| """True once teardown/fatal-error started — delayed flushes must drop. | ||||||||
|
|
||||||||
| Buffered text/photo/media-group flushes sit behind an asyncio.sleep(). | ||||||||
| If disconnect wins the race, dispatching them spawns an agent on a | ||||||||
| torn-down session, producing stale/duplicate deliveries. | ||||||||
| """ | ||||||||
| return bool(getattr(self, "_drop_delayed_deliveries", False)) | ||||||||
|
|
||||||||
| def _notification_kwargs( | ||||||||
| self, metadata: Optional[Dict[str, Any]] | ||||||||
| ) -> Dict[str, Any]: | ||||||||
|
|
@@ -1773,16 +1751,24 @@ async def _handle_polling_network_error(self, error: Exception) -> None: | |||||||
| ) | ||||||||
| await asyncio.sleep(delay) | ||||||||
|
|
||||||||
| # Capture a stable local reference: self._app can be reassigned to None | ||||||||
| # by a concurrent disconnect() while we're suspended across the awaits | ||||||||
| # below, and re-reading self._app after that point would silently swap | ||||||||
| # in None mid-sequence instead of failing fast in one place. | ||||||||
| app = self._app | ||||||||
|
|
||||||||
| try: | ||||||||
| if self._app and self._app.updater and self._app.updater.running: | ||||||||
| await self._app.updater.stop() | ||||||||
| if app and app.updater and app.updater.running: | ||||||||
| await app.updater.stop() | ||||||||
| except Exception: | ||||||||
| pass | ||||||||
|
|
||||||||
| await self._drain_polling_connections() | ||||||||
|
|
||||||||
| try: | ||||||||
| await self._app.updater.start_polling( | ||||||||
| if not app: | ||||||||
| raise RuntimeError("Telegram application was torn down during reconnect") | ||||||||
| await app.updater.start_polling( | ||||||||
| allowed_updates=Update.ALL_TYPES, | ||||||||
| drop_pending_updates=False, | ||||||||
| error_callback=self._polling_error_callback_ref, | ||||||||
|
|
@@ -1824,6 +1810,12 @@ async def _handle_polling_network_error(self, error: Exception) -> None: | |||||||
| ) | ||||||||
| self._background_tasks.add(task) | ||||||||
| task.add_done_callback(self._background_tasks.discard) | ||||||||
| # This chained retry IS the in-flight recovery attempt — it | ||||||||
| # must replace the reentrancy guard, otherwise the heartbeat | ||||||||
| # loop, the pending-updates probe, and the PTB error callback | ||||||||
| # all see _polling_error_task as "done" and can each start a | ||||||||
| # second, concurrent recovery for the same outage. | ||||||||
| self._polling_error_task = task | ||||||||
|
|
||||||||
| async def _polling_heartbeat_loop(self) -> None: | ||||||||
| """Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing. | ||||||||
|
|
@@ -2937,60 +2929,8 @@ async def _set_status_indicator(self, online: bool) -> None: | |||||||
| self.name, text, e, | ||||||||
| ) | ||||||||
|
|
||||||||
| async def _cancel_pending_delivery_tasks(self) -> None: | ||||||||
| """Cancel every delayed-delivery task family before disconnect completes. | ||||||||
|
|
||||||||
| Covers media-group, photo-batch and text-batch flush tasks plus the | ||||||||
| polling-error recovery task. Each sits behind an ``asyncio.sleep()``; | ||||||||
| if teardown leaves them running they dispatch ``handle_message`` into a | ||||||||
| torn-down session. Skips the current task so the coroutine driving | ||||||||
| teardown does not cancel itself. | ||||||||
| """ | ||||||||
| current_task = asyncio.current_task() | ||||||||
| pending_tasks: list[asyncio.Task] = [] | ||||||||
| awaitable_tasks: list[asyncio.Task] = [] | ||||||||
| seen: set[int] = set() | ||||||||
|
|
||||||||
| def collect(task: Optional[asyncio.Task]) -> None: | ||||||||
| if not task or task.done() or task is current_task: | ||||||||
| return | ||||||||
| marker = id(task) | ||||||||
| if marker in seen: | ||||||||
| return | ||||||||
| seen.add(marker) | ||||||||
| pending_tasks.append(task) | ||||||||
| if asyncio.isfuture(task) or asyncio.iscoroutine(task): | ||||||||
| awaitable_tasks.append(task) | ||||||||
|
|
||||||||
| for task in list(self._media_group_tasks.values()): | ||||||||
| collect(task) | ||||||||
| for task in list(self._pending_photo_batch_tasks.values()): | ||||||||
| collect(task) | ||||||||
| for task in list(self._pending_text_batch_tasks.values()): | ||||||||
| collect(task) | ||||||||
| collect(self._polling_error_task) | ||||||||
|
|
||||||||
| for task in pending_tasks: | ||||||||
| task.cancel() | ||||||||
| if awaitable_tasks: | ||||||||
| await asyncio.gather(*awaitable_tasks, return_exceptions=True) | ||||||||
|
|
||||||||
| self._media_group_tasks.clear() | ||||||||
| self._media_group_events.clear() | ||||||||
| self._pending_photo_batch_tasks.clear() | ||||||||
| self._pending_photo_batches.clear() | ||||||||
| self._pending_text_batch_tasks.clear() | ||||||||
| self._pending_text_batches.clear() | ||||||||
| if self._polling_error_task is not current_task: | ||||||||
| self._polling_error_task = None | ||||||||
|
|
||||||||
| async def disconnect(self) -> None: | ||||||||
| """Stop polling/webhook, cancel pending delayed deliveries, and disconnect.""" | ||||||||
| # Mark disconnected first so the drop guard short-circuits any flush | ||||||||
| # that wins the race against teardown and prevents new delayed tasks | ||||||||
| # from being scheduled by late update handlers. | ||||||||
| self._mark_disconnected() | ||||||||
|
|
||||||||
| """Stop polling/webhook, cancel pending album flushes, and disconnect.""" | ||||||||
| # Cancel the heartbeat before tearing down the app so the probe task | ||||||||
| # cannot fire get_me() into a half-shutdown bot client. | ||||||||
| if self._polling_heartbeat_task and not self._polling_heartbeat_task.done(): | ||||||||
|
|
@@ -3011,7 +2951,13 @@ async def disconnect(self) -> None: | |||||||
| except Exception: | ||||||||
| pass | ||||||||
|
|
||||||||
| await self._cancel_pending_delivery_tasks() | ||||||||
| pending_media_group_tasks = list(self._media_group_tasks.values()) | ||||||||
| for task in pending_media_group_tasks: | ||||||||
| task.cancel() | ||||||||
| if pending_media_group_tasks: | ||||||||
| await asyncio.gather(*pending_media_group_tasks, return_exceptions=True) | ||||||||
| self._media_group_tasks.clear() | ||||||||
| self._media_group_events.clear() | ||||||||
|
|
||||||||
| if self._app: | ||||||||
| try: | ||||||||
|
|
@@ -3025,6 +2971,13 @@ async def disconnect(self) -> None: | |||||||
| logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True) | ||||||||
| self._release_platform_lock() | ||||||||
|
|
||||||||
| for task in self._pending_photo_batch_tasks.values(): | ||||||||
| if task and not task.done(): | ||||||||
| task.cancel() | ||||||||
| self._pending_photo_batch_tasks.clear() | ||||||||
| self._pending_photo_batches.clear() | ||||||||
|
Comment on lines
+2974
to
+2978
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Telegram adapter disconnect leaks text-batch and polling-error tasks into torn-down session (bug) The refactored TelegramAdapter.disconnect() (adapter.py:2932-2983) cancels media-group tasks and photo-batch tasks but omits two task families: (1) text-batch tasks in 💡 Suggestion: Add text-batch task cancellation (mirroring the photo-batch pattern at lines 2974-2978) and polling-error task cancellation (mirroring the heartbeat pattern at lines 2936-2942) to disconnect(). Clear the corresponding dicts after cancellation. 📋 Prompt for AI AgentsIn disconnect() at plugins/platforms/telegram/adapter.py, add two cleanup blocks: (A) Before the app-shutdown block (around line 2962), cancel and clear _polling_error_task: |
||||||||
|
|
||||||||
| self._mark_disconnected() | ||||||||
| self._app = None | ||||||||
| self._bot = None | ||||||||
| logger.info("[%s] Disconnected from Telegram", self.name) | ||||||||
|
|
@@ -6935,10 +6888,6 @@ def _enqueue_text_event(self, event: MessageEvent) -> None: | |||||||
| concatenates them and waits for a short quiet period before | ||||||||
| dispatching the combined message. | ||||||||
| """ | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping text batch enqueue after disconnect started") | ||||||||
| return | ||||||||
|
|
||||||||
| key = self._text_batch_key(event) | ||||||||
| existing = self._pending_text_batches.get(key) | ||||||||
| chunk_len = len(event.text or "") | ||||||||
|
|
@@ -6998,9 +6947,6 @@ async def _flush_text_batch(self, key: str) -> None: | |||||||
| event = self._pending_text_batches.pop(key, None) | ||||||||
| if not event: | ||||||||
| return | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping text batch flush after disconnect started") | ||||||||
| return | ||||||||
| logger.info( | ||||||||
| "[Telegram] Flushing text batch %s (%d chars)", | ||||||||
| key, len(event.text or ""), | ||||||||
|
|
@@ -7035,9 +6981,6 @@ async def _flush_photo_batch(self, batch_key: str) -> None: | |||||||
| event = self._pending_photo_batches.pop(batch_key, None) | ||||||||
| if not event: | ||||||||
| return | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping photo batch flush after disconnect started") | ||||||||
| return | ||||||||
| logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls)) | ||||||||
| await self.handle_message(event) | ||||||||
| finally: | ||||||||
|
|
@@ -7046,10 +6989,6 @@ async def _flush_photo_batch(self, batch_key: str) -> None: | |||||||
|
|
||||||||
| def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None: | ||||||||
| """Merge photo events into a pending batch and schedule flush.""" | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping photo batch enqueue after disconnect started") | ||||||||
| return | ||||||||
|
|
||||||||
| existing = self._pending_photo_batches.get(batch_key) | ||||||||
| if existing is None: | ||||||||
| self._pending_photo_batches[batch_key] = event | ||||||||
|
|
@@ -7354,10 +7293,6 @@ async def _queue_media_group_event(self, media_group_id: str, event: MessageEven | |||||||
| new user message and interrupts the first. We debounce briefly and merge the | ||||||||
| attachments into a single MessageEvent. | ||||||||
| """ | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping media group enqueue after disconnect started") | ||||||||
| return | ||||||||
|
|
||||||||
| existing = self._media_group_events.get(media_group_id) | ||||||||
| if existing is None: | ||||||||
| self._media_group_events[media_group_id] = event | ||||||||
|
|
@@ -7376,20 +7311,15 @@ async def _queue_media_group_event(self, media_group_id: str, event: MessageEven | |||||||
| ) | ||||||||
|
|
||||||||
| async def _flush_media_group_event(self, media_group_id: str) -> None: | ||||||||
| current_task = asyncio.current_task() | ||||||||
| try: | ||||||||
| await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS) | ||||||||
| event = self._media_group_events.pop(media_group_id, None) | ||||||||
| if event is not None: | ||||||||
| if self._should_drop_delayed_delivery(): | ||||||||
| logger.debug("[Telegram] Dropping media group flush after disconnect started") | ||||||||
| return | ||||||||
| await self.handle_message(event) | ||||||||
| except asyncio.CancelledError: | ||||||||
| return | ||||||||
| finally: | ||||||||
| if self._media_group_tasks.get(media_group_id) is current_task: | ||||||||
| self._media_group_tasks.pop(media_group_id, None) | ||||||||
| self._media_group_tasks.pop(media_group_id, None) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Media-group flush task unconditionally pops replacement task from tracking dict (bug) In 💡 Suggestion: Restore the
Suggested change
📋 Prompt for AI AgentsIn _flush_media_group_event at plugins/platforms/telegram/adapter.py, capture |
||||||||
|
|
||||||||
| async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None: | ||||||||
| """ | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 /queue command silently drops media attachments, reply context, and event metadata (bug)
The refactored /queue handler in GatewayRunner._handle_message (gateway/run.py:8462-8468) creates a bare MessageEvent that discards all media (media_urls, media_types), reply context (reply_to_message_id, reply_to_text, reply_to_author_id, reply_to_author_name, reply_to_is_own_message), auto_skill, raw_message, internal flag, and timestamp from the original event. This reverses a prior fix that intentionally preserved these fields. Users who /queue a photo, document, or replied-to message silently lose the attachment when the queued turn runs. Additionally, the has_media check was removed so queuing with only media (no caption) is now rejected outright.
💡 Suggestion: Restore the media/reply field copying from the original event when constructing the queued MessageEvent. Reintroduce the has_media check so /queue with an attachment but no text caption is still allowed (the old code's documented intent).
📋 Prompt for AI Agents
In gateway/run.py at the /queue handler around line 8456, replace lines 8457-8468 with: (1) compute has_media = bool(getattr(event, 'media_urls', None)), (2) guard with 'if not queued_text and not has_media: return usage line', (3) construct queued_event with message_type=event.message_type if has_media else MessageType.TEXT, and copy the following fields from the original event: raw_message, media_urls, media_types, reply_to_message_id, reply_to_text, reply_to_author_id, reply_to_author_name, reply_to_is_own_message, auto_skill, internal, timestamp. Keep source, message_id, and channel_prompt as-is.