cpython icon indicating copy to clipboard operation
cpython copied to clipboard

`trace` function is cleared after `RecursionError` is fired

Open XuehaiPan opened this issue 8 months ago • 5 comments

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

XuehaiPan avatar May 19 '25 13:05 XuehaiPan

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.

gaogaotiantian avatar May 19 '25 14:05 gaogaotiantian

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

XuehaiPan avatar May 19 '25 14:05 XuehaiPan

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.

gaogaotiantian avatar May 19 '25 14:05 gaogaotiantian

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.

XuehaiPan avatar May 20 '25 08:05 XuehaiPan

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

picnixz avatar Jun 29 '25 10:06 picnixz