From 1f4b28f32edccfd4cefb4de259658a730840e49e Mon Sep 17 00:00:00 2001 From: emad Date: Sat, 18 Apr 2026 01:52:17 +0300 Subject: [PATCH 1/2] Retain strong refs to update-processing tasks in AsyncTeleBot asyncio only keeps weak references to tasks returned from create_task, so a fire-and-forget task with no external reference may be garbage collected mid-execution, silently dropping updates. This adds a per-bot _pending_tasks set that holds each processing task until it completes, mirroring the pattern documented in the asyncio docs. Closes #2572 --- telebot/async_telebot.py | 10 ++++- tests/test_async_telebot.py | 75 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/test_async_telebot.py diff --git a/telebot/async_telebot.py b/telebot/async_telebot.py index 418cd39ee..4a35253d6 100644 --- a/telebot/async_telebot.py +++ b/telebot/async_telebot.py @@ -193,6 +193,10 @@ def __init__(self, token: str, parse_mode: Optional[str]=None, offset: Optional[ self._user = None # set during polling self._polling = None + # Strong references to background tasks created via asyncio.create_task(). + # asyncio only keeps weak references, so unreferenced tasks can be GC'd + # mid-execution; see https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task + self._pending_tasks: set = set() self.webhook_listener = None if validate_token: @@ -456,8 +460,10 @@ async def _process_polling(self, non_stop: bool=False, interval: int=0, timeout: updates = await self.get_updates(offset=self.offset, allowed_updates=allowed_updates, timeout=timeout, request_timeout=request_timeout) if updates: self.offset = updates[-1].update_id + 1 - # noinspection PyAsyncCall - asyncio.create_task(self.process_new_updates(updates)) # Seperate task for processing updates + # Retain a strong reference so the task isn't GC'd mid-execution. + task = asyncio.create_task(self.process_new_updates(updates)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) if interval: await asyncio.sleep(interval) error_interval = 0.25 # drop error_interval if no errors diff --git a/tests/test_async_telebot.py b/tests/test_async_telebot.py new file mode 100644 index 000000000..993378287 --- /dev/null +++ b/tests/test_async_telebot.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""Unit tests for `telebot.async_telebot.AsyncTeleBot`. + +These tests are self-contained (no TOKEN/CHAT_ID required) and stub out all +network I/O. +""" +import asyncio + +from telebot import types +from telebot.async_telebot import AsyncTeleBot + + +def _make_fake_me() -> types.User: + return types.User.de_json({ + "id": 1, + "is_bot": True, + "first_name": "Test", + "username": "test_bot", + }) + + +def test_process_polling_retains_update_processing_tasks(): + """Regression test for issue #2572. + + Tasks fired by `_process_polling` for `process_new_updates` must be held + in `self._pending_tasks` while running and discarded on completion, so + they cannot be garbage-collected mid-execution. + """ + bot = AsyncTeleBot("1:fake", validate_token=False) + + task_was_tracked_during_run: list[bool] = [] + process_completed = asyncio.Event() + + async def fake_process_new_updates(updates): + current = asyncio.current_task() + task_was_tracked_during_run.append(current in bot._pending_tasks) + process_completed.set() + + async def fake_get_me(): + return _make_fake_me() + + # Deliver a single update batch, then stop polling on the next tick. + fake_update = types.Update.de_json({"update_id": 1}) + call_count = {"n": 0} + + async def fake_get_updates(*args, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return [fake_update] + bot._polling = False + return [] + + async def noop(): + return None + + bot.get_me = fake_get_me + bot.get_updates = fake_get_updates + bot.process_new_updates = fake_process_new_updates + bot.close_session = noop # stub: no real aiohttp session in tests + + async def driver(): + await bot._process_polling(non_stop=True, interval=0, timeout=0) + # Allow the fire-and-forget task to finish plus one yield for the + # add_done_callback discard to run. + await process_completed.wait() + await asyncio.sleep(0) + + asyncio.run(driver()) + + assert task_was_tracked_during_run == [True], ( + "In-flight processing task must be held by _pending_tasks" + ) + assert bot._pending_tasks == set(), ( + "Completed processing tasks must be discarded from _pending_tasks" + ) From ea13186c1832d7d44a1879a7927763e6f0a26390 Mon Sep 17 00:00:00 2001 From: emad Date: Sun, 19 Apr 2026 04:52:04 +0300 Subject: [PATCH 2/2] Address review: type _pending_tasks, add test timeout Apply review feedback: - Parameterise _pending_tasks as set[asyncio.Task[Any]] so type checkers have visibility into the element type. - Wrap the test's process_completed.wait() in asyncio.wait_for(..., timeout=1) so a regression or stub-rewiring can't cause the test to hang the whole run. --- telebot/async_telebot.py | 2 +- tests/test_async_telebot.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/telebot/async_telebot.py b/telebot/async_telebot.py index 4a35253d6..99349a15c 100644 --- a/telebot/async_telebot.py +++ b/telebot/async_telebot.py @@ -196,7 +196,7 @@ def __init__(self, token: str, parse_mode: Optional[str]=None, offset: Optional[ # Strong references to background tasks created via asyncio.create_task(). # asyncio only keeps weak references, so unreferenced tasks can be GC'd # mid-execution; see https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task - self._pending_tasks: set = set() + self._pending_tasks: set[asyncio.Task[Any]] = set() self.webhook_listener = None if validate_token: diff --git a/tests/test_async_telebot.py b/tests/test_async_telebot.py index 993378287..fae838a52 100644 --- a/tests/test_async_telebot.py +++ b/tests/test_async_telebot.py @@ -61,8 +61,9 @@ async def noop(): async def driver(): await bot._process_polling(non_stop=True, interval=0, timeout=0) # Allow the fire-and-forget task to finish plus one yield for the - # add_done_callback discard to run. - await process_completed.wait() + # add_done_callback discard to run. A timeout guards against the + # stub ever being rewired such that the processing task never runs. + await asyncio.wait_for(process_completed.wait(), timeout=1) await asyncio.sleep(0) asyncio.run(driver())