1818import json
1919import logging
2020import os
21+ import psutil
2122import re
2223import shlex
2324import shutil
3637import clang_native
3738import jsrun
3839import line_endings
39- from tools .shared import EMCC , EMXX , DEBUG
40+ from tools .shared import EMCC , EMXX , DEBUG , exe_suffix
4041from tools .shared import get_canonical_temp_dir , path_from_root
4142from tools .utils import MACOS , WINDOWS , read_file , read_binary , write_binary , exit_with_error
4243from tools .settings import COMPILE_TIME_SETTINGS
8788# file to track which tests were flaky so they can be graphed in orange color to
8889# visually stand out.
8990flaky_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' ))
9092
9193
9294# Default flags used to run browsers in CI testing:
@@ -116,6 +118,7 @@ class FirefoxConfig:
116118 data_dir_flag = '-profile '
117119 default_flags = ()
118120 headless_flags = '-headless'
121+ executable_name = exe_suffix ('firefox' )
119122
120123 @staticmethod
121124 def configure (data_dir ):
@@ -945,8 +948,25 @@ def make_dir_writeable(dirname):
945948
946949
947950def 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
950970
951971
952972def force_delete_contents (dirname ):
@@ -2508,6 +2528,81 @@ def configure_test_browser():
25082528 EMTEST_BROWSER += f" { config .headless_flags } "
25092529
25102530
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+
25112606class BrowserCore (RunnerCore ):
25122607 # note how many tests hang / do not send an output. if many of these
25132608 # happen, likely something is broken and it is best to abort the test
@@ -2524,15 +2619,19 @@ def __init__(self, *args, **kwargs):
25242619
25252620 @classmethod
25262621 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
25362635
25372636 @classmethod
25382637 def browser_restart (cls ):
@@ -2551,9 +2650,18 @@ def browser_open(cls, url):
25512650 if worker_id is not None :
25522651 # Running in parallel mode, give each browser its own profile dir.
25532652 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.
25562663 os .mkdir (browser_data_dir )
2664+
25572665 if is_chrome ():
25582666 config = ChromeConfig ()
25592667 elif is_firefox ():
@@ -2568,7 +2676,41 @@ def browser_open(cls, url):
25682676
25692677 browser_args = shlex .split (browser_args )
25702678 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 )
25722714
25732715 @classmethod
25742716 def setUpClass (cls ):
0 commit comments