1111
1212try :
1313 import _remote_debugging # noqa: F401
14+ from profiling .sampling import gecko_collector
1415 from profiling .sampling .pstats_collector import PstatsCollector
1516 from profiling .sampling .stack_collector import (
1617 CollapsedStackCollector ,
@@ -59,6 +60,42 @@ def find_child_by_name(children, strings, substr):
5960 return None
6061
6162
63+ def export_gecko_profile (testcase , collector ):
64+ gecko_out = tempfile .NamedTemporaryFile (suffix = ".json" , delete = False )
65+ testcase .addCleanup (close_and_unlink , gecko_out )
66+ # We cannot overwrite an open file on Windows.
67+ gecko_out .close ()
68+
69+ with captured_stdout (), captured_stderr ():
70+ collector .export (gecko_out .name )
71+
72+ testcase .assertGreater (os .path .getsize (gecko_out .name ), 0 )
73+ with open (gecko_out .name , encoding = "utf-8" ) as file :
74+ return json .load (file )
75+
76+
77+ def assert_gecko_column_lengths (testcase , table , columns ):
78+ expected = table ["length" ]
79+ for column in columns :
80+ testcase .assertEqual (
81+ len (table [column ]), expected ,
82+ f"{ column !r} has wrong length" ,
83+ )
84+
85+
86+ def gecko_marker_names (profile , markers ):
87+ string_array = profile ["shared" ]["stringArray" ]
88+ return [string_array [idx ] for idx in markers ["name" ]]
89+
90+
91+ def gecko_opcode_marker_data (profile ):
92+ markers = profile ["threads" ][0 ]["markers" ]
93+ return [
94+ data for data in markers ["data" ]
95+ if data .get ("type" ) == "Opcode"
96+ ]
97+
98+
6299class TestSampleProfilerComponents (unittest .TestCase ):
63100 """Unit tests for individual profiler components."""
64101
@@ -583,9 +620,10 @@ def test_gecko_collector_basic(self):
583620
584621 # Verify samples
585622 samples = thread_data ["samples" ]
586- self .assertEqual (len (samples ["stack" ]), 1 )
587- self .assertEqual (len (samples ["time" ]), 1 )
588623 self .assertEqual (samples ["length" ], 1 )
624+ assert_gecko_column_lengths (
625+ self , samples , ("stack" , "time" , "eventDelay" )
626+ )
589627
590628 # Verify function table structure and content
591629 func_table = thread_data ["funcTable" ]
@@ -622,11 +660,6 @@ def test_gecko_collector_basic(self):
622660 @unittest .skipIf (is_emscripten , "threads not available" )
623661 def test_gecko_collector_export (self ):
624662 """Test Gecko profile export functionality."""
625- gecko_out = tempfile .NamedTemporaryFile (suffix = ".json" , delete = False )
626- self .addCleanup (close_and_unlink , gecko_out )
627- # We cannot overwrite an open file on Windows.
628- gecko_out .close ()
629-
630663 collector = GeckoCollector (1000 )
631664
632665 test_frames1 = [
@@ -659,17 +692,7 @@ def test_gecko_collector_export(self):
659692 collector .collect (test_frames2 )
660693 collector .collect (test_frames3 )
661694
662- # Export gecko profile
663- with captured_stdout (), captured_stderr ():
664- collector .export (gecko_out .name )
665-
666- # Verify file was created and contains valid data
667- self .assertTrue (os .path .exists (gecko_out .name ))
668- self .assertGreater (os .path .getsize (gecko_out .name ), 0 )
669-
670- # Check file contains valid JSON
671- with open (gecko_out .name , "r" ) as f :
672- profile_data = json .load (f )
695+ profile_data = export_gecko_profile (self , collector )
673696
674697 # Should be valid Gecko profile format
675698 self .assertIn ("meta" , profile_data )
@@ -690,6 +713,100 @@ def test_gecko_collector_export(self):
690713 self .assertIn ("func2" , string_array )
691714 self .assertIn ("other_func" , string_array )
692715
716+ thread_data = profile_data ["threads" ][0 ]
717+ assert_gecko_column_lengths (
718+ self , thread_data ["samples" ], ("stack" , "time" , "eventDelay" )
719+ )
720+
721+ @unittest .skipIf (is_emscripten , "threads not available" )
722+ def test_gecko_collector_export_after_spill_flush (self ):
723+ """Test Gecko profile export after spill buffers flush to disk."""
724+ old_buffer_bytes = gecko_collector .DEFAULT_SPILL_BUFFER_BYTES
725+ gecko_collector .DEFAULT_SPILL_BUFFER_BYTES = 1
726+ self .addCleanup (
727+ setattr , gecko_collector , "DEFAULT_SPILL_BUFFER_BYTES" ,
728+ old_buffer_bytes
729+ )
730+
731+ collector = GeckoCollector (1000 )
732+ test_frames = [
733+ MockInterpreterInfo (
734+ 0 ,
735+ [
736+ MockThreadInfo (
737+ 1 ,
738+ [MockFrameInfo ("file.py" , 10 , "func" )],
739+ status = THREAD_STATUS_HAS_GIL ,
740+ )
741+ ],
742+ )
743+ ]
744+ collector .collect (test_frames , timestamps_us = [1000 , 2000 , 3000 ])
745+
746+ profile_data = export_gecko_profile (self , collector )
747+ samples = profile_data ["threads" ][0 ]["samples" ]
748+ self .assertEqual (samples ["length" ], 3 )
749+ assert_gecko_column_lengths (
750+ self , samples , ("stack" , "time" , "eventDelay" )
751+ )
752+
753+ @unittest .skipIf (is_emscripten , "threads not available" )
754+ def test_gecko_collector_rejects_collect_after_export (self ):
755+ collector = GeckoCollector (1000 )
756+ test_frames = [
757+ MockInterpreterInfo (
758+ 0 ,
759+ [
760+ MockThreadInfo (
761+ 1 ,
762+ [MockFrameInfo ("file.py" , 10 , "func" )],
763+ status = THREAD_STATUS_HAS_GIL ,
764+ )
765+ ],
766+ )
767+ ]
768+ collector .collect (test_frames )
769+ export_gecko_profile (self , collector )
770+
771+ with self .assertRaisesRegex (RuntimeError , "after export" ):
772+ collector .collect (test_frames )
773+
774+ @unittest .skipIf (is_emscripten , "threads not available" )
775+ def test_gecko_collector_export_failure_keeps_existing_file (self ):
776+ collector = GeckoCollector (1000 )
777+ test_frames = [
778+ MockInterpreterInfo (
779+ 0 ,
780+ [
781+ MockThreadInfo (
782+ 1 ,
783+ [MockFrameInfo ("file.py" , 10 , "func" )],
784+ status = THREAD_STATUS_HAS_GIL ,
785+ )
786+ ],
787+ )
788+ ]
789+ collector .collect (test_frames )
790+
791+ with tempfile .TemporaryDirectory () as temp_dir :
792+ filename = os .path .join (temp_dir , "profile.json" )
793+ with open (filename , "w" , encoding = "utf-8" ) as file :
794+ file .write ("existing" )
795+
796+ before = set (os .listdir (temp_dir ))
797+
798+ def fail (file ):
799+ raise OSError ("boom" )
800+
801+ collector ._stream_profile = fail
802+ with captured_stdout (), captured_stderr ():
803+ with self .assertRaisesRegex (OSError , "boom" ):
804+ collector .export (filename )
805+
806+ with open (filename , encoding = "utf-8" ) as file :
807+ self .assertEqual (file .read (), "existing" )
808+ self .assertEqual (set (os .listdir (temp_dir )), before )
809+
693810 def test_gecko_collector_markers (self ):
694811 """Test Gecko profile markers for GIL and CPU state tracking."""
695812 collector = GeckoCollector (1000 )
@@ -773,21 +890,16 @@ def test_gecko_collector_markers(self):
773890 self .assertIn ("markers" , thread_data )
774891 markers = thread_data ["markers" ]
775892
776- # Should have marker arrays
777- self .assertIn ("name" , markers )
778- self .assertIn ("startTime" , markers )
779- self .assertIn ("endTime" , markers )
780- self .assertIn ("category" , markers )
781893 self .assertGreater (
782894 markers ["length" ], 0 , "Should have generated markers"
783895 )
784-
785- # Get marker names from string table
786- string_array = profile_data [ "shared" ][ "stringArray" ]
787- marker_names = [ string_array [ idx ] for idx in markers [ "name" ]]
896+ assert_gecko_column_lengths (
897+ self , markers ,
898+ ( "data" , "name" , "startTime" , "endTime" , "phase" , "category" ),
899+ )
788900
789901 # Verify we have different marker types
790- marker_name_set = set (marker_names )
902+ marker_name_set = set (gecko_marker_names ( profile_data , markers ) )
791903
792904 # Should have "Has GIL" markers (when thread had GIL)
793905 self .assertIn (
@@ -2661,7 +2773,7 @@ def test_gecko_collector_opcodes_enabled(self):
26612773 def test_gecko_opcode_state_tracking (self ):
26622774 """Test that GeckoCollector tracks opcode state changes."""
26632775 collector = GeckoCollector (sample_interval_usec = 1000 , opcodes = True )
2664- self .addCleanup (lambda : collector .spill_dir . cleanup () )
2776+ self .addCleanup (collector ._cleanup_spills )
26652777
26662778 # First sample with opcode 90 (RAISE_VARARGS)
26672779 frame1 = MockFrameInfo ("test.py" , 10 , "func" , opcode = 90 )
@@ -2683,7 +2795,6 @@ def test_gecko_opcode_state_tracking(self):
26832795 def test_gecko_opcode_state_change_emits_marker (self ):
26842796 """Test that opcode state change emits an interval marker."""
26852797 collector = GeckoCollector (sample_interval_usec = 1000 , opcodes = True )
2686- self .addCleanup (lambda : collector .spill_dir .cleanup ())
26872798
26882799 # First sample: opcode 90
26892800 frame1 = MockFrameInfo ("test.py" , 10 , "func" , opcode = 90 )
@@ -2706,12 +2817,32 @@ def test_gecko_opcode_state_change_emits_marker(self):
27062817 collector .collect (frames2 )
27072818
27082819 # Should have emitted a marker for the first opcode
2709- self .assertGreater (collector .thread_spills [1 ].marker_count , 0 )
2820+ profile = collector ._build_profile ()
2821+ markers = profile ["threads" ][0 ]["markers" ]
2822+ assert_gecko_column_lengths (
2823+ self , markers ,
2824+ ("data" , "name" , "startTime" , "endTime" , "phase" , "category" ),
2825+ )
2826+ opcode_markers = gecko_opcode_marker_data (profile )
2827+ self .assertIn (
2828+ {
2829+ "opcode" : 90 ,
2830+ "line" : 10 ,
2831+ "function" : "func" ,
2832+ },
2833+ [
2834+ {
2835+ "opcode" : marker ["opcode" ],
2836+ "line" : marker ["line" ],
2837+ "function" : marker ["function" ],
2838+ }
2839+ for marker in opcode_markers
2840+ ],
2841+ )
27102842
27112843 def test_gecko_opcode_markers_not_emitted_when_disabled (self ):
27122844 """Test that no opcode markers when opcodes=False."""
27132845 collector = GeckoCollector (sample_interval_usec = 1000 , opcodes = False )
2714- self .addCleanup (lambda : collector .spill_dir .cleanup ())
27152846
27162847 frame1 = MockFrameInfo ("test.py" , 10 , "func" , opcode = 90 )
27172848 frames1 = [
@@ -2731,13 +2862,13 @@ def test_gecko_opcode_markers_not_emitted_when_disabled(self):
27312862 ]
27322863 collector .collect (frames2 )
27332864
2734- # opcode_state should not be tracked
2735- self .assertEqual (len (collector .opcode_state ), 0 )
2865+ profile = collector ._build_profile ()
2866+ self .assertEqual (gecko_opcode_marker_data (profile ), [])
2867+ self .assertEqual (profile ["meta" ]["markerSchema" ], [])
27362868
27372869 def test_gecko_opcode_with_none_opcode (self ):
27382870 """Test that None opcode doesn't cause issues."""
27392871 collector = GeckoCollector (sample_interval_usec = 1000 , opcodes = True )
2740- self .addCleanup (lambda : collector .spill_dir .cleanup ())
27412872
27422873 # Frame with no opcode (None)
27432874 frame = MockFrameInfo ("test.py" , 10 , "func" , opcode = None )
@@ -2749,9 +2880,8 @@ def test_gecko_opcode_with_none_opcode(self):
27492880 ]
27502881 collector .collect (frames )
27512882
2752- # Should track the state but opcode is None
2753- self .assertIn (1 , collector .opcode_state )
2754- self .assertIsNone (collector .opcode_state [1 ][0 ])
2883+ profile = collector ._build_profile ()
2884+ self .assertEqual (gecko_opcode_marker_data (profile ), [])
27552885
27562886
27572887class TestCollectorFrameFormat (unittest .TestCase ):
0 commit comments