diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index cc833b80d52542..98bfb4d25dad2a 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -1517,3 +1517,12 @@ Task object used by end-user code. See :meth:`uncancel` for more details. .. versionadded:: 3.11 + + .. method:: cancelling_since() + + Return the event loop time at which :meth:`cancel` was first called, or + ``None`` if cancellation has not been requested. + + Repeated calls to :meth:`cancel` do not change the recorded time. + + .. versionadded:: 3.16 diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py index fbd5c39a7c56ac..79f270b005c698 100644 --- a/Lib/asyncio/tasks.py +++ b/Lib/asyncio/tasks.py @@ -98,6 +98,7 @@ def __init__(self, coro, *, loop=None, name=None, context=None, self._name = str(name) self._num_cancels_requested = 0 + self._cancel_requested_at = None self._must_cancel = False self._fut_waiter = None self._coro = coro @@ -206,6 +207,8 @@ def cancel(self, msg=None): if self.done(): return False self._num_cancels_requested += 1 + if self._num_cancels_requested == 1: + self._cancel_requested_at = self._loop.time() # These two lines are controversial. See discussion starting at # https://github.com/python/cpython/pull/31394#issuecomment-1053545331 # Also remember that this is duplicated in _asynciomodule.c. @@ -230,6 +233,9 @@ def cancelling(self): """ return self._num_cancels_requested + def cancelling_since(self): + return self._cancel_requested_at + def uncancel(self): """Decrement the task's count of cancellation requests. diff --git a/Lib/test/test_asyncio/test_tasks.py b/Lib/test/test_asyncio/test_tasks.py index 56b1494c8363ca..95578191d484b4 100644 --- a/Lib/test/test_asyncio/test_tasks.py +++ b/Lib/test/test_asyncio/test_tasks.py @@ -539,6 +539,56 @@ async def task(): finally: loop.close() + def test_cancelling_since_none_before_cancel(self): + loop = asyncio.new_event_loop() + + async def task(): + await asyncio.sleep(10) + + try: + t = self.new_task(loop, task()) + self.assertIsNone(t.cancelling_since()) + t.cancel() + with self.assertRaises(asyncio.CancelledError): + loop.run_until_complete(t) + finally: + loop.close() + + def test_cancelling_since_is_float_after_cancel(self): + loop = asyncio.new_event_loop() + + async def task(): + await asyncio.sleep(10) + + try: + t = self.new_task(loop, task()) + t.cancel() + ts = t.cancelling_since() + self.assertIsInstance(ts, float) + self.assertGreater(ts, 0) + with self.assertRaises(asyncio.CancelledError): + loop.run_until_complete(t) + finally: + loop.close() + + def test_cancelling_since_stable_across_multiple_cancels(self): + loop = asyncio.new_event_loop() + + async def task(): + await asyncio.sleep(10) + + try: + t = self.new_task(loop, task()) + t.cancel() + first = t.cancelling_since() + t.cancel() + t.cancel() + self.assertEqual(t.cancelling_since(), first) + with self.assertRaises(asyncio.CancelledError): + loop.run_until_complete(t) + finally: + loop.close() + def test_uncancel_basic(self): loop = asyncio.new_event_loop() diff --git a/Misc/NEWS.d/next/Library/2026-05-19-12-20-00.gh-issue-150060.Gm2kQn.rst b/Misc/NEWS.d/next/Library/2026-05-19-12-20-00.gh-issue-150060.Gm2kQn.rst new file mode 100644 index 00000000000000..62156d330be1b1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-19-12-20-00.gh-issue-150060.Gm2kQn.rst @@ -0,0 +1,2 @@ +Add :meth:`asyncio.Task.cancelling_since` to report when cancellation was +first requested for a task. diff --git a/Modules/_asynciomodule.c b/Modules/_asynciomodule.c index 9679a7dde31b0d..5d09d3bdb898fc 100644 --- a/Modules/_asynciomodule.c +++ b/Modules/_asynciomodule.c @@ -62,6 +62,7 @@ typedef struct TaskObj { unsigned task_must_cancel: 1; unsigned task_log_destroy_pending: 1; int task_num_cancels_requested; + PyObject *task_cancel_requested_at; /* loop time of first cancel(), or Py_None */ PyObject *task_fut_waiter; PyObject *task_coro; PyObject *task_name; @@ -2342,6 +2343,7 @@ _asyncio_Task___init___impl(TaskObj *self, PyObject *coro, PyObject *loop, self->task_must_cancel = 0; self->task_log_destroy_pending = 1; self->task_num_cancels_requested = 0; + self->task_cancel_requested_at = Py_NewRef(Py_None); set_task_coro(self, coro); if (name == Py_None) { @@ -2399,6 +2401,7 @@ TaskObj_clear(PyObject *op) clear_task_coro(task); Py_CLEAR(task->task_context); Py_CLEAR(task->task_name); + Py_CLEAR(task->task_cancel_requested_at); Py_CLEAR(task->task_fut_waiter); return 0; } @@ -2411,6 +2414,7 @@ TaskObj_traverse(PyObject *op, visitproc visit, void *arg) Py_VISIT(task->task_context); Py_VISIT(task->task_coro); Py_VISIT(task->task_name); + Py_VISIT(task->task_cancel_requested_at); Py_VISIT(task->task_fut_waiter); FutureObj *fut = (FutureObj *)task; Py_VISIT(fut->fut_loop); @@ -2588,6 +2592,15 @@ _asyncio_Task_cancel_impl(TaskObj *self, PyObject *msg) self->task_num_cancels_requested += 1; + if (self->task_num_cancels_requested == 1) { + PyObject *t = PyObject_CallMethodNoArgs( + ((FutureObj *)self)->fut_loop, &_Py_ID(time)); + if (t == NULL) { + return NULL; + } + Py_SETREF(self->task_cancel_requested_at, t); + } + // These three lines are controversial. See discussion starting at // https://github.com/python/cpython/pull/31394#issuecomment-1053545331 // and corresponding code in tasks.py. @@ -2640,6 +2653,20 @@ _asyncio_Task_cancelling_impl(TaskObj *self) return PyLong_FromLong(self->task_num_cancels_requested); } +/*[clinic input] +@critical_section +_asyncio.Task.cancelling_since + +Return the loop time when cancel() was first called, or None. +[clinic start generated code]*/ + +static PyObject * +_asyncio_Task_cancelling_since_impl(TaskObj *self) +/*[clinic end generated code: output=2670bb55581ab10e input=1f81f0a3b6e3da63]*/ +{ + return Py_NewRef(self->task_cancel_requested_at); +} + /*[clinic input] @critical_section _asyncio.Task.uncancel @@ -2918,6 +2945,7 @@ static PyMethodDef TaskType_methods[] = { _ASYNCIO_TASK_SET_EXCEPTION_METHODDEF _ASYNCIO_TASK_CANCEL_METHODDEF _ASYNCIO_TASK_CANCELLING_METHODDEF + _ASYNCIO_TASK_CANCELLING_SINCE_METHODDEF _ASYNCIO_TASK_UNCANCEL_METHODDEF _ASYNCIO_TASK_GET_STACK_METHODDEF _ASYNCIO_TASK_PRINT_STACK_METHODDEF diff --git a/Modules/clinic/_asynciomodule.c.h b/Modules/clinic/_asynciomodule.c.h index 66953d74213b66..30b1a87abd6f34 100644 --- a/Modules/clinic/_asynciomodule.c.h +++ b/Modules/clinic/_asynciomodule.c.h @@ -1221,6 +1221,30 @@ _asyncio_Task_cancelling(PyObject *self, PyObject *Py_UNUSED(ignored)) return return_value; } +PyDoc_STRVAR(_asyncio_Task_cancelling_since__doc__, +"cancelling_since($self, /)\n" +"--\n" +"\n" +"Return the loop time when cancel() was first called, or None."); + +#define _ASYNCIO_TASK_CANCELLING_SINCE_METHODDEF \ + {"cancelling_since", (PyCFunction)_asyncio_Task_cancelling_since, METH_NOARGS, _asyncio_Task_cancelling_since__doc__}, + +static PyObject * +_asyncio_Task_cancelling_since_impl(TaskObj *self); + +static PyObject * +_asyncio_Task_cancelling_since(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _asyncio_Task_cancelling_since_impl((TaskObj *)self); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + PyDoc_STRVAR(_asyncio_Task_uncancel__doc__, "uncancel($self, /)\n" "--\n" @@ -2232,4 +2256,4 @@ _asyncio_future_discard_from_awaited_by(PyObject *module, PyObject *const *args, exit: return return_value; } -/*[clinic end generated code: output=b69948ed810591d9 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=1e3738a5fe4c981d input=a9049054013a1b77]*/