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,9 +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-
628663 collector = GeckoCollector (1000 )
629664
630665 test_frames1 = [
@@ -657,17 +692,7 @@ def test_gecko_collector_export(self):
657692 collector .collect (test_frames2 )
658693 collector .collect (test_frames3 )
659694
660- # Export gecko profile
661- with captured_stdout (), captured_stderr ():
662- collector .export (gecko_out .name )
663-
664- # Verify file was created and contains valid data
665- self .assertTrue (os .path .exists (gecko_out .name ))
666- self .assertGreater (os .path .getsize (gecko_out .name ), 0 )
667-
668- # Check file contains valid JSON
669- with open (gecko_out .name , "r" ) as f :
670- profile_data = json .load (f )
695+ profile_data = export_gecko_profile (self , collector )
671696
672697 # Should be valid Gecko profile format
673698 self .assertIn ("meta" , profile_data )
@@ -688,6 +713,100 @@ def test_gecko_collector_export(self):
688713 self .assertIn ("func2" , string_array )
689714 self .assertIn ("other_func" , string_array )
690715
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+
691810 def test_gecko_collector_markers (self ):
692811 """Test Gecko profile markers for GIL and CPU state tracking."""
693812 collector = GeckoCollector (1000 )
@@ -771,21 +890,16 @@ def test_gecko_collector_markers(self):
771890 self .assertIn ("markers" , thread_data )
772891 markers = thread_data ["markers" ]
773892
774- # Should have marker arrays
775- self .assertIn ("name" , markers )
776- self .assertIn ("startTime" , markers )
777- self .assertIn ("endTime" , markers )
778- self .assertIn ("category" , markers )
779893 self .assertGreater (
780894 markers ["length" ], 0 , "Should have generated markers"
781895 )
782-
783- # Get marker names from string table
784- string_array = profile_data [ "shared" ][ "stringArray" ]
785- 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+ )
786900
787901 # Verify we have different marker types
788- marker_name_set = set (marker_names )
902+ marker_name_set = set (gecko_marker_names ( profile_data , markers ) )
789903
790904 # Should have "Has GIL" markers (when thread had GIL)
791905 self .assertIn (
@@ -2659,6 +2773,7 @@ def test_gecko_collector_opcodes_enabled(self):
26592773 def test_gecko_opcode_state_tracking (self ):
26602774 """Test that GeckoCollector tracks opcode state changes."""
26612775 collector = GeckoCollector (sample_interval_usec = 1000 , opcodes = True )
2776+ self .addCleanup (collector ._cleanup_spills )
26622777
26632778 # First sample with opcode 90 (RAISE_VARARGS)
26642779 frame1 = MockFrameInfo ("test.py" , 10 , "func" , opcode = 90 )
@@ -2702,10 +2817,28 @@ def test_gecko_opcode_state_change_emits_marker(self):
27022817 collector .collect (frames2 )
27032818
27042819 # Should have emitted a marker for the first opcode
2705- thread_data = collector .threads [1 ]
2706- markers = thread_data ["markers" ]
2707- # At least one marker should have been added
2708- self .assertGreater (len (markers ["name" ]), 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+ )
27092842
27102843 def test_gecko_opcode_markers_not_emitted_when_disabled (self ):
27112844 """Test that no opcode markers when opcodes=False."""
@@ -2729,8 +2862,9 @@ def test_gecko_opcode_markers_not_emitted_when_disabled(self):
27292862 ]
27302863 collector .collect (frames2 )
27312864
2732- # opcode_state should not be tracked
2733- 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" ], [])
27342868
27352869 def test_gecko_opcode_with_none_opcode (self ):
27362870 """Test that None opcode doesn't cause issues."""
@@ -2746,9 +2880,8 @@ def test_gecko_opcode_with_none_opcode(self):
27462880 ]
27472881 collector .collect (frames )
27482882
2749- # Should track the state but opcode is None
2750- self .assertIn (1 , collector .opcode_state )
2751- self .assertIsNone (collector .opcode_state [1 ][0 ])
2883+ profile = collector ._build_profile ()
2884+ self .assertEqual (gecko_opcode_marker_data (profile ), [])
27522885
27532886
27542887class TestCollectorFrameFormat (unittest .TestCase ):
0 commit comments