Skip to content

Commit 23f5f75

Browse files
committed
gh-156545: Fix flamegraph export RecursionError on deeply recursive programs
1 parent d915492 commit 23f5f75

3 files changed

Lines changed: 61 additions & 23 deletions

File tree

‎Lib/profiling/sampling/stack_collector.py‎

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def export(self, filename):
6767
return True
6868

6969

70+
# Extra recursion budget for the recursive flamegraph tree walk during export,
71+
# large enough to cover the unwinder's maximum captured stack depth.
72+
_FLAMEGRAPH_RECURSION_MARGIN = 2000
73+
74+
7075
class FlamegraphCollector(StackTraceCollector):
7176
def __init__(self, *args, **kwargs):
7277
super().__init__(*args, **kwargs)
@@ -163,34 +168,44 @@ def set_replay_stats(self, info):
163168
)
164169

165170
def export(self, filename):
166-
flamegraph_data = self._convert_to_flamegraph_format()
167-
168-
# Debug output with string table statistics
169-
num_functions = len(flamegraph_data.get("children", []))
170-
total_time = flamegraph_data.get("value", 0)
171-
string_count = len(self._string_table)
172-
s1 = "" if num_functions == 1 else "s"
173-
s2 = "" if total_time == 1 else "s"
174-
s3 = "" if string_count == 1 else "s"
175-
print(
176-
f"Flamegraph data: {num_functions} root function{s1}, "
177-
f"{total_time} total sample{s2}, "
178-
f"{string_count} unique string{s3}"
179-
)
180-
181-
if num_functions == 0:
171+
# The flamegraph tree is as deep as the deepest sampled stack and is
172+
# walked recursively here (and by json.dumps), so raise the recursion
173+
# limit while exporting. The remote unwinder caps stack depth at
174+
# MAX_FRAMES, so this margin is bounded.
175+
old_limit = sys.getrecursionlimit()
176+
sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN)
177+
try:
178+
flamegraph_data = self._convert_to_flamegraph_format()
179+
180+
# Debug output with string table statistics
181+
num_functions = len(flamegraph_data.get("children", []))
182+
total_time = flamegraph_data.get("value", 0)
183+
string_count = len(self._string_table)
184+
s1 = "" if num_functions == 1 else "s"
185+
s2 = "" if total_time == 1 else "s"
186+
s3 = "" if string_count == 1 else "s"
182187
print(
183-
"Warning: No functions found in profiling data. Check if sampling captured any data."
188+
f"Flamegraph data: {num_functions} root function{s1}, "
189+
f"{total_time} total sample{s2}, "
190+
f"{string_count} unique string{s3}"
184191
)
185-
return False
186192

187-
html_content = self._create_flamegraph_html(flamegraph_data)
193+
if num_functions == 0:
194+
print(
195+
"Warning: No functions found in profiling data. "
196+
"Check if sampling captured any data."
197+
)
198+
return False
188199

189-
with open(filename, "w", encoding="utf-8") as f:
190-
f.write(html_content)
200+
html_content = self._create_flamegraph_html(flamegraph_data)
191201

192-
print(f"Flamegraph saved to: {filename}")
193-
return True
202+
with open(filename, "w", encoding="utf-8") as f:
203+
f.write(html_content)
204+
205+
print(f"Flamegraph saved to: {filename}")
206+
return True
207+
finally:
208+
sys.setrecursionlimit(old_limit)
194209

195210
@staticmethod
196211
@functools.lru_cache(maxsize=None)

‎Lib/test/test_profiling/test_sampling_profiler/test_collectors.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,26 @@ def test_flamegraph_collector_empty_export_fails(self):
588588
self.assertFalse(export_ok)
589589
self.assertEqual(os.path.getsize(flamegraph_out.name), 0)
590590

591+
def test_flamegraph_deep_stack_export(self):
592+
"""A deep stack must export instead of raising RecursionError."""
593+
flamegraph_out = tempfile.NamedTemporaryFile(
594+
suffix=".html", delete=False
595+
)
596+
self.addCleanup(close_and_unlink, flamegraph_out)
597+
598+
collector = FlamegraphCollector(1000)
599+
# Deeper than the default recursion limit; the tree walk in export()
600+
# used to blow up here.
601+
frames = [MockFrameInfo("f.py", i + 1, f"f{i}") for i in range(1536)]
602+
collector.collect(
603+
[MockInterpreterInfo(0, [MockThreadInfo(1, frames)])])
604+
605+
with captured_stdout(), captured_stderr():
606+
export_ok = collector.export(flamegraph_out.name)
607+
608+
self.assertTrue(export_ok)
609+
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
610+
591611
def test_gecko_collector_basic(self):
592612
"""Test basic GeckoCollector functionality."""
593613
collector = GeckoCollector(1000)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix the sampling profiler's flamegraph export so that profiling a deeply
2+
recursive program no longer fails with :exc:`RecursionError` instead of
3+
producing a flamegraph. Patch by tonghuaroot.

0 commit comments

Comments
 (0)