blob: 5fff9a36ccfe472875ac5635452b7156053b2835 [file]
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""The `osx_sdk` module provides safe functions to access a semi-hermetic
XCode installation.
Available only to Google-run bots."""
from contextlib import contextmanager
import re
from recipe_engine import recipe_api
from datetime import datetime, timedelta
_RUNTIMESPATH = (
'Contents/Developer/Platforms/iPhoneOS.platform/Library/'
'Developer/CoreSimulator/Profiles/Runtimes'
)
# This CIPD source contains Xcode and iOS runtimes create and maintained by the
# Flutter team.
_FLUTTER_XCODE_CIPD = 'flutter_internal/ios/xcode'
# This CIPD source contains Xcode and iOS runtimes created and maintained by the
# Chrome team.
_INFRA_XCODE_CIPD = 'infra_internal/ios/xcode'
# Intel bots cannot run macOS 26, which newer Xcodes require, so any Xcode
# requested on Intel is replaced by 17c52 (Xcode 26.2), the newest Xcode that
# runs there. See https://github.com/flutter/flutter/issues/192754.
_INTEL_MAX_XCODE_VERSION = '17c52'
# Matches simulator runtime disk image UDIDs as printed by `simctl runtime`,
# e.g. `D: 73EA5108-FE5E-4ADC-B6A6-D2FBE4448EA2 iOS (26.2 - 23C54) (Unusable -
# Other Failure: ... "Duplicate of 30BC4E5F-DE5F-499A-93AF-22FEF83EF916" ...)`.
_RUNTIME_UDID_RE = re.compile(
r'\b[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\b'
)
# Substring of the `simctl runtime add` output when the added image is a
# duplicate of an already registered (possibly still deleting) image. See
# https://github.com/flutter/flutter/issues/180141.
_DUPLICATE_RUNTIME_ERROR = 'duplicate of'
class OSXSDKApi(recipe_api.RecipeApi):
"""API for using OS X SDK distributed via CIPD."""
def __init__(self, sdk_properties, *args, **kwargs):
super(OSXSDKApi, self).__init__(*args, **kwargs)
self._sdk_properties = sdk_properties
self._sdk_version = None
self._runtime_versions = None
self._tool_pkg = 'infra/tools/mac_toolchain/${platform}'
self._tool_ver = 'latest'
self._cleanup_cache = False
self.macos_13_or_later = False
self.macos_26_or_later = False
self._xcode_cipd_package_source = None
self._skip = False
def initialize(self):
"""Initializes xcode, and ios versions.
Versions are usually passed as recipe properties but if not then defaults
are used.
"""
if not self.m.platform.is_mac:
return
if 'skip_xcode_install' in self._sdk_properties:
self._skip = self._sdk_properties['skip_xcode_install']
if 'cleanup_cache' in self._sdk_properties:
self._cleanup_cache = self._sdk_properties['cleanup_cache']
if 'arm' in self.m.platform.arch and 'toolchain_ver_arm' in self._sdk_properties:
self._tool_ver = self._sdk_properties['toolchain_ver_arm']
elif 'intel' in self.m.platform.arch and 'toolchain_ver_intel' in self._sdk_properties:
self._tool_ver = self._sdk_properties['toolchain_ver_intel']
if 'runtime_versions' in self._sdk_properties:
# Sorts the runtime versions to make xcode cache path deterministic, without
# being affected by how user orders the runtime versions.
runtime_versions = self._sdk_properties['runtime_versions']
runtime_versions.sort(reverse=True)
self._runtime_versions = runtime_versions
find_os = self.m.step(
"find macOS version",
["sw_vers", "-productVersion"],
stdout=self.m.raw_io.output_text(),
step_test_data=(
lambda: self.m.raw_io.test_api.stream_output_text("14.4")
),
)
current_os = self.m.version.parse(find_os.stdout.strip())
if 'sdk_version' in self._sdk_properties:
self._sdk_version = self._sdk_properties['sdk_version'].lower()
# The `!=` check only avoids a no-op step when 17c52 is already requested.
if ('intel' in self.m.platform.arch and
self._sdk_version != _INTEL_MAX_XCODE_VERSION):
self.m.step.empty(
'Pinning Xcode to %s on Intel' % _INTEL_MAX_XCODE_VERSION,
step_text=(
'Requested Xcode %s, but Intel bots cannot run macOS 26. See '
'https://github.com/flutter/flutter/issues/192754.'
% self._sdk_version
),
)
self._sdk_version = _INTEL_MAX_XCODE_VERSION
self.macos_13_or_later = current_os >= self.m.version.parse('13.0.0')
self.macos_26_or_later = current_os >= self.m.version.parse('26.0.0')
if 'xcode_cipd_package_source' in self._sdk_properties:
self._xcode_cipd_package_source = self._sdk_properties[
'xcode_cipd_package_source']
else:
# TODO(vashworth): Once all bots are on macOS 13, we can remove this
# check and always default to _INFRA_XCODE_CIPD.
if self.macos_13_or_later:
# Starting with macOS 13, Xcode packages that have been altered are
# considered "damaged" and will not be usable. Since Xcode CIPD packages
# from flutter_internal have been altered up to Xcode 15 beta 6 (15a5219j),
# use infra_internal Xcode CIPD packages as the default on macOS 13.
self._xcode_cipd_package_source = _INFRA_XCODE_CIPD
else:
self._xcode_cipd_package_source = _FLUTTER_XCODE_CIPD
@contextmanager
def __call__(self, kind, devicelab=False):
"""Sets up the XCode SDK environment.
Is a no-op on non-mac platforms.
This will deploy the helper tool and the XCode.app bundle at
`[START_DIR]/cache/osx_sdk`.
To avoid machines rebuilding these on every run, set up a named cache in
your cr-buildbucket.cfg file like:
caches: [
{
name: "flutter_xcode"
path: "osx_sdk"
}
]
The Xcode app will be installed under:
osx_sdk/xcode_<xcode-version>
If any iOS runtime is needed, the corresponding path will be:
osx_sdk/xcode_<xcode-version>_runtime_<runtime-version>
These cached packages will be shared across builds/bots.
Usage:
with api.osx_sdk('mac'):
# sdk with mac build bits
with api.osx_sdk('ios'):
# sdk with mac+iOS build bits
Args:
kind ('mac'|'ios'): How the SDK should be configured. iOS includes the
base XCode distribution, as well as the iOS simulators (which can be
quite large).
devicelab (bool): whether this is a devicelab tasks. The xcode for devicelab
is installed in a fixed location `/opt/flutter/xcode`.
Raises:
StepFailure or InfraFailure.
"""
assert kind in ('mac', 'ios'), 'Invalid kind %r' % (kind,)
if self._skip or not self.m.platform.is_mac:
yield
return
if self._sdk_version is None:
self.m.step.empty('Xcode version is not set. Skipping.',)
yield
return
try:
with self.m.context(infra_steps=True):
self._diagnose_unresponsive_mac(devicelab)
self._setup_osx_sdk(kind, devicelab)
runtime_simulators = self.m.step(
'list runtimes', ['xcrun', 'simctl', 'list', 'runtimes'],
stdout=self.m.raw_io.output_text(add_output_log=True)
).stdout.splitlines()
if self._missing_runtime(runtime_simulators[1:]) or self._unavailable_runtime(runtime_simulators):
self._cleanup_cache = True
self._setup_osx_sdk(kind, devicelab)
yield
finally:
self.reset_xcode()
def reset_xcode(self):
'''Unset manually defined Xcode path for Xcode command line tools on macOS.'''
if self._skip or not self.m.platform.is_mac:
return
with self.m.context(infra_steps=True):
self.m.step('reset XCode', ['sudo', 'xcode-select', '--reset'])
def _missing_runtime(self, runtime_simulators):
"""Check if there is any missing runtime.
If no explicit `_runtime_versions` is specified, we assume `runtime_simulators`
at least has the default runtime and should not be empty.
If there is explicit `_runtime_versions` defined, we need to check if the number
of installed runtime matches the number of required.
The runtime_simulators follows:
[
"iOS 16.2 (16.2 - 20C52) - com.apple.CoreSimulator.SimRuntime.iOS-16-2",
"iOS 16.4 (16.4 - 20E247) - com.apple.CoreSimulator.SimRuntime.iOS-16-4"
]
The property `_runtime_versions` follows:
[
"ios-16-4_14e300c",
"ios-16-2_14c18"
]
"""
if not self._runtime_versions:
return not runtime_simulators
return len(self._runtime_versions) != len(runtime_simulators)
def _unavailable_runtime(self, runtime_simulators):
"""Check if any runtime is unavailable due to architecture mismatch.
On Intel Macs, an ARM-only runtime may have been cached. When this happens,
`simctl list runtimes` reports the runtime as unavailable with a specific
error about liblaunch_sim.dylib.
Args:
runtime_simulators: (list) A list of strings from `simctl list runtimes` output.
Returns:
True if any runtime is unavailable due to this error, False otherwise.
"""
unavailable_error = '(unavailable, liblaunch_sim.dylib could not be opened)'
for runtime in runtime_simulators:
if unavailable_error in runtime:
return True
return False
def _get_xcode_base_cache_path(self, devicelab: bool):
"""Returns the base cache path for xcode.
Args:
devicelab - Whether or not the machine this code is running is a devicelab
machine.
"""
if devicelab:
return self.m.path.cast_to_path('/opt/flutter/xcode')
return self.m.path.cache_dir / 'osx_sdk'
def _setup_osx_sdk(self, kind, devicelab):
app = None
self._clean_xcode_cache(devicelab)
# NOTE: cleaning of the cache on devicelab will happen via salt.
if not devicelab:
self._micro_manage_cache(devicelab=devicelab)
app = self._ensure_sdk(kind, devicelab)
self.m.os_utils.kill_simulators()
self._select_xcode(app)
self.m.step('list simulators', ['xcrun', 'simctl', 'list'])
def _clean_xcode_cache(self, devicelab):
"""Cleans up cache when specified or polluted.
Cleans up only corresponding versioned xcode instead of the whole `osx_sdk`.
If on macOS 13 or later, deletes all mounted runtimes and deletes
corresponding cached runtime dmgs.
"""
if not self._cleanup_cache:
return
if devicelab or not self.macos_13_or_later:
self.m.file.rmtree('Cleaning up Xcode cache', self._xcode_dir(devicelab))
else:
# Clean up with `file.rmtree` fails on macOS 13 non-devicelab bots with an
# "Operation not permitted" error when deleting XCode.app/Contents/_CodeSignature.
# Use `rm -rf` instead.
self.m.step(
'Cleaning up Xcode cache',
['rm', '-rf', self._xcode_dir(devicelab)]
)
def _ensure_mac_toolchain(self, tool_dir):
ef = self.m.cipd.EnsureFile()
ef.add_package(self._tool_pkg, self._tool_ver)
self.m.cipd.ensure(tool_dir, ef)
def _select_xcode(self, sdk_app):
self.m.step('select xcode', ['sudo', 'xcode-select', '--switch', sdk_app])
def _verify_xcode(self, sdk_app):
"""If Xcode is already downloaded, verify that it's not damaged.
Args:
sdk_app: (Path) Path to installed Xcode app bundle.
"""
if not self.m.path.exists(sdk_app):
return
with self.m.step.nest('verify xcode %s' % sdk_app):
version_check_failed = False
try:
self._select_xcode(sdk_app)
# This step is expected to timeout if Xcode is damaged.
self.m.step(
'check xcode version',
[
'xcrun',
'xcodebuild',
'-version',
],
timeout=60 * 5, # 5 minutes
)
except self.m.step.StepFailure:
version_check_failed = True
raise
finally:
self.reset_xcode()
self._dimiss_damaged_notification()
if version_check_failed:
self._diagnose_codesign_failure(sdk_app)
def _diagnose_codesign_failure(self, sdk_app):
"""Check if Xcode verification may have failed due to code not matching
original signed code. Used to help debug issues.
"""
self.m.step(
'verify codesign',
[
'codesign',
'-vv',
sdk_app,
],
ok_ret='any',
)
def _dimiss_damaged_notification(self):
"""If Xcode is damaged, it may show a notification that can cause
Xcode processes to hang until it's closed. Kill `CoreServicesUIAgent`
to close it.
"""
self.m.step(
'dismiss damaged notification',
['killall', '-9', 'CoreServicesUIAgent'],
ok_ret='any',
)
def _try_install_xcode(self, tool_dir, kind, app_dir, sdk_app, devicelab):
"""Installs xcode using mac_toolchain. If install fails, clear the cache and try again.
Args:
devicelab: (bool) Whether this is a devicelab tasks. Don't install
explicit runtimes for devicelab tasks.
app_dir: (Path) Path to Xcode cache directory.
tool_dir: (Path) Path to mac_toolchain cache directory.
sdk_app: (Path) Path to installed Xcode app bundle.
kind ('mac'|'ios'): How the SDK should be configured.
"""
with self.m.step.nest('install xcode'):
try:
self._install_xcode(tool_dir, kind, app_dir, sdk_app, devicelab)
except self.m.step.StepFailure:
self._install_xcode(tool_dir, kind, app_dir, sdk_app, devicelab, True)
def _install_xcode(
self, tool_dir, kind, app_dir, sdk_app, devicelab, retry=False
):
"""Installs xcode using mac_toolchain.
Args:
devicelab: (bool) Whether this is a devicelab tasks. Don't install
explicit runtimes for devicelab tasks.
app_dir: (Path) Path to Xcode cache directory.
tool_dir: (Path) Path to mac_toolchain cache directory.
sdk_app: (Path) Path to installed Xcode app bundle.
kind ('mac'|'ios'): How the SDK should be configured.
retry: (bool) Whether this is the second attempt to install Xcode.
"""
install_path = sdk_app
# On retry, install Xcode to a different path and later move it to the
# original path. Although unproven, there is a theory that re-installing
# Xcode to the same path as a previously damaged version may cause the new
# version to also be considered damaged.
if retry:
uuid = self.m.uuid.random()
install_path = app_dir / f'temp_{uuid}_xcode.app'
try:
# Verify that existing Xcode is not damaged. If it's damaged, the
# 'install xcode from cipd' step may get stuck until it times out.
self._verify_xcode(install_path)
except self.m.step.StepFailure:
self._cleanup_cache = True
self._clean_xcode_cache(devicelab)
try:
self._ensure_mac_toolchain(tool_dir)
if self.macos_13_or_later:
# TODO(vashworth): Remove macOS 13 specific install steps once
# https://github.com/flutter/flutter/issues/138238 is resolved.
self.m.step('Show tool_dir cache', ['ls', '-al', tool_dir])
self.m.step(
'install xcode from cipd',
[
tool_dir / 'mac_toolchain',
'install',
'-kind',
kind,
'-xcode-version',
self._sdk_version,
'-output-dir',
install_path,
'-cipd-package-prefix',
self._xcode_cipd_package_source,
'-with-runtime=%s' % (not bool(self._runtime_versions)),
'-with-metal-toolchain=True',
'-verbose',
],
timeout=60 * 30 # 30 minutes
)
if retry:
self.m.step(
'move to final destination',
[
'mv',
install_path,
sdk_app,
],
)
except self.m.step.StepFailure:
if self.macos_13_or_later:
# TODO(vashworth): Remove macOS 13 specific install steps once
# https://github.com/flutter/flutter/issues/138238 is resolved.
self.m.step('Show tool_dir cache', ['ls', '-al', tool_dir])
self.m.step('Show app_dir cache', ['ls', '-al', app_dir], ok_ret='any')
self._dimiss_damaged_notification()
self._diagnose_codesign_failure(sdk_app)
self._cleanup_cache = True
self._clean_xcode_cache(devicelab)
self.m.step.empty(
'Failed to install Xcode',
status=self.m.step.INFRA_FAILURE,
)
finally:
if retry:
self.m.step(
'clean temporary install path',
[
'rm',
'-rf',
install_path,
],
ok_ret='any',
)
def _diagnose_unresponsive_mac(self, devicelab):
"""To prevent mac tests from hanging due to unresponsive host, kill Setup
Assistant and reset the Launch Services database.
Also, check if `xcodebuild` commands may have potentially hung due to Launch
Services issues. If so, add entry to Launch Services reset log and print a warning.
On macOS 26 and later, `lsregister -kill` was removed so the reset is
skipped, but the log check still runs so Launch Services problems are
surfaced.
Args:
devicelab: (bool) tells the module which path we should be working with.
"""
if devicelab:
return
# Try to kill setup assistant
self._kill_setup_assistant()
with self.m.step.nest('verify launch services') as display_step:
start_time = self.now().strftime("%Y-%m-%d %H:%M:%S")
if self.macos_26_or_later:
# macOS 26 removed `lsregister -kill` ("The -kill option has been
# removed because it was dangerous and no longer useful"), so the db
# can no longer be reset from here. The log check below still runs.
# TODO(okorohelijah): Find a replacement for resetting the Launch
# Services database on macOS 26+. See
# https://github.com/flutter/flutter/issues/192645.
self.m.step.empty(
'Skipping Launch Services reset on macOS 26+',
step_text='`lsregister -kill` was removed in macOS 26.',
)
else:
self.m.step(
'Reset and rescan Launch Services db',
[
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister',
'-kill',
'-r',
'-domain',
'local',
'-domain',
'system',
'-domain',
'user',
],
)
launch_services_reset_log = self._get_xcode_base_cache_path(
devicelab
) / 'launch_services_reset_log.txt'
reset_logs = ''
last_entry_time = None
if self.m.path.exists(launch_services_reset_log):
reset_logs = self.m.file.read_text(
'Check if Launch Services db has been reset recently',
launch_services_reset_log,
)
try:
# Get last entry in log.
last_entry = reset_logs.splitlines()[-1].rstrip()
last_entry_time = datetime.strptime(last_entry, "%Y-%m-%d %H:%M:%S")
except:
# If there was an invalid date, reset the log file.
reset_logs = ''
# If last log entry was within 24 hours, only search logs since that time
# (with a 30 minute buffer). Otherwise search the last 24 hours
if last_entry_time and (self.now() - last_entry_time).days <= 1:
search_time = (last_entry_time +
timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
time_parameters = [
'--start',
search_time,
]
else:
time_parameters = [
'--last',
'24h',
]
# Check if xcodebuild commands have potentially been slowed by Launch
# Services.
xcodebuild_logs = self.m.step(
'Check if xcodebuild impacted by Launch Services',
[
'log',
'show',
*time_parameters,
'--style',
'compact',
'--predicate',
'logType == "error" AND process == "xcodebuild" AND '
'subsystem == "com.apple.launchservices" AND composedMessage '
'CONTAINS "disconnect event interruption received for service"',
],
stdout=self.m.raw_io.output_text(add_output_log=True),
)
# Remove first line, which is headers.
xcodebuild_logs_stdout = xcodebuild_logs.stdout.splitlines()[1:]
# Since Launch Services resets on every build (before macOS 26),
# xcodebuild should not continue to fail. If there are still errors here,
# print a warning and update the reset log.
if len(xcodebuild_logs_stdout) == 0:
return
display_step.status = self.m.step.INFRA_FAILURE
if self.macos_26_or_later:
display_step.step_text = 'xcodebuild reported Launch Services errors and the Launch Services db cannot be reset on macOS 26+. Please file a bug and see go/flutter-infra-macos-troubleshoot.'
else:
display_step.step_text = 'Launch Services has already been reset recently. Please file a bug and see go/flutter-infra-macos-troubleshoot.'
self.m.file.write_text(
'Update Launch Services reset log',
launch_services_reset_log,
'%s\n%s' % (reset_logs, start_time),
)
def _kill_setup_assistant(self):
"""Tries to kill Setup Assistant. Sometimes on macOS, tests will be blocked
by a Setup Assistant window. This appears to be a bug with macOS
(see b/343978626). To workaround, kill Setup Assistant."""
pgrep_result = self.m.step(
'check for Setup Assistant',
[
'pgrep',
'Setup Assistant',
],
ok_ret='any',
stdout=self.m.raw_io.output_text(add_output_log=True),
)
if pgrep_result.stdout != '':
self.m.step(
'Kill Setup Assistant',
[
'pkill',
'-9',
'Setup Assistant',
],
ok_ret='any',
)
def now(self):
"""Provide a deterministic date when running tests."""
return datetime.now() # pragma: nocover
def _ensure_sdk(self, kind, devicelab):
"""Ensures the mac_toolchain tool and OSX SDK packages are installed.
Returns Path to the installed sdk app bundle."""
app_dir = self._xcode_dir(devicelab)
tool_dir = self.m.path.mkdtemp() / 'osx_sdk' if devicelab else app_dir
sdk_app = app_dir / 'XCode.app'
self._try_install_xcode(tool_dir, kind, app_dir, sdk_app, devicelab)
self._cleanup_runtimes_cache(sdk_app)
self._install_runtimes(devicelab, app_dir, tool_dir, sdk_app, kind)
return sdk_app
def _micro_manage_cache(self, devicelab: bool):
"""Tracks the age of packages in the target cache. If older than a
specific date then delete them.
Params:
devicelab: (bool) tells the module which path we should be working with.
"""
cache_path = self._get_xcode_base_cache_path(devicelab)
app_dir = self._xcode_dir(devicelab)
self.m.step("show app_dir", ['echo', app_dir])
self._show_xcode_cache(cache_path)
self.m.cache_micro_manager.run(cache_path, [app_dir])
self._show_xcode_cache(cache_path)
def _show_xcode_cache(self, cache_path):
self.m.step(
'Show xcode cache',
['ls', '-al', cache_path],
ok_ret='any',
)
def _install_runtimes(self, devicelab, app_dir, tool_dir, sdk_app, kind):
"""Ensure runtimes are installed.
For macOS lower than 13, this involves downloading the runtime and copying
it into the Xcode app bundle.
For macOS 13 and higher, this involved downloading the runtime dmg and
running a `simctl` command to verify and mount it. This is required because
copying files into Xcode on macOS 13 may damage it and prevent it from
being usable.
Args:
devicelab: (bool) Whether this is a devicelab tasks. Don't install
explicit runtimes for devicelab tasks.
app_dir: (Path) Path to Xcode cache directory.
tool_dir: (Path) Path to mac_toolchain cache directory.
sdk_app: (Path) Path to installed Xcode app bundle.
kind ('mac'|'ios'): How the SDK should be configured.
"""
if not self._runtime_versions:
# On macOS 13, when cleanup_cache is True, we first clear the Xcode cache,
# install Xcode, and then clean up the runtimes cache. It happens in this
# order because removing runtimes requires Xcode developer tools so Xcode
# must be installed first. However, when no explicit runtimes are defined,
# the runtime is also installed in the `_try_install_xcode` function. So after
# cleaning the runtimes, the runtime that was installed may have been
# removed. So re-call `_try_install_xcode` to reinstall the removed runtime.
if self.macos_13_or_later and self._cleanup_cache:
with self.m.step.nest('install runtimes'):
self._try_install_xcode(tool_dir, kind, app_dir, sdk_app, devicelab)
return
if devicelab:
return
with self.m.step.nest('install runtimes'):
if self.macos_13_or_later:
self._select_xcode(sdk_app)
runtime_simulators = self.m.step(
'list runtimes', ['xcrun', 'simctl', 'list', 'runtimes'],
stdout=self.m.raw_io.output_text(add_output_log=True)
).stdout.splitlines()
for version in self._runtime_versions:
runtime_version_parts = version.split('_')
if len(runtime_version_parts) != 2:
self.m.step.empty(
'Invalid runtime version %s' % version,
status=self.m.step.INFRA_FAILURE,
)
runtime_version = runtime_version_parts[0]
xcode_version = runtime_version_parts[1]
# we can assume devicelab False and build this with base path.
runtime_dmg_cache_dir = self._runtime_dmg_dir_cache_path(version)
self.m.step(
'install xcode runtime %s' % version.lower(),
[
app_dir / '/mac_toolchain',
'install-runtime-dmg',
'-cipd-package-prefix',
self._xcode_cipd_package_source,
'-runtime-version',
runtime_version,
'-xcode-version',
xcode_version,
'-output-dir',
runtime_dmg_cache_dir,
],
)
downloaded_runtime_files = self.m.file.listdir(
'list xcode runtime dmg %s' % version.lower(),
runtime_dmg_cache_dir
)
# The runtime dmg may not be named consistently so search for the dmg file.
runtime_dmg_path = None
for file_path in downloaded_runtime_files:
if '.dmg' in str(file_path):
runtime_dmg_path = str(file_path)
if runtime_dmg_path is None:
self.m.step.empty(
'Failed to find runtime dmg',
status=self.m.step.INFRA_FAILURE,
)
# Skip adding the runtime if it's already mounted.
ios_runtime_build_version = self._lookup_runtime_build_version(
runtime_version, xcode_version
)
if not self._is_runtime_build_listed(ios_runtime_build_version,
runtime_simulators):
self._mount_runtime(
version, runtime_dmg_path, ios_runtime_build_version
)
else:
# Skips runtime installation if it already exists. Otherwise,
# installs each runtime version under `osx_sdk` for cache sharing,
# and then copies over to the destination.
self.m.file.ensure_directory(
'Ensuring runtimes directory', sdk_app / _RUNTIMESPATH
)
for version in self._runtime_versions:
runtime_name = 'iOS %s.simruntime' % version.lower(
).replace('ios-', '').replace('-', '.')
dest = sdk_app / _RUNTIMESPATH / runtime_name
if not self.m.path.exists(dest):
cache_base_path = self._get_xcode_base_cache_path(False)
runtime_cache_dir = (
cache_base_path / ('xcode_runtime_%s' % version.lower())
)
self.m.step(
'install xcode runtime %s' % version.lower(),
[
app_dir / 'mac_toolchain',
'install-runtime',
'-cipd-package-prefix',
_FLUTTER_XCODE_CIPD,
'-runtime-version',
version.lower(),
'-output-dir',
runtime_cache_dir,
],
)
# Move the runtimes
path_with_version = runtime_cache_dir / runtime_name
# If the runtime was the default for xcode the cipd bundle contains a directory called iOS.simruntime otherwise
# it contains a folder called "iOS <version>.simruntime".
source = path_with_version if self.m.path.exists(
path_with_version
) else runtime_cache_dir / 'iOS.simruntime'
self.m.file.copytree(
'Copy runtime to %s' % dest, source, dest, symlinks=True
)
def _xcode_dir(self, devicelab):
"""Returns the location of the xcode app in the cache dir.
For a devicelab task, the path is prefixed at `/opt/flutter/xcode`.
For a host only task without runtime, the path looks like
xcode_<xcode-version>
a host only task with runtimes, the path looks like
xcode_<xcode-version>_runtime1_<runtime1-version>_..._runtimeN_<runtimeN-version>
"""
xcode_cache_base_path = self._get_xcode_base_cache_path(devicelab)
if devicelab:
return xcode_cache_base_path / self._sdk_version
runtime_version = None
sdk_version = f"xcode_{self._sdk_version}"
if not self.macos_13_or_later and self._runtime_versions:
runtime_version = "_".join(self._runtime_versions)
sdk_version = f"{sdk_version}_runtime_{runtime_version}"
return xcode_cache_base_path / sdk_version
def _runtime_dmg_dir_cache_path(self, version):
cache_base_path = self._get_xcode_base_cache_path(False)
# this method seems to be only used for non-devicelab cache path.
return cache_base_path / ('xcode_runtime_dmg_%s' % version.lower())
def _cleanup_runtimes_cache(self, sdk_app):
"""Deletes all mounted runtimes and deletes corresponding cached runtime dmgs.
This is only used for macOS 13+ since runtimes are installed and mounted separately.
"""
if not self._cleanup_cache or not self.macos_13_or_later:
return
with self.m.step.nest('Cleaning up runtimes cache'):
self._select_xcode(sdk_app)
simulator_cleanup_result = self.m.step(
'Cleaning up mounted simulator runtimes',
[
'xcrun',
'simctl',
'runtime',
'delete',
'all',
],
raise_on_failure=False,
ok_ret='any',
stdout=self.m.raw_io.output_text(add_output_log=True),
stderr=self.m.raw_io.output_text(add_output_log=True),
)
simulator_cleanup_stdout = simulator_cleanup_result.stdout.rstrip()
simulator_cleanup_stderr = simulator_cleanup_result.stderr.rstrip()
if simulator_cleanup_stdout and 'No matching images found to delete' not in simulator_cleanup_stdout:
self.m.step.empty(
'Failed to delete runtimes',
status=self.m.step.INFRA_FAILURE,
step_text=simulator_cleanup_stdout,
)
if simulator_cleanup_stderr and 'No matching images found to delete' not in simulator_cleanup_stderr:
self.m.step.empty(
'Failed to delete runtimes',
status=self.m.step.INFRA_FAILURE,
step_text=simulator_cleanup_stderr,
)
# Determine how many runtimes can remain to consider them unmounted. For
# Xcode versions 15 or greater, 0 are allowed to remain. Otherwise 1 is
# allowed. This is because in older versions of Xcode, the runtime is
# included in Xcode, which means it won't be unmounted.
max_remaining_runtimes = 1
xcode_version = self.m.step(
'check xcode version',
[
'xcrun',
'xcodebuild',
'-version',
],
stdout=self.m.raw_io.output_text(add_output_log=True),
).stdout.splitlines()
if len(xcode_version) > 0:
xcode_version = xcode_version[0].replace("Xcode", "").strip()
parsed_xcode_version = self.m.version.parse(xcode_version)
if parsed_xcode_version >= self.m.version.parse('15.0.0'):
max_remaining_runtimes = 0
# Wait up to ~5 minutes until runtimes are unmounted and their disk
# images are fully deleted.
self.m.retry.basic_wrap(
lambda timeout: self._is_runtimes_unmounted(
max_remaining_runtimes,
timeout=timeout,
),
step_name='Wait for runtimes to unmount',
sleep=5.0,
backoff_factor=2,
max_attempts=7
)
if not self._runtime_versions:
return
for version in self._runtime_versions:
runtime_dmg_cache_dir = self._runtime_dmg_dir_cache_path(version)
self.m.file.rmtree(
'Cleaning up runtime cache %s' % version.lower(),
runtime_dmg_cache_dir
)
# pylint: disable=unused-argument
def _is_runtimes_unmounted(self, max_remaining_runtimes, timeout=None):
'''Check if more than one runtime is currently mounted or if any runtime
disk image is still being deleted. If so, raise an `InfraFailure` (which
`retry.basic_wrap` retries; if it persists, the build is an infra failure
rather than a test failure).
Args:
max_remaining_runtimes: (int) How many runtimes are allowed to remain for
it to be considered done unmounting.
'''
runtime_simulators = self._list_runtimes()
if len(runtime_simulators[1:]) > max_remaining_runtimes:
raise self.m.step.InfraFailure('Runtimes not unmounted yet')
# `simctl list runtimes` stops listing a runtime as soon as it is unmounted,
# but `simctl runtime delete` keeps the disk image registered (in the
# `Deleting` state) until it has been fully removed. Re-adding the same
# image before that finishes makes simctl register the new image as an
# unusable "Duplicate of" the old one, which is the root cause of
# https://github.com/flutter/flutter/issues/180141.
self._is_runtime_deletion_complete()
def _list_runtimes(self):
"""Runs `xcrun simctl list runtimes` and returns its output lines.
Example output:
[
"== Runtimes ==",
"iOS 16.2 (16.2 - 20C52) - com.apple.CoreSimulator.SimRuntime.iOS-16-2",
"iOS 16.4 (16.4 - 20E247) - com.apple.CoreSimulator.SimRuntime.iOS-16-4"
]
"""
return self.m.step(
'list runtimes', ['xcrun', 'simctl', 'list', 'runtimes'],
stdout=self.m.raw_io.output_text(add_output_log=True)
).stdout.splitlines()
# pylint: disable=unused-argument
def _is_runtime_deletion_complete(self, timeout=None):
"""Check if any runtime disk image is still being deleted. If so, raise an
`InfraFailure`.
Uses `xcrun simctl runtime list` (not `simctl list runtimes`), which
reports the state of each runtime disk image (e.g. `Ready`, `Deleting`,
`Unusable`).
"""
disk_images = self.m.step(
'list runtime disk images', ['xcrun', 'simctl', 'runtime', 'list'],
stdout=self.m.raw_io.output_text(add_output_log=True)
).stdout
if 'Deleting' in disk_images:
raise self.m.step.InfraFailure('Runtime disk images not deleted yet')
def _wait_for_runtime_deletion(self):
"""Waits up to ~5 minutes until no runtime disk image is being deleted."""
self.m.retry.basic_wrap(
lambda timeout: self._is_runtime_deletion_complete(timeout=timeout),
sleep=5.0,
backoff_factor=2,
max_attempts=7
)
def _mount_runtime(
self, version, runtime_dmg_path, ios_runtime_build_version
):
"""Mounts an iOS runtime dmg with `simctl runtime add`.
`simctl runtime add` fails with an `Unusable - Other Failure: ...
"Duplicate of <UDID>"` error when an image with the same identity is still
registered, typically because a previous `simctl runtime delete` of that
image has not finished yet (deletion is asynchronous). The runtime may
nevertheless be usable, so rather than failing the build outright:
1. wait for any pending deletions to finish,
2. re-check `simctl list runtimes`; if the runtime is now listed, the
duplicate error was benign,
3. otherwise delete the duplicate disk image(s) and retry the mount
once, failing the build if that also fails.
See https://github.com/flutter/flutter/issues/180141.
Args:
version: (string) The configured runtime version (e.g.
"ios-16-4_14e300c").
runtime_dmg_path: (string) Path to the runtime dmg to mount.
ios_runtime_build_version: (string) The iOS runtime build (e.g.
"20e247") used to verify whether the runtime is mounted.
"""
step_name = 'verify and mount runtime %s' % version.lower()
cmd = ['xcrun', 'simctl', 'runtime', 'add', runtime_dmg_path]
result = self.m.step(
step_name,
cmd,
ok_ret='any',
stdout=self.m.raw_io.output_text(add_output_log=True),
stderr=self.m.raw_io.output_text(add_output_log=True),
)
if result.retcode == 0:
return
output = '%s\n%s' % (result.stdout, result.stderr)
if _DUPLICATE_RUNTIME_ERROR not in output.lower():
result.presentation.status = self.m.step.INFRA_FAILURE
self.m.step.empty(
'Failed to mount runtime %s' % version.lower(),
status=self.m.step.INFRA_FAILURE,
step_text=output.strip(),
)
# The presentation can only be changed until the next step starts.
result.presentation.step_text = (
'simctl reported a "Duplicate of" error (retcode %d); see the "handle '
'duplicate runtime %s" step.' % (result.retcode, version.lower())
)
with self.m.step.nest('handle duplicate runtime %s' %
version.lower()) as display_step:
self._wait_for_runtime_deletion()
if self._is_runtime_build_listed(ios_runtime_build_version,
self._list_runtimes()):
display_step.step_text = (
'Runtime %s (%s) is already mounted; ignoring benign "Duplicate '
'of" error from simctl.' % (version, ios_runtime_build_version)
)
return
# The runtime is not usable, so the unusable duplicate (and the image it
# duplicates, which is not usable either since it is not listed) must be
# removed before the dmg can be added again.
for udid in self._parse_runtime_udids(output):
self.m.step(
'delete runtime disk image %s' % udid,
['xcrun', 'simctl', 'runtime', 'delete', udid],
ok_ret='any',
)
self._wait_for_runtime_deletion()
self.m.step('%s (retry)' % step_name, cmd)
def _parse_runtime_udids(self, output):
"""Returns the unique runtime disk image UDIDs found in `output`, in order.
Args:
output: (string) Output of a `simctl runtime` command.
"""
udids = []
for udid in _RUNTIME_UDID_RE.findall(output.upper()):
if udid not in udids:
udids.append(udid)
return udids
def _lookup_runtime_build_version(self, runtime_version, xcode_version):
"""Looks up the iOS runtime build version from CIPD.
Args:
runtime_version: (string) The iOS version (e.g. "ios-16-4").
xcode_version: (string) The Xcode version (e.g. "14e300c").
Returns:
A string of the iOS runtime build version (e.g. "20e247").
Raises:
InfraFailure if the build version could not be determined.
"""
with self.m.step.nest('cipd describe %s_%s' %
(runtime_version, xcode_version)) as display_step:
# First try to get the iOS runtime build version by the Xcode version
ios_runtime_build_version = self._get_ios_runtime_build_version(
runtime_version, xcode_version
)
# If unable to get from the Xcode version, try again by using the iOS runtime version
if ios_runtime_build_version is None:
ios_runtime_build_version = self._get_ios_runtime_build_version(
runtime_version
)
if ios_runtime_build_version is None:
self.m.step.empty(
'Failed to get runtime build version',
status=self.m.step.INFRA_FAILURE,
)
else:
self.m.step.empty(
'runtime build version', step_text=ios_runtime_build_version
)
display_step.status = self.m.step.SUCCESS
return ios_runtime_build_version
def _is_runtime_build_listed(
self, ios_runtime_build_version, runtime_simulators
):
"""Determine if an iOS runtime build is listed as usable by
`simctl list runtimes`.
Args:
ios_runtime_build_version: (string) The iOS runtime build (e.g. "20e247").
runtime_simulators: (list) Output lines of `simctl list runtimes`.
Returns:
A boolean for whether or not the runtime is mounted and usable.
"""
for runtime in runtime_simulators:
# Example runtime: `iOS 14.3 (14.3 - 18C61) - com.apple.CoreSimulator.SimRuntime.iOS-14-3`
runtime = runtime.lower()
# A runtime reported as unavailable/unusable (e.g. an unusable duplicate
# disk image) does not count as mounted; it must be (re)mounted.
if 'unavailable' in runtime or 'unusable' in runtime:
continue
if ios_runtime_build_version.lower() in runtime:
return True
return False
def _get_ios_runtime_build_version(self, runtime_version, xcode_version=None):
"""Gets the iOS runtime build version from CIPD.
If `xcode_version` is provided, it will use it to search for the CIPD package.
If not provided, the `runtime_version` will be used. In both cases, it ensures
the found package matches the `runtime_version`.
Args:
runtime_version: (string) An iOS version used to find and match the CIPD package (e.g. "ios-16-4")
xcode_version: (string) A version of Xcode to use to find CIPD package (e.g. "14e300c").
Returns:
A string of the build version or None if unable to get the build version.
"""
search_ref = runtime_version
if xcode_version is not None:
search_ref = xcode_version
try:
description = self.m.cipd.describe(
'%s/ios_runtime_dmg' % self._xcode_cipd_package_source,
search_ref,
)
runtime_tag = None
build_tag = None
for tag in description.tags:
if 'ios_runtime_version' in tag.tag:
runtime_tag = self._parse_cipd_description_tag(tag.tag)
if 'ios_runtime_build' in tag.tag:
build_tag = self._parse_cipd_description_tag(tag.tag)
if runtime_tag == runtime_version:
return build_tag
self.m.step.empty(
'mismatching runtimes',
step_text='Found %s, expected %s' % (runtime_tag, runtime_version),
status=self.m.step.INFRA_FAILURE,
)
except self.m.step.StepFailure:
pass
return None
def _parse_cipd_description_tag(self, tag):
"""Parse a colon separated CIPD description tag.
Args:
tag: (string) A colon separated string describing a CIPD tag.
(e.g "ios_runtime_build:21A5303d" or "ios_runtime_version:ios-17-0")
Returns:
A string of the value following the colon or None if unable to parse.
"""
tag_parts = tag.split(':')
if len(tag_parts) == 2:
return tag_parts[1]
return None