@@ -863,3 +863,98 @@ def test_async_aware_running_sees_only_cpu_task(self):
863863 self .assertGreater (cpu_percentage , 90.0 ,
864864 f"cpu_leaf should dominate samples in 'running' mode, "
865865 f"got { cpu_percentage :.1f} % ({ cpu_leaf_samples } /{ total } )" )
866+
867+
868+ def _generate_deep_generators_script (chain_depth = 20 , recurse_depth = 150 ):
869+ """Generate a script with deep nested generators for stress testing."""
870+ lines = [
871+ 'import sys' ,
872+ 'sys.setrecursionlimit(5000)' ,
873+ '' ,
874+ ]
875+ # Generate chain of yield-from functions
876+ for i in range (chain_depth - 1 ):
877+ lines .extend ([
878+ f'def deep_yield_chain_{ i } (n):' ,
879+ f' yield ("L{ i } ", n)' ,
880+ f' yield from deep_yield_chain_{ i + 1 } (n)' ,
881+ '' ,
882+ ])
883+ # Last chain function calls recursive_diver
884+ lines .extend ([
885+ f'def deep_yield_chain_{ chain_depth - 1 } (n):' ,
886+ f' yield ("L{ chain_depth - 1 } ", n)' ,
887+ f' yield from recursive_diver(n, { chain_depth } )' ,
888+ '' ,
889+ 'def recursive_diver(n, depth):' ,
890+ ' yield (f"DIVE_{depth}", n)' ,
891+ f' if depth < { recurse_depth } :' ,
892+ ' yield from recursive_diver(n, depth + 1)' ,
893+ ' else:' ,
894+ ' for i in range(5):' ,
895+ ' yield (f"BOTTOM_{depth}", i)' ,
896+ '' ,
897+ 'def oscillating_generator(iterations=1000):' ,
898+ ' for i in range(iterations):' ,
899+ ' yield ("OSCILLATE", i)' ,
900+ ' yield from deep_yield_chain_0(i)' ,
901+ '' ,
902+ 'def run_forever():' ,
903+ ' while True:' ,
904+ ' for _ in oscillating_generator(10):' ,
905+ ' pass' ,
906+ '' ,
907+ '_test_sock.sendall(b"working")' ,
908+ 'run_forever()' ,
909+ ])
910+ return '\n ' .join (lines )
911+
912+
913+ @requires_remote_subprocess_debugging ()
914+ class TestDeepGeneratorFrameCache (unittest .TestCase ):
915+ """Test frame cache consistency with deep oscillating generator stacks."""
916+
917+ def test_all_stacks_share_same_base_frame (self ):
918+ """Verify all sampled stacks reach the entry point function.
919+
920+ When profiling deep generators that oscillate up and down the call
921+ stack, every sample should include the entry point function
922+ (run_forever) in its call chain. If the frame cache stores
923+ incomplete stacks, some samples will be missing this base function,
924+ causing broken flamegraphs.
925+ """
926+ script = _generate_deep_generators_script ()
927+ with test_subprocess (script , wait_for_working = True ) as subproc :
928+ collector = CollapsedStackCollector (sample_interval_usec = 1 , skip_idle = False )
929+
930+ with (
931+ io .StringIO () as captured_output ,
932+ mock .patch ("sys.stdout" , captured_output ),
933+ ):
934+ profiling .sampling .sample .sample (
935+ subproc .process .pid ,
936+ collector ,
937+ duration_sec = 2 ,
938+ )
939+
940+ samples_with_entry_point = 0
941+ samples_without_entry_point = 0
942+ total_samples = 0
943+
944+ for (call_tree , _thread_id ), count in collector .stack_counter .items ():
945+ total_samples += count
946+ if call_tree :
947+ has_entry_point = call_tree and call_tree [0 ][2 ] == "<module>"
948+ if has_entry_point :
949+ samples_with_entry_point += count
950+ else :
951+ samples_without_entry_point += count
952+
953+ self .assertGreater (total_samples , 100 ,
954+ f"Expected at least 100 samples, got { total_samples } " )
955+
956+ self .assertEqual (samples_without_entry_point , 0 ,
957+ f"Found { samples_without_entry_point } /{ total_samples } samples "
958+ f"missing the entry point function 'run_forever'. This indicates "
959+ f"incomplete stacks are being returned, likely due to frame cache "
960+ f"storing partial stack traces." )
0 commit comments