From 23f5f751bfeebaa4f8cc513a780be9a901704956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tonghuaroot=20=28=E7=AB=A5=E8=AF=9D=29?= Date: Sat, 29 Aug 2026 11:40:41 +0800 Subject: [PATCH 1/3] gh-156545: Fix flamegraph export RecursionError on deeply recursive programs --- Lib/profiling/sampling/stack_collector.py | 61 ++++++++++++------- .../test_sampling_profiler/test_collectors.py | 20 ++++++ ...-08-29-11-37-40.gh-issue-156545.fLaMe1.rst | 3 + 3 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 8de460856666d7f..0070e99802fbd6a 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -67,6 +67,11 @@ def export(self, filename): return True +# Extra recursion budget for the recursive flamegraph tree walk during export, +# large enough to cover the unwinder's maximum captured stack depth. +_FLAMEGRAPH_RECURSION_MARGIN = 2000 + + class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -163,34 +168,44 @@ def set_replay_stats(self, info): ) 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: + # The flamegraph tree is as deep as the deepest sampled stack and is + # walked recursively here (and by json.dumps), so raise the recursion + # limit while exporting. The remote unwinder caps stack depth at + # MAX_FRAMES, so this margin is bounded. + 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 069a72eb1c88a6a..07092a9bcecf5b5 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -588,6 +588,26 @@ 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): + """A deep stack must export instead of raising RecursionError.""" + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + collector = FlamegraphCollector(1000) + # Deeper than the default recursion limit; the tree walk in export() + # used to blow up here. + frames = [MockFrameInfo("f.py", i + 1, f"f{i}") for i in range(1536)] + collector.collect( + [MockInterpreterInfo(0, [MockThreadInfo(1, frames)])]) + + with captured_stdout(), captured_stderr(): + export_ok = collector.export(flamegraph_out.name) + + self.assertTrue(export_ok) + self.assertGreater(os.path.getsize(flamegraph_out.name), 0) + 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 000000000000000..cc4f57ff42adabb --- /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. From a1ad4cd2ee94df4a074d14efa9e2e0915b64afa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?tonghuaroot=20=28=E7=AB=A5=E8=AF=9D=29?= Date: Sat, 29 Aug 2026 11:44:59 +0800 Subject: [PATCH 2/3] Tighten comments --- Lib/profiling/sampling/stack_collector.py | 8 ++------ .../test_sampling_profiler/test_collectors.py | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 0070e99802fbd6a..d1e99e2651564d6 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -67,8 +67,7 @@ def export(self, filename): return True -# Extra recursion budget for the recursive flamegraph tree walk during export, -# large enough to cover the unwinder's maximum captured stack depth. +# Bounded by the unwinder's maximum captured stack depth (MAX_FRAMES). _FLAMEGRAPH_RECURSION_MARGIN = 2000 @@ -168,10 +167,7 @@ def set_replay_stats(self, info): ) def export(self, filename): - # The flamegraph tree is as deep as the deepest sampled stack and is - # walked recursively here (and by json.dumps), so raise the recursion - # limit while exporting. The remote unwinder caps stack depth at - # MAX_FRAMES, so this margin is bounded. + # export() and json.dumps() recurse to the sampled stack depth. old_limit = sys.getrecursionlimit() sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN) try: 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 07092a9bcecf5b5..6ace415395c4f48 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -596,8 +596,7 @@ def test_flamegraph_deep_stack_export(self): self.addCleanup(close_and_unlink, flamegraph_out) collector = FlamegraphCollector(1000) - # Deeper than the default recursion limit; the tree walk in export() - # used to blow up here. + # 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)])]) From a3f2ee8c56ba38f4fdb64447e19778c59d464ade Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:04:28 +0100 Subject: [PATCH 3/3] gh-156545: Verify recursion limit restoration during export --- Lib/profiling/sampling/stack_collector.py | 2 +- .../test_sampling_profiler/test_collectors.py | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index c6ab3f7fa8042c0..e420bb6d2e9b871 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -170,7 +170,7 @@ def set_mode(self, mode): self.stats["mode"] = mode def export(self, filename): - # export() and json.dumps() recurse to the sampled stack depth. + # Converting the call tree recurses to the sampled stack depth. old_limit = sys.getrecursionlimit() sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN) try: 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 284b49b5bb89986..eb58c29dd361d3a 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 @@ -611,7 +612,6 @@ def test_flamegraph_collector_empty_export_fails(self): self.assertEqual(os.path.getsize(flamegraph_out.name), 0) def test_flamegraph_deep_stack_export(self): - """A deep stack must export instead of raising RecursionError.""" flamegraph_out = tempfile.NamedTemporaryFile( suffix=".html", delete=False ) @@ -623,12 +623,26 @@ def test_flamegraph_deep_stack_export(self): collector.collect( [MockInterpreterInfo(0, [MockThreadInfo(1, frames)])]) - with captured_stdout(), captured_stderr(): + 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)