@@ -148,6 +148,11 @@ def tearDown(self):
148148
149149 def create_binary_file (self , samples , interval = 1000 , compression = "none" ):
150150 """Create a test binary file and track it for cleanup."""
151+ filename , _ = self .write_binary_file (samples , interval , compression )
152+ return filename
153+
154+ def write_binary_file (self , samples , interval = 1000 , compression = "none" ):
155+ """Like create_binary_file but also returns the writer collector."""
151156 with tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False ) as f :
152157 filename = f .name
153158 self .temp_files .append (filename )
@@ -158,7 +163,7 @@ def create_binary_file(self, samples, interval=1000, compression="none"):
158163 for sample in samples :
159164 collector .collect (sample )
160165 collector .export (None )
161- return filename
166+ return filename , collector
162167
163168 def roundtrip (self , samples , interval = 1000 , compression = "none" ):
164169 """Write samples to binary and read back."""
@@ -805,6 +810,133 @@ def test_invalid_file_path(self):
805810 with BinaryReader ("/nonexistent/path/file.bin" ) as reader :
806811 reader .replay_samples (RawCollector ())
807812
813+ def test_writer_handles_empty_stack_first_sample (self ):
814+ """BinaryWriter.write_sample tolerates an empty stack on a fresh thread.
815+
816+ Regression test for the C-level RLE bug in process_thread_sample: a
817+ freshly-created ThreadEntry has prev_stack_depth == 0, so an empty
818+ curr_stack compares as STACK_REPEAT against the zero-initialized
819+ previous stack. Before the fix, this fell through the
820+ `&& !is_new_thread` guard into write_sample_with_encoding, which had
821+ no handler for STACK_REPEAT and raised
822+ RuntimeError("Invalid stack encoding type"). Goes through
823+ BinaryWriter.write_sample directly so the test cannot be masked by
824+ any Python-level filtering.
825+ """
826+ with tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False ) as f :
827+ filename = f .name
828+ self .temp_files .append (filename )
829+
830+ writer = _remote_debugging .BinaryWriter (filename , 1000 , 0 , compression = 0 )
831+ empty_sample = [
832+ make_interpreter (
833+ 0 , [make_thread (99 , [], status = THREAD_STATUS_UNKNOWN )]
834+ )
835+ ]
836+ # First sample for a fresh thread has empty frame_info — the exact
837+ # scenario that exposes the bug.
838+ writer .write_sample (empty_sample , 1000 )
839+ writer .write_sample (empty_sample , 2000 )
840+ # Mix in a real sample to exercise the transition out of the
841+ # empty-stack RLE buffer.
842+ real_sample = [
843+ make_interpreter (0 , [make_thread (1 , [make_frame ("a.py" , 1 , "f" )])])
844+ ]
845+ writer .write_sample (real_sample , 3000 )
846+ writer .finalize ()
847+
848+ reader_collector = RawCollector ()
849+ with BinaryReader (filename ) as reader :
850+ count = reader .replay_samples (reader_collector )
851+ # Empty-stack samples are recorded as STACK_REPEAT records with
852+ # depth-0 stacks; the file must replay all three samples.
853+ self .assertEqual (count , 3 )
854+
855+ def test_writer_handles_mixed_empty_and_real_first_sample (self ):
856+ """First sample with one empty + one real thread roundtrips through C."""
857+ with tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False ) as f :
858+ filename = f .name
859+ self .temp_files .append (filename )
860+
861+ writer = _remote_debugging .BinaryWriter (filename , 1000 , 0 , compression = 0 )
862+ sample = [
863+ make_interpreter (
864+ 0 ,
865+ [
866+ make_thread (1 , [make_frame ("a.py" , 1 , "f" )]),
867+ make_thread (99 , [], status = THREAD_STATUS_UNKNOWN ),
868+ ],
869+ )
870+ ]
871+ # Two samples so RLE state is exercised.
872+ writer .write_sample (sample , 1000 )
873+ writer .write_sample (sample , 2000 )
874+ writer .finalize ()
875+
876+ # Replay must succeed without raising RuntimeError, and the real
877+ # thread's frames must round-trip.
878+ reader_collector = RawCollector ()
879+ with BinaryReader (filename ) as reader :
880+ reader .replay_samples (reader_collector )
881+ self .assertIn ((0 , 1 ), reader_collector .by_thread )
882+ self .assertEqual (len (reader_collector .by_thread [(0 , 1 )]), 2 )
883+
884+ def test_writer_total_samples_after_finalize_matches_reader (self ):
885+ """BinaryWriter.total_samples after finalize() matches the reader's count."""
886+ # Five IDENTICAL samples force every sample beyond the first into the
887+ # per-thread RLE buffer. Regression for the cached_total_samples
888+ # ordering bug: capturing the cache BEFORE binary_writer_finalize()
889+ # missed the buffered samples that flush_pending_rle() counts. Keep
890+ # the samples identical to preserve coverage. See gh-149342.
891+ samples = [
892+ [make_interpreter (0 , [make_thread (1 , [make_frame ("a.py" , 1 , "f" )])])]
893+ ] * 5
894+ filename , writer_collector = self .write_binary_file (samples )
895+ reader_collector = RawCollector ()
896+ with BinaryReader (filename ) as reader :
897+ replayed = reader .replay_samples (reader_collector )
898+ self .assertEqual (writer_collector .total_samples , len (samples ))
899+ self .assertEqual (writer_collector .total_samples , replayed )
900+
901+ def test_writer_total_samples_after_context_manager_matches_reader (self ):
902+ """total_samples after `with BinaryWriter(...)` matches the reader's count.
903+
904+ Regression for the asymmetry between finalize() and __exit__ in
905+ module.c: __exit__ also calls binary_writer_finalize and must
906+ preserve cached_total_samples like finalize() does, otherwise the
907+ getter returns 0 once self->writer is NULL.
908+ """
909+ with tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False ) as f :
910+ filename = f .name
911+ self .temp_files .append (filename )
912+
913+ sample = [
914+ make_interpreter (0 , [make_thread (1 , [make_frame ("a.py" , 1 , "f" )])])
915+ ]
916+ with _remote_debugging .BinaryWriter (filename , 1000 , 0 , compression = 0 ) as w :
917+ for i in range (5 ):
918+ w .write_sample (sample , i * 1000 )
919+ self .assertEqual (w .total_samples , 5 )
920+
921+ reader_collector = RawCollector ()
922+ with BinaryReader (filename ) as reader :
923+ self .assertEqual (reader .replay_samples (reader_collector ), 5 )
924+
925+ def test_writer_total_samples_after_close_returns_zero (self ):
926+ """close() discards data; total_samples reflects no cached count."""
927+ with tempfile .NamedTemporaryFile (suffix = ".bin" , delete = False ) as f :
928+ filename = f .name
929+ self .temp_files .append (filename )
930+
931+ w = _remote_debugging .BinaryWriter (filename , 1000 , 0 , compression = 0 )
932+ sample = [
933+ make_interpreter (0 , [make_thread (1 , [make_frame ("a.py" , 1 , "f" )])])
934+ ]
935+ for i in range (5 ):
936+ w .write_sample (sample , i * 1000 )
937+ w .close ()
938+ self .assertEqual (w .total_samples , 0 )
939+
808940
809941class TestBinaryEncodings (BinaryFormatTestBase ):
810942 """Tests specifically targeting different stack encodings."""
0 commit comments