18
18
import json
19
19
import logging
20
20
import os
21
+ import psutil
21
22
import re
22
23
import shlex
23
24
import shutil
36
37
import clang_native
37
38
import jsrun
38
39
import line_endings
39
- from tools .shared import EMCC , EMXX , DEBUG
40
+ from tools .shared import EMCC , EMXX , DEBUG , exe_suffix
40
41
from tools .shared import get_canonical_temp_dir , path_from_root
41
42
from tools .utils import MACOS , WINDOWS , read_file , read_binary , write_binary , exit_with_error
42
43
from tools .settings import COMPILE_TIME_SETTINGS
87
88
# file to track which tests were flaky so they can be graphed in orange color to
88
89
# visually stand out.
89
90
flaky_tests_log_filename = os .path .join (path_from_root ('out/flaky_tests.txt' ))
91
+ browser_spawn_lock_filename = os .path .join (path_from_root ('out/browser_spawn_lock' ))
90
92
91
93
92
94
# Default flags used to run browsers in CI testing:
@@ -116,6 +118,7 @@ class FirefoxConfig:
116
118
data_dir_flag = '-profile '
117
119
default_flags = ()
118
120
headless_flags = '-headless'
121
+ executable_name = exe_suffix ('firefox' )
119
122
120
123
@staticmethod
121
124
def configure (data_dir ):
@@ -945,8 +948,25 @@ def make_dir_writeable(dirname):
945
948
946
949
947
950
def force_delete_dir (dirname ):
948
- make_dir_writeable (dirname )
949
- utils .delete_dir (dirname )
951
+ """Deletes a directory. Returns whether deletion succeeded."""
952
+ if not os .path .exists (dirname ):
953
+ return True
954
+ assert not os .path .isfile (dirname )
955
+
956
+ try :
957
+ make_dir_writeable (dirname )
958
+ utils .delete_dir (dirname )
959
+ except PermissionError as e :
960
+ # This issue currently occurs on Windows when running browser tests e.g.
961
+ # on Firefox browser. Killing Firefox browser is not 100% watertight, and
962
+ # occassionally a Firefox browser process can be left behind, holding on
963
+ # to a file handle, preventing the deletion from succeeding.
964
+ # We expect this issue to only occur on Windows.
965
+ if not WINDOWS :
966
+ raise e
967
+ print (f'Warning: Failed to delete directory "{ dirname } "\n { e } ' )
968
+ return False
969
+ return True
950
970
951
971
952
972
def force_delete_contents (dirname ):
@@ -2508,6 +2528,81 @@ def configure_test_browser():
2508
2528
EMTEST_BROWSER += f" { config .headless_flags } "
2509
2529
2510
2530
2531
+ def list_processes_by_name (exe_name ):
2532
+ pids = []
2533
+ if exe_name :
2534
+ for proc in psutil .process_iter ():
2535
+ try :
2536
+ pinfo = proc .as_dict (attrs = ['pid' , 'name' , 'exe' ])
2537
+ if pinfo ['exe' ] and exe_name in pinfo ['exe' ].replace ('\\ ' , '/' ).split ('/' ):
2538
+ pids .append (psutil .Process (pinfo ['pid' ]))
2539
+ except psutil .NoSuchProcess : # E.g. "process no longer exists (pid=13132)" (code raced to acquire the iterator and process it)
2540
+ pass
2541
+
2542
+ return pids
2543
+
2544
+
2545
+ class FileLock :
2546
+ """Implements a filesystem-based mutex, with an additional feature that it
2547
+ returns an integer counter denoting how many times the lock has been locked
2548
+ before (during the current python test run instance)"""
2549
+ def __init__ (self , path ):
2550
+ self .path = path
2551
+ self .counter = 0
2552
+
2553
+ def __enter__ (self ):
2554
+ # Acquire the lock
2555
+ while True :
2556
+ try :
2557
+ self .fd = os .open (self .path , os .O_CREAT | os .O_EXCL | os .O_WRONLY )
2558
+ break
2559
+ except FileExistsError :
2560
+ time .sleep (0.1 )
2561
+ # Return the locking count number
2562
+ try :
2563
+ self .counter = int (open (f'{ self .path } _counter' ).read ())
2564
+ except Exception :
2565
+ pass
2566
+ return self .counter
2567
+
2568
+ def __exit__ (self , * a ):
2569
+ # Increment locking count number before releasing the lock
2570
+ with open (f'{ self .path } _counter' , 'w' ) as f :
2571
+ f .write (str (self .counter + 1 ))
2572
+ # And release the lock
2573
+ os .close (self .fd )
2574
+ try :
2575
+ os .remove (self .path )
2576
+ except Exception :
2577
+ pass # Another process has raced to acquire the lock, and will delete it.
2578
+
2579
+
2580
+ def move_browser_window (pid , x , y ):
2581
+ """Utility function to move the top-level window owned by given process to
2582
+ (x,y) coordinate. Used to ensure each browser window has some visible area."""
2583
+ import win32gui
2584
+ import win32process
2585
+
2586
+ def enum_windows_callback (hwnd , _unused ):
2587
+ _ , win_pid = win32process .GetWindowThreadProcessId (hwnd )
2588
+ if win_pid == pid and win32gui .IsWindowVisible (hwnd ):
2589
+ rect = win32gui .GetWindowRect (hwnd )
2590
+ win32gui .MoveWindow (hwnd , x , y , rect [2 ] - rect [0 ], rect [3 ] - rect [1 ], True )
2591
+ return True
2592
+
2593
+ win32gui .EnumWindows (enum_windows_callback , None )
2594
+
2595
+
2596
+ def increment_suffix_number (str_with_maybe_suffix ):
2597
+ match = re .match (r"^(.*?)(?:_(\d+))?$" , str_with_maybe_suffix )
2598
+ if match :
2599
+ base , number = match .groups ()
2600
+ if number :
2601
+ return f'{ base } _{ int (number ) + 1 } '
2602
+
2603
+ return f'{ str_with_maybe_suffix } _1'
2604
+
2605
+
2511
2606
class BrowserCore (RunnerCore ):
2512
2607
# note how many tests hang / do not send an output. if many of these
2513
2608
# happen, likely something is broken and it is best to abort the test
@@ -2524,15 +2619,19 @@ def __init__(self, *args, **kwargs):
2524
2619
2525
2620
@classmethod
2526
2621
def browser_terminate (cls ):
2527
- cls .browser_proc .terminate ()
2528
- # If the browser doesn't shut down gracefully (in response to SIGTERM)
2529
- # after 2 seconds kill it with force (SIGKILL).
2530
- try :
2531
- cls .browser_proc .wait (2 )
2532
- except subprocess .TimeoutExpired :
2533
- logger .info ('Browser did not respond to `terminate`. Using `kill`' )
2534
- cls .browser_proc .kill ()
2535
- cls .browser_proc .wait ()
2622
+ for proc in cls .browser_procs :
2623
+ try :
2624
+ proc .terminate ()
2625
+ # If the browser doesn't shut down gracefully (in response to SIGTERM)
2626
+ # after 2 seconds kill it with force (SIGKILL).
2627
+ try :
2628
+ proc .wait (2 )
2629
+ except (subprocess .TimeoutExpired , psutil .TimeoutExpired ):
2630
+ logger .info ('Browser did not respond to `terminate`. Using `kill`' )
2631
+ proc .kill ()
2632
+ proc .wait ()
2633
+ except (psutil .NoSuchProcess , ProcessLookupError ):
2634
+ pass
2536
2635
2537
2636
@classmethod
2538
2637
def browser_restart (cls ):
@@ -2551,9 +2650,18 @@ def browser_open(cls, url):
2551
2650
if worker_id is not None :
2552
2651
# Running in parallel mode, give each browser its own profile dir.
2553
2652
browser_data_dir += '-' + str (worker_id )
2554
- if os .path .exists (browser_data_dir ):
2555
- utils .delete_dir (browser_data_dir )
2653
+
2654
+ # Delete old browser data directory.
2655
+ if WINDOWS :
2656
+ # If we cannot (the data dir is in use on Windows), switch to another dir.
2657
+ while not force_delete_dir (browser_data_dir ):
2658
+ browser_data_dir = increment_suffix_number (browser_data_dir )
2659
+ else :
2660
+ force_delete_dir (browser_data_dir )
2661
+
2662
+ # Recreate the new data directory.
2556
2663
os .mkdir (browser_data_dir )
2664
+
2557
2665
if is_chrome ():
2558
2666
config = ChromeConfig ()
2559
2667
elif is_firefox ():
@@ -2568,7 +2676,41 @@ def browser_open(cls, url):
2568
2676
2569
2677
browser_args = shlex .split (browser_args )
2570
2678
logger .info ('Launching browser: %s' , str (browser_args ))
2571
- cls .browser_proc = subprocess .Popen (browser_args + [url ])
2679
+
2680
+ if WINDOWS and is_firefox ():
2681
+ cls .launch_browser_harness_windows_firefox (worker_id , config , browser_args , url )
2682
+ else :
2683
+ cls .browser_procs = [subprocess .Popen (browser_args + [url ])]
2684
+
2685
+ @classmethod
2686
+ def launch_browser_harness_windows_firefox (cls , worker_id , config , browser_args , url ):
2687
+ ''' Dedicated function for launching browser harness on Firefox on Windows,
2688
+ which requires extra care for window positioning and process tracking.'''
2689
+
2690
+ with FileLock (browser_spawn_lock_filename ) as count :
2691
+ # Firefox is a multiprocess browser. On Windows, killing the spawned
2692
+ # process will not bring down the whole browser, but only one browser tab.
2693
+ # So take a delta snapshot before->after spawning the browser to find
2694
+ # which subprocesses we launched.
2695
+ if worker_id is not None :
2696
+ procs_before = list_processes_by_name (config .executable_name )
2697
+ cls .browser_procs = [subprocess .Popen (browser_args + [url ])]
2698
+ # Give Firefox time to spawn its subprocesses. Use an increasing timeout
2699
+ # as a crude way to account for system load.
2700
+ if worker_id is not None :
2701
+ time .sleep (2 + count * 0.3 )
2702
+ procs_after = list_processes_by_name (config .executable_name )
2703
+ # Make sure that each browser window is visible on the desktop. Otherwise
2704
+ # browser might decide that the tab is backgrounded, and not load a test,
2705
+ # or it might not tick rAF()s forward, causing tests to hang.
2706
+ if worker_id is not None and not EMTEST_HEADLESS :
2707
+ # On Firefox on Windows we needs to track subprocesses that got created
2708
+ # by Firefox. Other setups can use 'browser_proc' directly to terminate
2709
+ # the browser.
2710
+ cls .browser_procs = list (set (procs_after ).difference (set (procs_before )))
2711
+ # Wrap window positions on a Full HD desktop area modulo primes.
2712
+ for proc in cls .browser_procs :
2713
+ move_browser_window (proc .pid , (300 + count * 47 ) % 1901 , (10 + count * 37 ) % 997 )
2572
2714
2573
2715
@classmethod
2574
2716
def setUpClass (cls ):
0 commit comments