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} : New test simulator detected" )
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