@@ -267,21 +267,59 @@ 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+ Empty frame data (e.g., from --mode exception with no active exception)
294+ still counts as successful since the profiler could read process state.
295+ """
296+ collector = LiveStatsCollector (1000 )
297+
298+ # Mix of samples with and without frame data
299+ frames = [MockFrameInfo ("test.py" , 10 , "func" )]
300+ thread_with_frames = MockThreadInfo (123 , frames )
301+ thread_empty = MockThreadInfo (456 , [])
302+ interp_with_frames = MockInterpreterInfo (0 , [thread_with_frames ])
303+ interp_empty = MockInterpreterInfo (0 , [thread_empty ])
304+
305+ # Collect various samples
306+ collector .collect ([interp_with_frames ]) # Has frames
307+ collector .collect ([interp_empty ]) # No frames (filtered)
308+ collector .collect ([interp_with_frames ]) # Has frames
309+ collector .collect ([interp_empty ]) # No frames (filtered)
310+ collector .collect ([interp_empty ]) # No frames (filtered)
311+
312+ # All 5 samples are successful (profiler could read process state)
313+ self .assertEqual (collector .total_samples , 5 )
314+ self .assertEqual (collector .successful_samples , 5 )
315+ self .assertEqual (collector .failed_samples , 0 )
316+
317+ # Invariant must hold
318+ self .assertEqual (
319+ collector .total_samples ,
320+ collector .successful_samples + collector .failed_samples
321+ )
322+
285323 def test_collect_skip_idle_threads (self ):
286324 """Test that idle threads are skipped when skip_idle=True."""
287325 collector = LiveStatsCollector (1000 , skip_idle = True )
@@ -327,9 +365,10 @@ def test_collect_multiple_threads(self):
327365 def test_collect_filtered_mode_percentage_calculation (self ):
328366 """Test that percentages use successful_samples, not total_samples.
329367
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.
368+ With the current behavior, all samples are considered successful
369+ (the profiler could read from the process), even when filters result
370+ in no frame data. This means percentages are relative to all sampling
371+ attempts that succeeded in reading process state.
333372 """
334373 collector = LiveStatsCollector (1000 )
335374
@@ -338,35 +377,30 @@ def test_collect_filtered_mode_percentage_calculation(self):
338377 thread_with_data = MockThreadInfo (123 , frames_with_data )
339378 interpreter_with_data = MockInterpreterInfo (0 , [thread_with_data ])
340379
341- # Empty thread simulates filtered-out data
380+ # Empty thread simulates filtered-out data at C level
342381 thread_empty = MockThreadInfo (456 , [])
343382 interpreter_empty = MockInterpreterInfo (0 , [thread_empty ])
344383
345384 # 2 samples with data
346385 collector .collect ([interpreter_with_data ])
347386 collector .collect ([interpreter_with_data ])
348387
349- # 8 samples without data (filtered out)
388+ # 8 samples without data (filtered out at C level, but sample still succeeded )
350389 for _ in range (8 ):
351390 collector .collect ([interpreter_empty ])
352391
353- # Verify counts
392+ # All 10 samples are successful - the profiler could read from the process
354393 self .assertEqual (collector .total_samples , 10 )
355- self .assertEqual (collector .successful_samples , 2 )
394+ self .assertEqual (collector .successful_samples , 10 )
356395
357396 # Build stats and check percentage
358397 stats_list = collector .build_stats_list ()
359398 self .assertEqual (len (stats_list ), 1 )
360399
361- # The function appeared in 2 out of 2 successful samples = 100%
362- # NOT 2 out of 10 total samples = 20%
400+ # The function appeared in 2 out of 10 successful samples = 20%
363401 location = ("test.py" , 10 , "exception_handler" )
364402 self .assertEqual (collector .result [location ]["direct_calls" ], 2 )
365403
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-
370404 def test_percentage_values_use_successful_samples (self ):
371405 """Test that percentages are calculated from successful_samples.
372406
0 commit comments