@@ -85,7 +85,7 @@ def test_pstats_collector_with_extreme_intervals_and_empty_data(self):
8585 # Should still process the frames
8686 self .assertEqual (len (collector .result ), 1 )
8787
88- # Test collecting duplicate frames in same sample
88+ # Test collecting duplicate frames in same sample (recursive function)
8989 test_frames = [
9090 MockInterpreterInfo (
9191 0 , # interpreter_id
@@ -94,17 +94,17 @@ def test_pstats_collector_with_extreme_intervals_and_empty_data(self):
9494 1 ,
9595 [
9696 MockFrameInfo ("file.py" , 10 , "func1" ),
97- MockFrameInfo ("file.py" , 10 , "func1" ), # Duplicate
97+ MockFrameInfo ("file.py" , 10 , "func1" ), # Duplicate (recursion)
9898 ],
9999 )
100100 ],
101101 )
102102 ]
103103 collector = PstatsCollector (sample_interval_usec = 1000 )
104104 collector .collect (test_frames )
105- # Should count both occurrences
105+ # Should count only once per sample to avoid over-counting recursive functions
106106 self .assertEqual (
107- collector .result [("file.py" , 10 , "func1" )]["cumulative_calls" ], 2
107+ collector .result [("file.py" , 10 , "func1" )]["cumulative_calls" ], 1
108108 )
109109
110110 def test_pstats_collector_single_frame_stacks (self ):
@@ -1201,3 +1201,194 @@ def test_flamegraph_collector_per_thread_gc_percentage(self):
12011201 self .assertEqual (collector .per_thread_stats [2 ]["gc_samples" ], 1 )
12021202 self .assertEqual (collector .per_thread_stats [2 ]["total" ], 6 )
12031203 self .assertAlmostEqual (per_thread_stats [2 ]["gc_pct" ], 10.0 , places = 1 )
1204+
1205+
1206+ class TestRecursiveFunctionHandling (unittest .TestCase ):
1207+ """Tests for correct handling of recursive functions in cumulative stats."""
1208+
1209+ def test_pstats_collector_recursive_function_single_sample (self ):
1210+ """Test that recursive functions are counted once per sample, not per occurrence."""
1211+ collector = PstatsCollector (sample_interval_usec = 1000 )
1212+
1213+ # Simulate a recursive function appearing 5 times in one sample
1214+ recursive_frames = [
1215+ MockInterpreterInfo (
1216+ 0 ,
1217+ [
1218+ MockThreadInfo (
1219+ 1 ,
1220+ [
1221+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1222+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1223+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1224+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1225+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1226+ ],
1227+ )
1228+ ],
1229+ )
1230+ ]
1231+ collector .collect (recursive_frames )
1232+
1233+ location = ("test.py" , 10 , "recursive_func" )
1234+ # Should count as 1 cumulative call (present in 1 sample), not 5
1235+ self .assertEqual (collector .result [location ]["cumulative_calls" ], 1 )
1236+ # Direct calls should be 1 (top of stack)
1237+ self .assertEqual (collector .result [location ]["direct_calls" ], 1 )
1238+
1239+ def test_pstats_collector_recursive_function_multiple_samples (self ):
1240+ """Test cumulative counting across multiple samples with recursion."""
1241+ collector = PstatsCollector (sample_interval_usec = 1000 )
1242+
1243+ # Sample 1: recursive function at depth 3
1244+ sample1 = [
1245+ MockInterpreterInfo (
1246+ 0 ,
1247+ [
1248+ MockThreadInfo (
1249+ 1 ,
1250+ [
1251+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1252+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1253+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1254+ ],
1255+ )
1256+ ],
1257+ )
1258+ ]
1259+ # Sample 2: recursive function at depth 2
1260+ sample2 = [
1261+ MockInterpreterInfo (
1262+ 0 ,
1263+ [
1264+ MockThreadInfo (
1265+ 1 ,
1266+ [
1267+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1268+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1269+ ],
1270+ )
1271+ ],
1272+ )
1273+ ]
1274+ # Sample 3: recursive function at depth 4
1275+ sample3 = [
1276+ MockInterpreterInfo (
1277+ 0 ,
1278+ [
1279+ MockThreadInfo (
1280+ 1 ,
1281+ [
1282+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1283+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1284+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1285+ MockFrameInfo ("test.py" , 10 , "recursive_func" ),
1286+ ],
1287+ )
1288+ ],
1289+ )
1290+ ]
1291+
1292+ collector .collect (sample1 )
1293+ collector .collect (sample2 )
1294+ collector .collect (sample3 )
1295+
1296+ location = ("test.py" , 10 , "recursive_func" )
1297+ # Should count as 3 cumulative calls (present in 3 samples)
1298+ # Not 3+2+4=9 which would be the buggy behavior
1299+ self .assertEqual (collector .result [location ]["cumulative_calls" ], 3 )
1300+ self .assertEqual (collector .result [location ]["direct_calls" ], 3 )
1301+
1302+ def test_pstats_collector_mixed_recursive_and_nonrecursive (self ):
1303+ """Test a call stack with both recursive and non-recursive functions."""
1304+ collector = PstatsCollector (sample_interval_usec = 1000 )
1305+
1306+ # Stack: main -> foo (recursive x3) -> bar
1307+ frames = [
1308+ MockInterpreterInfo (
1309+ 0 ,
1310+ [
1311+ MockThreadInfo (
1312+ 1 ,
1313+ [
1314+ MockFrameInfo ("test.py" , 50 , "bar" ), # top of stack
1315+ MockFrameInfo ("test.py" , 20 , "foo" ), # recursive
1316+ MockFrameInfo ("test.py" , 20 , "foo" ), # recursive
1317+ MockFrameInfo ("test.py" , 20 , "foo" ), # recursive
1318+ MockFrameInfo ("test.py" , 10 , "main" ), # bottom
1319+ ],
1320+ )
1321+ ],
1322+ )
1323+ ]
1324+ collector .collect (frames )
1325+
1326+ # bar: 1 cumulative (in stack), 1 direct (top)
1327+ self .assertEqual (collector .result [("test.py" , 50 , "bar" )]["cumulative_calls" ], 1 )
1328+ self .assertEqual (collector .result [("test.py" , 50 , "bar" )]["direct_calls" ], 1 )
1329+
1330+ # foo: 1 cumulative (counted once despite 3 occurrences), 0 direct
1331+ self .assertEqual (collector .result [("test.py" , 20 , "foo" )]["cumulative_calls" ], 1 )
1332+ self .assertEqual (collector .result [("test.py" , 20 , "foo" )]["direct_calls" ], 0 )
1333+
1334+ # main: 1 cumulative, 0 direct
1335+ self .assertEqual (collector .result [("test.py" , 10 , "main" )]["cumulative_calls" ], 1 )
1336+ self .assertEqual (collector .result [("test.py" , 10 , "main" )]["direct_calls" ], 0 )
1337+
1338+ def test_pstats_collector_cumulative_percentage_cannot_exceed_100 (self ):
1339+ """Test that cumulative percentage stays <= 100% even with deep recursion."""
1340+ collector = PstatsCollector (sample_interval_usec = 1000000 ) # 1 second for easy math
1341+
1342+ # Collect 10 samples, each with recursive function at depth 100
1343+ for _ in range (10 ):
1344+ frames = [
1345+ MockInterpreterInfo (
1346+ 0 ,
1347+ [
1348+ MockThreadInfo (
1349+ 1 ,
1350+ [MockFrameInfo ("test.py" , 10 , "deep_recursive" )] * 100 ,
1351+ )
1352+ ],
1353+ )
1354+ ]
1355+ collector .collect (frames )
1356+
1357+ location = ("test.py" , 10 , "deep_recursive" )
1358+ # Cumulative calls should be 10 (number of samples), not 1000
1359+ self .assertEqual (collector .result [location ]["cumulative_calls" ], 10 )
1360+
1361+ # Verify stats calculation gives correct percentage
1362+ collector .create_stats ()
1363+ stats = collector .stats [location ]
1364+ # stats format: (direct_calls, cumulative_calls, total_time, cumulative_time, callers)
1365+ cumulative_calls = stats [1 ]
1366+ self .assertEqual (cumulative_calls , 10 )
1367+
1368+ def test_pstats_collector_different_lines_same_function_counted_separately (self ):
1369+ """Test that different line numbers in same function are tracked separately."""
1370+ collector = PstatsCollector (sample_interval_usec = 1000 )
1371+
1372+ # Function with multiple line numbers (e.g., different call sites within recursion)
1373+ frames = [
1374+ MockInterpreterInfo (
1375+ 0 ,
1376+ [
1377+ MockThreadInfo (
1378+ 1 ,
1379+ [
1380+ MockFrameInfo ("test.py" , 15 , "func" ), # line 15
1381+ MockFrameInfo ("test.py" , 12 , "func" ), # line 12
1382+ MockFrameInfo ("test.py" , 15 , "func" ), # line 15 again
1383+ MockFrameInfo ("test.py" , 10 , "func" ), # line 10
1384+ ],
1385+ )
1386+ ],
1387+ )
1388+ ]
1389+ collector .collect (frames )
1390+
1391+ # Each unique (file, line, func) should be counted once
1392+ self .assertEqual (collector .result [("test.py" , 15 , "func" )]["cumulative_calls" ], 1 )
1393+ self .assertEqual (collector .result [("test.py" , 12 , "func" )]["cumulative_calls" ], 1 )
1394+ self .assertEqual (collector .result [("test.py" , 10 , "func" )]["cumulative_calls" ], 1 )
0 commit comments