`trace` function is cleared after `RecursionError` is fired
Bug report
Bug description:
I'm using coverage and pytest-cov in CI to measure the line coverage of the unittests. It is achieved by registering a trace function with sys.settrace.
However, after binsecting my unittests, I found that when a RecursionError is raised, the system trace function will be cleared. That will cause a warning emitted by coverage:
~/Projects/cpython/venv/lib/python3.15t/site-packages/coverage/pytracer.py:355: CoverageWarning: Trace function changed, data is likely wrong: None != <bound method PyTracer._trace of <PyTracer at 0x200021dcc20: 2076 data points in 11 files>> (trace-changed)
self.warn(
Reproducible code:
import sys
def tracer(*args, **kwargs):
pass
def factorial(n: int) -> int:
"""Calculate the factorial of a number."""
if n <= 1:
return 1
return n * factorial(n - 1)
sys.settrace(tracer)
assert sys.gettrace() is tracer
sys.setrecursionlimit(64)
assert sys.gettrace() is tracer
try:
_ = factorial(100)
except RecursionError:
pass
assert sys.gettrace() is None
REPL Output:
# Add a code block here, if required
$ python3
Python 3.13.3 (main, Apr 8 2025, 13:54:08) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> def tracer(*args, **kwargs):
... pass
...
>>> def factorial(n):
... if n <= 1:
... return 1
... return n * factorial(n - 1)
...
>>> sys.settrace(tracer)
>>> sys.gettrace() is tracer
True
>>> sys.setrecursionlimit(64)
>>> _ = factorial(100)
Traceback (most recent call last):
File "<python-input-6>", line 1, in <module>
_ = factorial(100)
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
[Previous line repeated 51 more times]
File "<python-input-2>", line 1, in factorial
def factorial(n):
RecursionError: maximum recursion depth exceeded
>>> sys.gettrace() is None
True
CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS
I don't believe this is a bug. It's documented that
If there is any error occurred in the trace function, it will be unset, just like
settrace(None)is called.
I believe what happened is the trace function triggered the recursion exception which turned off the trace.
I don't believe this is a bug. It's documented that
If there is any error occurred in the trace function, it will be unset, just like
settrace(None)is called.I believe what happened is the trace function triggered the recursion exception which turned off the trace.
My first thought is that the Python interpreter calls the trace function, which is not handled in the user space. And the recursion error is expected to be raised in user code (see the exception traceback in the example code), not the trace function.
>>> _ = factorial(100)
Traceback (most recent call last):
File "<python-input-6>", line 1, in <module>
_ = factorial(100)
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~^^^^^^^
[Previous line repeated 51 more times]
File "<python-input-2>", line 1, in factorial
def factorial(n):
RecursionError: maximum recursion depth exceeded
As a workaround, I manually backup the trace function in recursion tests to make coverage happy:
@contextlib.contextmanager
def recursionlimit(limit):
old_limit = sys.getrecursionlimit()
old_tracer = sys.gettrace()
sys.setrecursionlimit(min(old_limit, limit))
try:
yield
finally:
sys.setrecursionlimit(old_limit)
sys.settrace(old_tracer)
def test_recursion():
with recursionlimit(64):
with pytest.raises(RecursionError):
_ = trigger_recursion_error()
I believe the recursion error is raised before calling the function, that's why it's not in the traceback. However, the tracing code has some trampoline code written in C before calling the actual trace function. So the order would be:
start_trace -> trampoline -> about to call trace func but realize it's over limit -> raise exception -> trace is stopped
I don't see a reason to change this behavior, you can mess up a lot of things when you reach the recursion limit. coveragepy might be responsible to keep its trace going in this special case.
I believe the recursion error is raised before calling the function, that's why it's not in the traceback.
Confirmed that the trace function has visible side effects on user code. ([Previous line repeated 52 more times] vs. [Previous line repeated 51 more times])
>>> import sys
>>> sys.setrecursionlimit(64)
>>> def factorial(n):
... if n <= 1:
... return 1
... return n * factorial(n - 1)
...
>>> _ = factorial(100)
Traceback (most recent call last):
File "<python-input-3>", line 1, in <module>
_ = factorial(100)
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
[Previous line repeated 52 more times]
RecursionError: maximum recursion depth exceeded
>>> sys.settrace(lambda *args, **kwargs: None)
>>> _ = factorial(100)
Traceback (most recent call last):
File "<python-input-5>", line 1, in <module>
_ = factorial(100)
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
File "<python-input-2>", line 4, in factorial
return n * factorial(n - 1)
~~~~~~~~~^^^^^^^
[Previous line repeated 51 more times]
File "<python-input-2>", line 1, in factorial
def factorial(n):
RecursionError: maximum recursion depth exceeded
>>>
So we might need to update the documentation that the trace function shares the same stack limit with the user code. I expected the trace function to have its own call frame stack because the user frame is passed as the trace function argument.
And the 'exception' event might not work as expected for RecursionError.
'exception' An exception has occurred. The local trace function is called; arg is a tuple
(exception, value, traceback); the return value specifies the new local trace function.
I'm ok for a doc update I guess, but I don't know whether it's relevant to users.
coveragepy might be responsible to keep its trace going in this special case.
Indeed, cc @nedbat