@@ -267,21 +267,64 @@ def test_collect_with_frames(self):
267267 self .assertEqual (collector .failed_samples , 0 )
268268
269269 def test_collect_with_empty_frames (self ):
270- """Test collect with empty frames."""
270+ """Test collect with empty frames counts as successful.
271+
272+ A sample is considered successful if the profiler could read from the
273+ target process, even if no frames matched the current filter (e.g.,
274+ --mode exception when no thread has an active exception). The sample
275+ itself worked; it just didn't produce frame data.
276+ """
271277 collector = LiveStatsCollector (1000 )
272278 thread_info = MockThreadInfo (123 , [])
273279 interpreter_info = MockInterpreterInfo (0 , [thread_info ])
274280 stack_frames = [interpreter_info ]
275281
276282 collector .collect (stack_frames )
277283
278- # Empty frames do NOT count as successful - this is important for
279- # filtered modes like --mode exception where most samples may have
280- # no matching data. Only samples with actual frame data are counted.
281- self .assertEqual (collector .successful_samples , 0 )
284+ # Empty frames still count as successful - the sample worked even
285+ # though no frames matched the filter
286+ self .assertEqual (collector .successful_samples , 1 )
282287 self .assertEqual (collector .total_samples , 1 )
283288 self .assertEqual (collector .failed_samples , 0 )
284289
290+ def test_sample_counts_invariant (self ):
291+ """Test that total_samples == successful_samples + failed_samples.
292+
293+ This is a regression test ensuring the sample counting invariant holds:
294+ - successful_samples: samples where profiler could read process state
295+ - failed_samples: samples where reading failed (e.g., process exited)
296+ - total_samples: sum of both
297+
298+ Empty frame data (e.g., from --mode exception with no active exception)
299+ still counts as successful - the sample worked, just no frames matched.
300+ """
301+ collector = LiveStatsCollector (1000 )
302+
303+ # Mix of samples with and without frame data
304+ frames = [MockFrameInfo ("test.py" , 10 , "func" )]
305+ thread_with_frames = MockThreadInfo (123 , frames )
306+ thread_empty = MockThreadInfo (456 , [])
307+ interp_with_frames = MockInterpreterInfo (0 , [thread_with_frames ])
308+ interp_empty = MockInterpreterInfo (0 , [thread_empty ])
309+
310+ # Collect various samples
311+ collector .collect ([interp_with_frames ]) # Has frames
312+ collector .collect ([interp_empty ]) # No frames (filtered)
313+ collector .collect ([interp_with_frames ]) # Has frames
314+ collector .collect ([interp_empty ]) # No frames (filtered)
315+ collector .collect ([interp_empty ]) # No frames (filtered)
316+
317+ # All 5 samples are successful (profiler could read process state)
318+ self .assertEqual (collector .total_samples , 5 )
319+ self .assertEqual (collector .successful_samples , 5 )
320+ self .assertEqual (collector .failed_samples , 0 )
321+
322+ # Invariant must hold
323+ self .assertEqual (
324+ collector .total_samples ,
325+ collector .successful_samples + collector .failed_samples
326+ )
327+
285328 def test_collect_skip_idle_threads (self ):
286329 """Test that idle threads are skipped when skip_idle=True."""
287330 collector = LiveStatsCollector (1000 , skip_idle = True )
@@ -327,9 +370,10 @@ def test_collect_multiple_threads(self):
327370 def test_collect_filtered_mode_percentage_calculation (self ):
328371 """Test that percentages use successful_samples, not total_samples.
329372
330- This is critical for filtered modes like --mode exception where most
331- samples may be filtered out at the C level. The percentages should
332- be relative to samples that actually had frame data, not all attempts.
373+ With the current behavior, all samples are considered successful
374+ (the profiler could read from the process), even when filters result
375+ in no frame data. This means percentages are relative to all sampling
376+ attempts that succeeded in reading process state.
333377 """
334378 collector = LiveStatsCollector (1000 )
335379
@@ -338,35 +382,30 @@ def test_collect_filtered_mode_percentage_calculation(self):
338382 thread_with_data = MockThreadInfo (123 , frames_with_data )
339383 interpreter_with_data = MockInterpreterInfo (0 , [thread_with_data ])
340384
341- # Empty thread simulates filtered-out data
385+ # Empty thread simulates filtered-out data at C level
342386 thread_empty = MockThreadInfo (456 , [])
343387 interpreter_empty = MockInterpreterInfo (0 , [thread_empty ])
344388
345389 # 2 samples with data
346390 collector .collect ([interpreter_with_data ])
347391 collector .collect ([interpreter_with_data ])
348392
349- # 8 samples without data (filtered out)
393+ # 8 samples without data (filtered out at C level, but sample still succeeded )
350394 for _ in range (8 ):
351395 collector .collect ([interpreter_empty ])
352396
353- # Verify counts
397+ # All 10 samples are successful - the profiler could read from the process
354398 self .assertEqual (collector .total_samples , 10 )
355- self .assertEqual (collector .successful_samples , 2 )
399+ self .assertEqual (collector .successful_samples , 10 )
356400
357401 # Build stats and check percentage
358402 stats_list = collector .build_stats_list ()
359403 self .assertEqual (len (stats_list ), 1 )
360404
361- # The function appeared in 2 out of 2 successful samples = 100%
362- # NOT 2 out of 10 total samples = 20%
405+ # The function appeared in 2 out of 10 successful samples = 20%
363406 location = ("test.py" , 10 , "exception_handler" )
364407 self .assertEqual (collector .result [location ]["direct_calls" ], 2 )
365408
366- # Verify the percentage calculation in build_stats_list
367- # direct_calls / successful_samples * 100 = 2/2 * 100 = 100%
368- # This would be 20% if using total_samples incorrectly
369-
370409 def test_percentage_values_use_successful_samples (self ):
371410 """Test that percentages are calculated from successful_samples.
372411
0 commit comments