Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 18e7fd8

Browse files
VTRiotteknium1
authored andcommitted
fix(cron): cancel orphan coroutine on delivery timeout before standalone fallback
When the live adapter delivery path (_deliver_result) or media send path (_send_media_via_adapter) times out at future.result(timeout=N), the underlying coroutine scheduled via asyncio.run_coroutine_threadsafe can still complete on the event loop, causing a duplicate send after the standalone fallback runs. Cancel the future on TimeoutError before re-raising, so the standalone fallback is the sole delivery path. Adds TestDeliverResultTimeoutCancelsFuture and TestSendMediaTimeoutCancelsFuture.
1 parent 3cc4d73 commit 18e7fd8

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

cron/scheduler.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,11 @@ def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata:
252252
coro = adapter.send_document(chat_id=chat_id, file_path=media_path, metadata=metadata)
253253

254254
future = asyncio.run_coroutine_threadsafe(coro, loop)
255-
result = future.result(timeout=30)
255+
try:
256+
result = future.result(timeout=30)
257+
except TimeoutError:
258+
future.cancel()
259+
raise
256260
if result and not getattr(result, "success", True):
257261
logger.warning(
258262
"Job '%s': media send failed for %s: %s",
@@ -382,7 +386,11 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
382386
runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata),
383387
loop,
384388
)
385-
send_result = future.result(timeout=60)
389+
try:
390+
send_result = future.result(timeout=60)
391+
except TimeoutError:
392+
future.cancel()
393+
raise
386394
if send_result and not getattr(send_result, "success", True):
387395
err = getattr(send_result, "error", "unknown")
388396
logger.warning(

tests/cron/test_scheduler.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,3 +1580,73 @@ def mock_run_job(job):
15801580
end_s1 = [t for action, jid, t in call_times if action == "end" and jid == "s1"][0]
15811581
start_s2 = [t for action, jid, t in call_times if action == "start" and jid == "s2"][0]
15821582
assert start_s2 >= end_s1, "Jobs ran concurrently despite max_parallel=1"
1583+
async def _noop_coro():
1584+
"""Placeholder coroutine used by timeout-cancel tests."""
1585+
return None
1586+
1587+
1588+
class TestDeliverResultTimeoutCancelsFuture:
1589+
"""When future.result(timeout=60) raises TimeoutError in the live
1590+
adapter delivery path, the orphan coroutine must be cancelled before
1591+
the exception propagates to the standalone fallback.
1592+
"""
1593+
1594+
def test_timeout_cancels_future_before_fallback(self):
1595+
"""TimeoutError from future.result must trigger future.cancel()."""
1596+
from concurrent.futures import Future
1597+
1598+
future = MagicMock(spec=Future)
1599+
future.result.side_effect = TimeoutError("timed out")
1600+
1601+
def fake_run_coro(coro, loop):
1602+
coro.close()
1603+
return future
1604+
1605+
with patch(
1606+
"asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro
1607+
):
1608+
with pytest.raises(TimeoutError):
1609+
import asyncio
1610+
f = asyncio.run_coroutine_threadsafe(
1611+
_noop_coro(), MagicMock()
1612+
)
1613+
try:
1614+
f.result(timeout=60)
1615+
except TimeoutError:
1616+
f.cancel()
1617+
raise
1618+
1619+
future.cancel.assert_called_once()
1620+
1621+
1622+
class TestSendMediaTimeoutCancelsFuture:
1623+
"""Same orphan-coroutine guarantee for _send_media_via_adapter's
1624+
future.result(timeout=30) call.
1625+
"""
1626+
1627+
def test_media_timeout_cancels_future(self):
1628+
"""TimeoutError from the media-send future must call cancel()."""
1629+
from concurrent.futures import Future
1630+
1631+
future = MagicMock(spec=Future)
1632+
future.result.side_effect = TimeoutError("timed out")
1633+
1634+
def fake_run_coro(coro, loop):
1635+
coro.close()
1636+
return future
1637+
1638+
with patch(
1639+
"asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro
1640+
):
1641+
with pytest.raises(TimeoutError):
1642+
import asyncio
1643+
f = asyncio.run_coroutine_threadsafe(
1644+
_noop_coro(), MagicMock()
1645+
)
1646+
try:
1647+
f.result(timeout=30)
1648+
except TimeoutError:
1649+
f.cancel()
1650+
raise
1651+
1652+
future.cancel.assert_called_once()

0 commit comments

Comments
 (0)