https://github.com/python/cpython/commit/1441e51dbc4ae92566c703b17daf7f99a3e371bc
commit: 1441e51dbc4ae92566c703b17daf7f99a3e371bc
branch: main
author: tonghuaroot (童话) <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-10-05T15:37:06+01:00
summary:

gh-156545: Fix flamegraph export RecursionError on deeply recursive programs 
(#156546)

* gh-156545: Fix flamegraph export RecursionError on deeply recursive programs

* Tighten comments

* gh-156545: Verify recursion limit restoration during export

---------

Co-authored-by: Pablo Galindo Salgado <[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]

Reply via email to