11import  argparse 
22import  asyncio 
3+ import  fcntl 
34import  json 
5+ import  os 
46import  plistlib 
57import  re 
68import  shutil 
79import  subprocess 
810import  sys 
11+ import  tempfile 
912from  contextlib  import  asynccontextmanager 
1013from  datetime  import  datetime 
1114from  pathlib  import  Path 
@@ -36,6 +39,46 @@ class MySystemExit(Exception):
3639    pass 
3740
3841
42+ class  SimulatorLock :
43+     # An fcntl-based filesystem lock that can be used to ensure that 
44+     def  __init__ (self , timeout ):
45+         self .filename  =  Path (tempfile .gettempdir ()) /  "python-ios-testbed" 
46+         self .timeout  =  timeout 
47+ 
48+         self .fd  =  None 
49+ 
50+     async  def  acquire (self ):
51+         # Ensure the lockfile exists 
52+         self .filename .touch (exist_ok = True )
53+ 
54+         # Try `timeout` times to acquire the lock file, with a 1 second pause 
55+         # between each attempt. Report status every 10 seconds. 
56+         for  i  in  range (0 , self .timeout ):
57+             try :
58+                 fd  =  os .open (self .filename , os .O_RDWR  |  os .O_TRUNC , 0o644 )
59+                 fcntl .flock (fd , fcntl .LOCK_EX  |  fcntl .LOCK_NB )
60+             except  OSError :
61+                 os .close (fd )
62+                 if  i  %  10  ==  0 :
63+                     print ("... waiting" , flush = True )
64+                 await  asyncio .sleep (1 )
65+             else :
66+                 self .fd  =  fd 
67+                 return 
68+ 
69+         # If we reach the end of the loop, we've exceeded the allowed number of 
70+         # attempts. 
71+         raise  ValueError ("Unable to obtain lock on iOS simulator creation" )
72+ 
73+     def  release (self ):
74+         # If a lock is held, release it. 
75+         if  self .fd  is  not None :
76+             # Release the lock. 
77+             fcntl .flock (self .fd , fcntl .LOCK_UN )
78+             os .close (self .fd )
79+             self .fd  =  None 
80+ 
81+ 
3982# All subprocesses are executed through this context manager so that no matter 
4083# what happens, they can always be cancelled from another task, and they will 
4184# always be cleaned up on exit. 
@@ -107,23 +150,24 @@ async def list_devices():
107150            raise 
108151
109152
110- async  def  find_device (initial_devices ):
153+ async  def  find_device (initial_devices ,  lock ):
111154    while  True :
112155        new_devices  =  set (await  list_devices ()).difference (initial_devices )
113156        if  len (new_devices ) ==  0 :
114157            await  asyncio .sleep (1 )
115158        elif  len (new_devices ) ==  1 :
116159            udid  =  new_devices .pop ()
117160            print (f"{ datetime .now ():%Y-%m-%d %H:%M:%S}  )
118-             print (f"UDID: { udid }  )
161+             print (f"UDID: { udid }  , flush = True )
162+             lock .release ()
119163            return  udid 
120164        else :
121165            exit (f"Found more than one new device: { new_devices }  )
122166
123167
124- async  def  log_stream_task (initial_devices ):
168+ async  def  log_stream_task (initial_devices ,  lock ):
125169    # Wait up to 5 minutes for the build to complete and the simulator to boot. 
126-     udid  =  await  asyncio .wait_for (find_device (initial_devices ), 5  *  60 )
170+     udid  =  await  asyncio .wait_for (find_device (initial_devices ,  lock ), 5  *  60 )
127171
128172    # Stream the iOS device's logs, filtering out messages that come from the 
129173    # XCTest test suite (catching NSLog messages from the test method), or 
@@ -171,7 +215,7 @@ async def log_stream_task(initial_devices):
171215
172216async  def  xcode_test (location , simulator , verbose ):
173217    # Run the test suite on the named simulator 
174-     print ("Starting xcodebuild..." )
218+     print ("Starting xcodebuild..." ,  flush = True )
175219    args  =  [
176220        "xcodebuild" ,
177221        "test" ,
@@ -331,7 +375,17 @@ async def run_testbed(simulator: str, args: list[str], verbose: bool=False):
331375    location  =  Path (__file__ ).parent 
332376    print ("Updating plist..." , end = "" , flush = True )
333377    update_plist (location , args )
334-     print (" done." )
378+     print (" done." , flush = True )
379+ 
380+     # We need to get an exclusive lock on simulator creation, to avoid issues 
381+     # with multiple simulators starting and being unable to tell which 
382+     # simulator is due to which testbed instance. See 
383+     # https://github.com/python/cpython/issues/130294 for details. Wait up to 
384+     # 10 minutes for a simulator to boot. 
385+     print ("Obtaining lock on simulator creation..." , flush = True )
386+     simulator_lock  =  SimulatorLock (timeout = 10 * 60 )
387+     await  simulator_lock .acquire ()
388+     print ("Simulator lock acquired." , flush = True )
335389
336390    # Get the list of devices that are booted at the start of the test run. 
337391    # The simulator started by the test suite will be detected as the new 
@@ -340,13 +394,15 @@ async def run_testbed(simulator: str, args: list[str], verbose: bool=False):
340394
341395    try :
342396        async  with  asyncio .TaskGroup () as  tg :
343-             tg .create_task (log_stream_task (initial_devices ))
397+             tg .create_task (log_stream_task (initial_devices ,  simulator_lock ))
344398            tg .create_task (xcode_test (location , simulator = simulator , verbose = verbose ))
345399    except* MySystemExit  as  e :
346400        raise  SystemExit (* e .exceptions [0 ].args ) from  None 
347401    except* subprocess .CalledProcessError  as  e :
348402        # Extract it from the ExceptionGroup so it can be handled by `main`. 
349403        raise  e .exceptions [0 ]
404+     finally :
405+         simulator_lock .release ()
350406
351407
352408def  main ():
0 commit comments