https://github.com/python/cpython/commit/0ac721704092d8f96720c6fd1d68c5941a4a530a commit: 0ac721704092d8f96720c6fd1d68c5941a4a530a branch: 3.15 author: Pablo Galindo Salgado <[email protected]> committer: pablogsal <[email protected]> date: 2026-10-05T15:43:55+01:00 summary:
[3.15] gh-156545: Fix flamegraph export RecursionError on deeply recursive programs (GH-156546) (#158851) Backport of GH-156546. Co-authored-by: tonghuaroot (童话) <[email protected]> files: A Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst M Lib/profiling/sampling/stack_collector.py M Lib/test/test_profiling/test_sampling_profiler/test_collectors.py diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 7410d07c7e0162..e420bb6d2e9b87 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -68,6 +68,10 @@ def export(self, filename): return True +# Bounded by the unwinder's maximum captured stack depth (MAX_FRAMES). +_FLAMEGRAPH_RECURSION_MARGIN = 2000 + + class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -166,34 +170,41 @@ def set_mode(self, mode): self.stats["mode"] = mode def export(self, filename): - flamegraph_data = self._convert_to_flamegraph_format() - - # Debug output with string table statistics - num_functions = len(flamegraph_data.get("children", [])) - total_time = flamegraph_data.get("value", 0) - string_count = len(self._string_table) - s1 = "" if num_functions == 1 else "s" - s2 = "" if total_time == 1 else "s" - s3 = "" if string_count == 1 else "s" - print( - f"Flamegraph data: {num_functions} root function{s1}, " - f"{total_time} total sample{s2}, " - f"{string_count} unique string{s3}" - ) - - if num_functions == 0: + # Converting the call tree recurses to the sampled stack depth. + old_limit = sys.getrecursionlimit() + sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN) + try: + flamegraph_data = self._convert_to_flamegraph_format() + + # Debug output with string table statistics + num_functions = len(flamegraph_data.get("children", [])) + total_time = flamegraph_data.get("value", 0) + string_count = len(self._string_table) + s1 = "" if num_functions == 1 else "s" + s2 = "" if total_time == 1 else "s" + s3 = "" if string_count == 1 else "s" print( - "Warning: No functions found in profiling data. Check if sampling captured any data." + f"Flamegraph data: {num_functions} root function{s1}, " + f"{total_time} total sample{s2}, " + f"{string_count} unique string{s3}" ) - return False - html_content = self._create_flamegraph_html(flamegraph_data) + if num_functions == 0: + print( + "Warning: No functions found in profiling data. " + "Check if sampling captured any data." + ) + return False - with open(filename, "w", encoding="utf-8") as f: - f.write(html_content) + html_content = self._create_flamegraph_html(flamegraph_data) - print(f"Flamegraph saved to: {filename}") - return True + with open(filename, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Flamegraph saved to: {filename}") + return True + finally: + sys.setrecursionlimit(old_limit) @staticmethod @functools.lru_cache(maxsize=None) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 282f97e5259e09..eb58c29dd361d3 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -4,10 +4,11 @@ import marshal import opcode import os +import sys import tempfile import unittest -from test.support import is_emscripten +from test.support import is_emscripten, set_recursion_limit try: import _remote_debugging # noqa: F401 @@ -610,6 +611,38 @@ def test_flamegraph_collector_empty_export_fails(self): self.assertFalse(export_ok) self.assertEqual(os.path.getsize(flamegraph_out.name), 0) + def test_flamegraph_deep_stack_export(self): + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + collector = FlamegraphCollector(1000) + # Deeper than the default recursion limit. + frames = [MockFrameInfo("f.py", i + 1, f"f{i}") for i in range(1536)] + collector.collect( + [MockInterpreterInfo(0, [MockThreadInfo(1, frames)])]) + + with set_recursion_limit(1000), captured_stdout(), captured_stderr(): + export_ok = collector.export(flamegraph_out.name) + self.assertEqual(sys.getrecursionlimit(), 1000) + + self.assertTrue(export_ok) + self.assertGreater(os.path.getsize(flamegraph_out.name), 0) + + def test_flamegraph_export_restores_recursion_limit(self): + collector = FlamegraphCollector(1000) + frame = MockFrameInfo("f.py", 1, "f") + with set_recursion_limit(500), captured_stdout(), captured_stderr(): + self.assertFalse(collector.export(None)) + self.assertEqual(sys.getrecursionlimit(), 500) + collector.collect([ + MockInterpreterInfo(0, [MockThreadInfo(1, [ + frame, MockFrameInfo("f.py", 2, "caller")])])]) + with self.assertRaises(TypeError): + collector.export(None) + self.assertEqual(sys.getrecursionlimit(), 500) + def test_gecko_collector_basic(self): """Test basic GeckoCollector functionality.""" collector = GeckoCollector(1000) diff --git a/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst b/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst new file mode 100644 index 00000000000000..cc4f57ff42adab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst @@ -0,0 +1,3 @@ +Fix the sampling profiler's flamegraph export so that profiling a deeply +recursive program no longer fails with :exc:`RecursionError` instead of +producing a flamegraph. Patch by tonghuaroot. _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
