1
1
import argparse
2
2
import asyncio
3
+ import fcntl
3
4
import json
5
+ import os
4
6
import plistlib
5
7
import re
6
8
import shutil
7
9
import subprocess
8
10
import sys
11
+ import tempfile
9
12
from contextlib import asynccontextmanager
10
13
from datetime import datetime
11
14
from pathlib import Path
@@ -36,6 +39,46 @@ class MySystemExit(Exception):
36
39
pass
37
40
38
41
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
+
39
82
# All subprocesses are executed through this context manager so that no matter
40
83
# what happens, they can always be cancelled from another task, and they will
41
84
# always be cleaned up on exit.
@@ -107,23 +150,24 @@ async def list_devices():
107
150
raise
108
151
109
152
110
- async def find_device (initial_devices ):
153
+ async def find_device (initial_devices , lock ):
111
154
while True :
112
155
new_devices = set (await list_devices ()).difference (initial_devices )
113
156
if len (new_devices ) == 0 :
114
157
await asyncio .sleep (1 )
115
158
elif len (new_devices ) == 1 :
116
159
udid = new_devices .pop ()
117
160
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 ()
119
163
return udid
120
164
else :
121
165
exit (f"Found more than one new device: { new_devices } " )
122
166
123
167
124
- async def log_stream_task (initial_devices ):
168
+ async def log_stream_task (initial_devices , lock ):
125
169
# 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 )
127
171
128
172
# Stream the iOS device's logs, filtering out messages that come from the
129
173
# XCTest test suite (catching NSLog messages from the test method), or
@@ -171,7 +215,7 @@ async def log_stream_task(initial_devices):
171
215
172
216
async def xcode_test (location , simulator , verbose ):
173
217
# Run the test suite on the named simulator
174
- print ("Starting xcodebuild..." )
218
+ print ("Starting xcodebuild..." , flush = True )
175
219
args = [
176
220
"xcodebuild" ,
177
221
"test" ,
@@ -331,7 +375,17 @@ async def run_testbed(simulator: str, args: list[str], verbose: bool=False):
331
375
location = Path (__file__ ).parent
332
376
print ("Updating plist..." , end = "" , flush = True )
333
377
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 )
335
389
336
390
# Get the list of devices that are booted at the start of the test run.
337
391
# 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):
340
394
341
395
try :
342
396
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 ))
344
398
tg .create_task (xcode_test (location , simulator = simulator , verbose = verbose ))
345
399
except* MySystemExit as e :
346
400
raise SystemExit (* e .exceptions [0 ].args ) from None
347
401
except* subprocess .CalledProcessError as e :
348
402
# Extract it from the ExceptionGroup so it can be handled by `main`.
349
403
raise e .exceptions [0 ]
404
+ finally :
405
+ simulator_lock .release ()
350
406
351
407
352
408
def main ():
0 commit comments