diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 8de460856666d7..d1e99e2651564d 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -67,6 +67,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) @@ -163,34 +167,41 @@ 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: + # export() and json.dumps() recurse 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 069a72eb1c88a6..6ace415395c4f4 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,25 @@ 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. + 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 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.