@@ -75,7 +75,7 @@ class Logger:
75
75
report_incomplete = sys .stdout .isatty ()
76
76
77
77
def __init__ (self ):
78
- self .lock = threading .Lock ()
78
+ self .lock = threading .RLock ()
79
79
self .incomplete_line = None
80
80
81
81
def log (self , msg = '' , incomplete = False ):
@@ -92,7 +92,8 @@ def log(self, msg='', incomplete=False):
92
92
self .incomplete_line = msg if incomplete else None
93
93
94
94
95
- log = Logger ().log
95
+ logger = Logger ()
96
+ log = logger .log
96
97
97
98
98
99
class TestStatus (enum .StrEnum ):
@@ -504,27 +505,105 @@ def run_tests(self, tests: list['TestSuite']):
504
505
log (crash )
505
506
506
507
def run_partitions_in_subprocesses (self , executor , partitions : list [list ['Test' ]]):
507
- futures = [
508
- executor .submit (self .run_in_subprocess_and_watch , partition )
509
- for partition in partitions
510
- ]
508
+ workers = [SubprocessWorker (self , partition ) for i , partition in enumerate (partitions )]
509
+ futures = [executor .submit (worker .run_in_subprocess_and_watch ) for worker in workers ]
510
+
511
+ def dump_worker_status ():
512
+ with logger .lock :
513
+ for i , partition in enumerate (partitions ):
514
+ not_started = 0
515
+ if futures [i ].running ():
516
+ log (f"Worker on { workers [i ].thread } : { workers [i ].get_status ()} " )
517
+ elif not futures [i ].done ():
518
+ not_started += len (partition )
519
+ if not_started :
520
+ log (f"There are { not_started } tests not assigned to any worker" )
521
+
511
522
try :
512
- concurrent .futures .wait (futures )
513
- for future in futures :
514
- future .result ()
515
- except KeyboardInterrupt :
516
- self .stop_event .set ()
517
- concurrent .futures .wait (futures )
518
- print ("Interrupted!" )
519
- sys .exit (1 )
523
+ def sigterm_handler (_signum , _frame ):
524
+ dump_worker_status ()
525
+ # noinspection PyUnresolvedReferences,PyProtectedMember
526
+ os ._exit (1 )
520
527
521
- def run_in_subprocess_and_watch (self , tests : list ['Test' ]):
528
+ prev_sigterm = signal .signal (signal .SIGTERM , sigterm_handler )
529
+ except Exception :
530
+ prev_sigterm = None
531
+
532
+ try :
533
+ try :
534
+ concurrent .futures .wait (futures )
535
+ for future in futures :
536
+ future .result ()
537
+ except KeyboardInterrupt :
538
+ log ("Received keyboard interrupt, stopping" )
539
+ dump_worker_status ()
540
+ self .stop_event .set ()
541
+ concurrent .futures .wait (futures )
542
+ sys .exit (1 )
543
+ finally :
544
+ if prev_sigterm :
545
+ signal .signal (signal .SIGTERM , prev_sigterm )
546
+
547
+
548
+ class SubprocessWorker :
549
+ def __init__ (self , runner : ParallelTestRunner , tests : list ['Test' ]):
550
+ self .runner = runner
551
+ self .stop_event = runner .stop_event
552
+ self .remaining_test_ids = [test .test_id for test in tests ]
553
+ self .tests_by_id = {test .test_id : test for test in tests }
554
+ self .out_file : typing .TextIO | None = None
555
+ self .last_started_test : Test | None = None
556
+ self .last_started_time : float | None = None
557
+ self .last_out_pos = 0
558
+ self .process : subprocess .Popen | None = None
559
+ self .thread = None
560
+
561
+ def process_event (self , event ):
562
+ match event ['event' ]:
563
+ case 'testStarted' :
564
+ self .remaining_test_ids .remove (event ['test' ])
565
+ self .runner .report_start (event ['test' ])
566
+ self .last_started_test = self .tests_by_id [event ['test' ]]
567
+ self .last_started_time = time .time ()
568
+ self .last_out_pos = event ['out_pos' ]
569
+ case 'testResult' :
570
+ out_end = event ['out_pos' ]
571
+ test_output = ''
572
+ if self .last_out_pos != out_end :
573
+ self .out_file .seek (self .last_out_pos )
574
+ test_output = self .out_file .read (out_end - self .last_out_pos )
575
+ result = TestResult (
576
+ test_id = event ['test' ],
577
+ status = event ['status' ],
578
+ param = event .get ('param' ),
579
+ output = test_output ,
580
+ duration = event .get ('duration' ),
581
+ )
582
+ self .runner .report_result (result )
583
+ self .last_started_test = None
584
+ self .last_started_time = None
585
+ self .last_out_pos = event ['out_pos' ]
586
+
587
+ def get_status (self ):
588
+ if not self .process :
589
+ process_status = "not started"
590
+ elif self .process .poll () is not None :
591
+ process_status = f"exitted with code { self .process .returncode } "
592
+ else :
593
+ process_status = "running"
594
+
595
+ if self .last_started_test is not None :
596
+ duration = time .time () - self .last_started_time
597
+ test_status = f"executing { self .last_started_test } for { duration :.2f} s"
598
+ else :
599
+ test_status = "no current test"
600
+ remaining = len (self .remaining_test_ids )
601
+ return f"test: { test_status } ; remaining: { remaining } ; process status: { process_status } "
602
+
603
+ def run_in_subprocess_and_watch (self ):
604
+ self .thread = threading .current_thread ()
522
605
# noinspection PyUnresolvedReferences
523
606
use_pipe = sys .platform != 'win32' and (not IS_GRAALPY or __graalpython__ .posix_module_backend () == 'native' )
524
- tests_by_id = {test .test_id : test for test in tests }
525
- remaining_test_ids = [test .test_id for test in tests ]
526
- last_started_test : Test | None = None
527
- last_started_time : float | None = None
528
607
with tempfile .TemporaryDirectory (prefix = 'graalpytest-' ) as tmp_dir :
529
608
tmp_dir = Path (tmp_dir )
530
609
env = os .environ .copy ()
@@ -535,106 +614,79 @@ def run_in_subprocess_and_watch(self, tests: list['Test']):
535
614
else :
536
615
result_file = tmp_dir / 'result'
537
616
538
- def process_event (event ):
539
- nonlocal remaining_test_ids , last_started_test , last_started_time , last_out_pos
540
- match event ['event' ]:
541
- case 'testStarted' :
542
- remaining_test_ids .remove (event ['test' ])
543
- self .report_start (event ['test' ])
544
- last_started_test = tests_by_id [event ['test' ]]
545
- last_started_time = time .time ()
546
- last_out_pos = event ['out_pos' ]
547
- case 'testResult' :
548
- out_end = event ['out_pos' ]
549
- test_output = ''
550
- if last_out_pos != out_end :
551
- out_file .seek (last_out_pos )
552
- test_output = out_file .read (out_end - last_out_pos )
553
- result = TestResult (
554
- test_id = event ['test' ],
555
- status = event ['status' ],
556
- param = event .get ('param' ),
557
- output = test_output ,
558
- duration = event .get ('duration' ),
559
- )
560
- self .report_result (result )
561
- last_started_test = None
562
- last_started_time = None
563
- last_out_pos = event ['out_pos' ]
564
-
565
- while remaining_test_ids and not self .stop_event .is_set ():
617
+ while self .remaining_test_ids and not self .stop_event .is_set ():
566
618
with (
567
- open (tmp_dir / 'out' , 'w+' ) as out_file ,
619
+ open (tmp_dir / 'out' , 'w+' ) as self . out_file ,
568
620
open (tmp_dir / 'tests' , 'w+' ) as tests_file ,
569
621
):
570
- last_out_pos = 0
622
+ self . last_out_pos = 0
571
623
cmd = [
572
624
sys .executable ,
573
625
'-u' ,
574
- * self .subprocess_args ,
626
+ * self .runner . subprocess_args ,
575
627
__file__ ,
576
628
'--tests-file' , str (tests_file .name ),
577
629
]
578
630
if use_pipe :
579
631
cmd += ['--pipe-fd' , str (child_pipe .fileno ())]
580
632
else :
581
633
cmd += ['--result-file' , str (result_file )]
582
- if self .failfast :
634
+ if self .runner . failfast :
583
635
cmd .append ('--failfast' )
584
636
# We communicate the tests through a temp file to avoid running into too long commandlines on windows
585
637
tests_file .seek (0 )
586
638
tests_file .truncate ()
587
- tests_file .write ('\n ' .join (map (str , remaining_test_ids )))
639
+ tests_file .write ('\n ' .join (map (str , self . remaining_test_ids )))
588
640
tests_file .flush ()
589
641
popen_kwargs : dict = dict (
590
- stdout = out_file ,
591
- stderr = out_file ,
642
+ stdout = self . out_file ,
643
+ stderr = self . out_file ,
592
644
env = env ,
593
645
)
594
646
if use_pipe :
595
647
popen_kwargs .update (pass_fds = [child_pipe .fileno ()])
596
- process = subprocess .Popen (cmd , ** popen_kwargs )
648
+ self . process = subprocess .Popen (cmd , ** popen_kwargs )
597
649
598
650
timed_out = False
599
651
600
652
if use_pipe :
601
- while process .poll () is None :
653
+ while self . process .poll () is None :
602
654
while pipe .poll (0.1 ):
603
- process_event (pipe .recv ())
655
+ self . process_event (pipe .recv ())
604
656
if self .stop_event .is_set ():
605
- interrupt_process (process )
657
+ interrupt_process (self . process )
606
658
break
607
- if last_started_test is not None :
608
- timeout = last_started_test .test_file .test_config .per_test_timeout
609
- if time .time () - last_started_time >= timeout :
610
- interrupt_process (process )
659
+ if self . last_started_test is not None :
660
+ timeout = self . last_started_test .test_file .test_config .per_test_timeout
661
+ if time .time () - self . last_started_time >= timeout :
662
+ interrupt_process (self . process )
611
663
timed_out = True
612
664
# Drain the pipe
613
665
while pipe .poll (0.1 ):
614
666
pipe .recv ()
615
667
break
616
668
try :
617
- returncode = process .wait (60 )
669
+ returncode = self . process .wait (60 )
618
670
except subprocess .TimeoutExpired :
619
671
log ("Warning: Worker didn't shutdown in a timely manner, interrupting it" )
620
- interrupt_process (process )
672
+ interrupt_process (self . process )
621
673
622
- process .wait ()
674
+ self . process .wait ()
623
675
624
676
if self .stop_event .is_set ():
625
677
return
626
678
if use_pipe :
627
679
while pipe .poll (0.1 ):
628
- process_event (pipe .recv ())
680
+ self . process_event (pipe .recv ())
629
681
else :
630
682
with open (result_file , 'rb' ) as f :
631
683
for file_event in pickle .load (f ):
632
- process_event (file_event )
684
+ self . process_event (file_event )
633
685
634
686
if returncode != 0 or timed_out :
635
- out_file .seek (last_out_pos )
636
- output = out_file .read ()
637
- if last_started_test :
687
+ self . out_file .seek (self . last_out_pos )
688
+ output = self . out_file .read ()
689
+ if self . last_started_test :
638
690
if timed_out :
639
691
message = "Timed out"
640
692
elif returncode >= 0 :
@@ -645,16 +697,16 @@ def process_event(event):
645
697
except ValueError :
646
698
signal_name = str (- returncode )
647
699
message = f"Test process killed by signal { signal_name } "
648
- self .report_result (TestResult (
649
- test_id = last_started_test .test_id ,
700
+ self .runner . report_result (TestResult (
701
+ test_id = self . last_started_test .test_id ,
650
702
status = TestStatus .ERROR ,
651
703
param = message ,
652
704
output = output ,
653
705
))
654
706
continue
655
707
else :
656
708
# Crashed outside of tests, don't retry
657
- self .crashes .append (output or 'Runner subprocess crashed' )
709
+ self .runner . crashes .append (output or 'Runner subprocess crashed' )
658
710
return
659
711
660
712
@@ -1114,6 +1166,8 @@ def main():
1114
1166
print (test )
1115
1167
return
1116
1168
1169
+ log (f"Collected { sum (len (test_suite .collected_tests ) for test_suite in tests )} tests" )
1170
+
1117
1171
if not tests :
1118
1172
sys .exit ("No tests matched\n " )
1119
1173
0 commit comments