In python version 3.13…
I noticed the possibility of creating infinite recursion, that is, the stack does not overflow, this could be attributed to optimizing the “tail” recursion, but memory measurements showed that memory is consumed until it runs out.
Here is the code for reproducing such a recursion:
This is neat but I don’t think it’s a problem (I’m not sure if you’re suggesting it is). It has always been possible to write an infinite loop in Python. It’s arguably a required feature for a programming language. As long as you can get out with an interrupt, it’s okay.
The fact that infinite loops are now possible with tail call optimization is actually cool and a useful feature in some scenarios. In purely functional languages where you can rely on this behavior, you might write an event loop this way:
It’s interesting that it’s running out of memory eventually, though. I doesn’t seem like it should, and I wonder if there is some further optimization possible there.
An endless loop is one thing, optimization is another, there is no optimization in the code that I provided, because the stacks are saved, this can be seen if you measure the RAM consumption during the code, it
grows exponentially
From my understanding what was changed in CPython 3.13 is that a Python function call no longer results in a C stack frame being created. In other words the C code in the interpreter no longer calls itself recursively to implement a Python function call. That means that the C stack size is not a reason to limit recursion any more.
However the Python call stack still exists and the Python call frames are still there. They just live in the heap. The interpreter is supposed to check how many of those there are and compare it with the recursion limit before each function call.
My guess here here is that in 3.13 something like the adaptive interpreter optimises this into something that no longer checks the recursion limit e.g. maybe the optimiser sees that this function foo is calling itself and reduces it to a loop.
Reduce the amount of printing and you can bork your system faster:
Trusty Ctrl-\ still works to exit with a core dump though.
Here is one from years ago which achieves much faster builtin performance borkage:
# DANGEROUS:
a = [...]
a.extend(iter(a))
Depending on OS and other things it might be that you need a hard reboot to recover from these things so make sure to save your work etc before trying.
That is what RecursionError is for. It just isn’t being raised here probably because of a bug.
I think you misunderstand what Ctrl-\ is. In my terminal on Linux Ctrl-\ sense SIGQUIT which is a more forceful way of telling a process (not necessarily Python) to stop.
This is what makes me think it is to do with the optimising interpreter or one of the other optimisation things that have recently been added to CPython. The optimising interpreter will see when some function is being called lots of times and then try to optimise its byte code. It will do that using various heuristics and other things and I can well imagine that having a parameter even if unused might affect the decisions it makes. If one of the optimiser’s code paths has a bug then the manifestation of that bug could be very sensitive to details in the code.
To be clear I think this is a bug. It would be better to open a github issue rather than discussing it here.
It looks like what is happening is something like this:
The CALL instruction takes the function that is called, makes a new stack frame, and then goes back to where the interpreter checks that there is still enough room left before reaching the recursion limit, then goes on to the beginning of the called function. If there isn’t enough room left, the interpreter raises a RecursionError.
If one function is being called at a particular place, and the number of arguments given is the same as the number of arguments that function takes, then the CALL instruction gets replaced with a CALL_PY_EXACT_ARGS. This instruction will check that it isn’t about to run out of room, and if it is, it turns the CALL_PY_EXACT_ARGS back into a CALL. If that’s not the case, then a new stack frame is made and it skips straight to the start of the called function.
However, if the number of arguments in the call is different from the number of arguments that the function takes, like in the case where there is a default argument, then the CALL instead becomes a CALL_PY_GENERAL. This instruction also, at the end, skips straight to the start of the called function. But, it omits the part where it checks that it isn’t running out of room. So, it ends up never checking the recursion limit at all, and the RecursionError isn’t raised.