The place where random ideas get written down and lost in time.
2025-04-10 - CircuitPython on ESP32: “pystack exhausted”
Category DEVWell, that escalated quickly.
I’ve been battling with this in Distant Signal:
@@ MQTT: Failed with pystack exhausted
Traceback (most recent call last):
File "code.py", line 498, in <module>
File "code.py", line 354, in _mqtt_loop
File "adafruit_minimqtt/adafruit_minimqtt.py", line 956, in loop
File "adafruit_minimqtt/adafruit_minimqtt.py", line 1027, in _wait_for_msg
File "adafruit_minimqtt/adafruit_minimqtt.py", line 381, in _handle_on_message
File "code.py", line 328, in _mqtt_on_message
File "script_loader.py", line 27, in newScript
File "script_parser.py", line 316, in parseJson
File "script_parser.py", line 289, in _parseGroup
File "script_parser.py", line 214, in _parseInstructions
File "script_parser.py", line 278, in _parseInstructions
File "adafruit_display_text/label.py", line 88, in __init__
File "adafruit_display_text/__init__.py", line 273, in __init__
File "adafruit_display_text/__init__.py", line 294, in _get_ascent_descent
RuntimeError: pystack exhausted
It turns out that CircuitPython is built with a max stack depth of 15-16 calls (*), at least for this version of the MatrixPortal S3.
It’s entirely possible to rebuild it with a different stack depth, if one is inclined to do so.
The stack size is controlled by CIRCUITPY_PYSTACK_SIZE which is defined globally here:
https://github.com/adafruit/circuitpython/blob/HEAD/py/circuitpy_mpconfig.h#L455
and is also architecture dependent -- e.g. the SAM arch overrides the default.
Documentation on CIRCUITPY_PYSTACK_SIZE is here:
https://github.com/adafruit/circuitpython/blob/HEAD/docs/environment.rst#circuitpy_pystack_size
(*) As can be seen above, the “stack size” is not really a number of calls but really a size in bytes. Thus I expect calls with more arguments will exhaust the stack faster, or whatever else needs to be pushed on the stack for each call.
At runtime, the “ustack” module can give the current max and used size:
https://docs.circuitpython.org/en/latest/shared-bindings/ustack/index.html
Well, not on the MatrixPortal S3 port at least.
So how does one solve that? Since I do not want to rebuild CircuitPython, it means I can just amend the code to reduce stack usage.
For example the main starts with this:
if __name__ == "__main__":
setup()
loop()
This conveniently mimics an Arduino sketch setup. But we don’t need that “loop()” function. Just place everything under the “if main” and save one call stack entry.
The MQTT error above happened when a message was received -- I’d parse the script right there in the MQTT handler. Instead, what I do is save the script in a temporary global variable, and process it from the main loop, after the MQTT handler has returned. That way we’re “saving” about 5 calls depth on the stack.
The parser is about 5 calls deep. I could probably save one call in there, if I really have to.
Another easy strategy is to create lambdas, and queue them for processing in the main loop.
That’s more or less what I do with the delayed processing of the script, except since there’s only one thing to do, I keep the string reference around rather than wrap the processing in a lambda. Delayed processing can quickly become hard to follow, it’s harder to understand and debug.