Skip to content

Commit 46e4066

Browse files
freakboy3742miss-islington
authored andcommitted
pythongh-129200: Add locking to the iOS testbed startup sequence. (pythonGH-130564)
Add a lock to ensure that only one iOS testbed per user can start at a time, so that the simulator discovery process doesn't collide between instances. (cherry picked from commit 9211b3d) Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
1 parent b53065a commit 46e4066

File tree

3 files changed

+66
-8
lines changed

3 files changed

+66
-8
lines changed

Makefile.pre.in

+1-1
Original file line numberDiff line numberDiff line change
@@ -2060,7 +2060,7 @@ testuniversal: all
20602060
# a full Xcode install that has an iPhone SE (3rd edition) simulator available.
20612061
# This must be run *after* a `make install` has completed the build. The
20622062
# `--with-framework-name` argument *cannot* be used when configuring the build.
2063-
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s)
2063+
XCFOLDER:=iOSTestbed.$(MULTIARCH).$(shell date +%s).$$PPID
20642064
.PHONY: testios
20652065
testios:
20662066
@if test "$(MACHDEP)" != "ios"; then \
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Multiple iOS testbed runners can now be started at the same time without
2+
introducing an ambiguity over simulator ownership.

iOS/testbed/__main__.py

+63-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import argparse
22
import asyncio
3+
import fcntl
34
import json
5+
import os
46
import plistlib
57
import re
68
import shutil
79
import subprocess
810
import sys
11+
import tempfile
912
from contextlib import asynccontextmanager
1013
from datetime import datetime
1114
from 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

172216
async 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

352408
def main():

0 commit comments

Comments
 (0)