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/2] 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/2] 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)])])