Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions telebot/async_telebot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[asyncio.Task[Any]] = set()
self.webhook_listener = None

if validate_token:
Expand Down Expand Up @@ -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)
Comment on lines +463 to +466
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The done callback currently only discards the task from _pending_tasks. If process_new_updates raises, the task’s exception is never retrieved/observed, which can lead to noisy Task exception was never retrieved warnings. Consider extending the done-callback to call task.exception() (and possibly log via _handle_exception/logger) before discarding, so failures aren’t silently ignored.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally left out of this PR. Adding task.exception() / _handle_exception into the done callback is a real behaviour change: today an unhandled exception in process_new_updates surfaces through Python's default unobserved-exception handler, which routes to whatever logging the user has configured. Moving that into the library would mean deciding whether to swallow, re-raise, or log at a particular level, and whether to reuse the polling loop's _handle_exception. That feels like a separate design conversation rather than a follow-on to the strong-ref fix. Happy to open a follow-up if @eternnoir wants to pull it in.

if interval: await asyncio.sleep(interval)
error_interval = 0.25 # drop error_interval if no errors

Expand Down
76 changes: 76 additions & 0 deletions tests/test_async_telebot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# -*- 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. 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())

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"
)