[K/N] Refactor Executor out of ExecutorService without gradle dependency

Merge-request: KT-MR-7622
Merged-by: Alexander Shabalin <Alexander.Shabalin@jetbrains.com>
This commit is contained in:
Alexander Shabalin
2022-11-21 19:52:29 +00:00
committed by Space Team
parent e16cfd5485
commit d512420204
10 changed files with 482 additions and 156 deletions
@@ -53,6 +53,7 @@ ext.target = platformManager.targetManager(project.testTarget).target
ext.testLibraryDir = "${ext.testOutputRoot}/klib/platform/${project.target.name}"
// Add executor to run tests depending on a target
// NOTE: If this persists in a gradle daemon, environment update (e.g. an Xcode update) may lead to execution failures.
project.extensions.executor = ExecutorServiceKt.create(project)
@@ -20,9 +20,12 @@ import com.google.gson.annotations.Expose
import groovy.lang.Closure
import org.gradle.api.Action
import org.gradle.api.Project
import org.gradle.process.ExecResult
import org.gradle.process.ExecSpec
import org.gradle.kotlin.dsl.*
import org.gradle.process.*
import org.gradle.process.internal.DefaultExecSpec
import org.gradle.process.internal.ExecException
import org.jetbrains.kotlin.konan.target.*
import org.jetbrains.kotlin.executors.*
import java.io.*
@@ -42,6 +45,47 @@ interface ExecutorService {
fun execute(action: Action<in ExecSpec>): ExecResult?
}
private fun Executor.service(project: Project) = object: ExecutorService {
override val project
get() = project
override fun execute(action: Action<in ExecSpec>): ExecResult? {
val execSpec = project.objects.newInstance<DefaultExecSpec>().apply {
action.execute(this)
}
val request = ExecuteRequest(
executableAbsolutePath = execSpec.executable,
args = execSpec.args,
).apply {
execSpec.standardInput?.let {
stdin = it
}
execSpec.standardOutput?.let {
stdout = it
}
execSpec.errorOutput?.let {
stderr = it
}
environment.putAll(execSpec.environment.mapValues { it.toString() })
}
val response = this@service.execute(request)
return object : ExecResult {
override fun getExitValue() = response.exitCode ?: -1
override fun assertNormalExitValue(): ExecResult {
if (exitValue != 0) {
throw ExecException("Failed with exit code $exitValue")
}
return this
}
override fun rethrowFailure(): ExecResult {
return this
}
}
}
}
/**
* Creates an ExecutorService depending on a test target -Ptest_target
*/
@@ -50,40 +94,17 @@ fun create(project: Project): ExecutorService {
val configurables = project.testTargetConfigurables
return when {
project.compileOnlyTests -> noopExecutor(project)
project.compileOnlyTests -> NoOpExecutor(explanation = "compile-only tests").service(project)
project.hasProperty("remote") -> sshExecutor(project, testTarget)
configurables is WasmConfigurables -> wasmExecutor(project)
configurables is ConfigurablesWithEmulator && testTarget != HostManager.host -> emulatorExecutor(project, testTarget)
configurables.targetTriple.isSimulator -> simulator(project)
configurables is WasmConfigurables -> WasmExecutor(configurables).service(project)
configurables is ConfigurablesWithEmulator && testTarget != HostManager.host -> EmulatorExecutor(configurables).service(project)
configurables is AppleConfigurables && configurables.targetTriple.isSimulator -> XcodeSimulatorExecutor(configurables).apply {
project.findProperty("iosDevice")?.toString()?.let {
deviceName = it
}
}.service(project)
supportsRunningTestsOnDevice(testTarget) -> deviceLauncher(project)
else -> localExecutorService(project)
}
}
private fun noopExecutor(project: Project) = object : ExecutorService {
override val project: Project
get() = project
override fun execute(action: Action<in ExecSpec>): ExecResult? = object : ExecResult {
override fun getExitValue(): Int = 0
override fun assertNormalExitValue(): ExecResult = this
override fun rethrowFailure(): ExecResult = this
}
}
private fun wasmExecutor(project: Project) = object : ExecutorService {
val absoluteTargetToolchain = project.testTargetConfigurables.absoluteTargetToolchain
override val project: Project get() = project
override fun execute(action: Action<in ExecSpec>): ExecResult? = project.exec {
action.execute(this)
val exe = executable
val d8 = "$absoluteTargetToolchain/bin/d8"
val launcherJs = "$executable.js"
this.executable = d8
this.args = listOf("--expose-wasm", launcherJs, "--", exe) + args
else -> HostExecutor().service(project)
}
}
@@ -202,118 +223,6 @@ fun localExecutorService(project: Project): ExecutorService = object : ExecutorS
override fun execute(action: Action<in ExecSpec>): ExecResult? = project.exec(action)
}
private fun emulatorExecutor(project: Project, target: KonanTarget) = object : ExecutorService {
val configurables = project.testTargetConfigurables as? ConfigurablesWithEmulator
?: error("$target does not support emulation!")
val absoluteTargetSysRoot = configurables.absoluteTargetSysRoot
override val project: Project get() = project
override fun execute(action: Action<in ExecSpec>): ExecResult? = project.exec {
action.execute(this)
val exe = executable
// TODO: Move these to konan.properties when when it will be possible
// to represent absolute path there.
val qemuSpecificArguments = listOf("-L", absoluteTargetSysRoot)
val targetSpecificArguments = when (target) {
KonanTarget.LINUX_MIPS32,
KonanTarget.LINUX_MIPSEL32 -> {
// This is to workaround an endianess issue.
// See https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=731082 for details.
listOf("$absoluteTargetSysRoot/lib/ld.so.1", "--inhibit-cache")
}
else -> emptyList()
}
executable = configurables.absoluteEmulatorExecutable
args = qemuSpecificArguments + targetSpecificArguments + exe + args
}
}
/**
* Executes a given action with iPhone Simulator.
*
* The test target should be specified with -Ptest_target=ios_x64
* @see KonanTarget.IOS_X64
* @param iosDevice an optional project property used to control simulator's device type
* Specify -PiosDevice=iPhone X to set it
*/
@Suppress("KDocUnresolvedReference")
private fun simulator(project: Project): ExecutorService = object : ExecutorService {
private val target = project.testTarget
val configurables = project.testTargetConfigurables as AppleConfigurables
init {
require(configurables.targetTriple.isSimulator) {
"${configurables.target} is not a simulator."
}
val compatibleArchs = when (HostManager.host.architecture) {
Architecture.X64 -> listOf(Architecture.X64, Architecture.X86)
Architecture.ARM64 -> listOf(Architecture.ARM64, Architecture.ARM32)
else -> throw IllegalStateException("$target is not supported by the simulator")
}
require(configurables.target.architecture in compatibleArchs) {
"Can't run simulator with ${configurables.target.architecture} architecture."
}
}
private val simctl by lazy {
val sdk = Xcode.findCurrent().pathToPlatformSdk(configurables.platformName())
val out = ByteArrayOutputStream()
val result = project.exec {
commandLine("/usr/bin/xcrun", "--find", "simctl", "--sdk", sdk)
standardOutput = out
}
result.assertNormalExitValue()
out.toString("UTF-8").trim()
}
private val device by lazy {
val version = Xcode.findCurrent().version
val default = project.findProperty("iosDevice")?.toString() ?: when (target.family) {
Family.TVOS -> "Apple TV 4K"
Family.IOS -> "iPhone 11"
Family.WATCHOS -> "Apple Watch Series 6 " + (if (version.startsWith("14")) "(40mm)" else "- 40mm")
else -> error("Unexpected simulation target: $target")
}
// Find out if the default device is available
val out = ByteArrayOutputStream()
var result = project.exec {
commandLine("/usr/bin/xcrun", "simctl", "list", "devices", "available")
standardOutput = out
}
result.assertNormalExitValue()
// Create if it's not available
if (!out.toString("UTF-8").trim().contains(default)) {
out.reset()
result = project.exec {
commandLine("/usr/bin/xcrun", "simctl", "create", default, default)
standardOutput = out
}
result.assertNormalExitValue()
}
default
}
private val archSpecification = when (target.architecture) {
Architecture.X86 -> listOf("-a", "i386")
Architecture.X64 -> listOf() // x86-64 is used by default on Intel Macs.
Architecture.ARM64 -> listOf() // arm64 is used by default on Apple Silicon.
else -> error("${target.architecture} can't be used in simulator.")
}.toTypedArray()
override val project: Project get() = project
override fun execute(action: Action<in ExecSpec>): ExecResult? = project.exec {
action.execute(this)
// Starting Xcode 11 `simctl spawn` requires explicit `--standalone` flag.
commandLine = listOf(simctl, "spawn", "--standalone", *archSpecification, device, executable) + args
}
}
/**
* Remote process executor.
*
@@ -306,6 +306,7 @@ open class CompileToBitcodeExtension @Inject constructor(val project: Project) :
reportFile.set(project.layout.buildDirectory.file("testReports/$testName/report-with-prefixes.xml"))
filter.set(project.findProperty("gtest_filter") as? String)
tsanSuppressionsFile.set(project.layout.projectDirectory.file("tsan_suppressions.txt"))
this.target.set(target)
usesService(runGTestSemaphore)
}
@@ -9,10 +9,13 @@ import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.gradle.kotlin.dsl.getByType
import org.gradle.process.ExecOperations
import org.gradle.workers.WorkAction
import org.gradle.workers.WorkParameters
import org.gradle.workers.WorkerExecutor
import org.jetbrains.kotlin.executors.*
import org.jetbrains.kotlin.konan.target.*
import javax.inject.Inject
private abstract class RunGTestJob : WorkAction<RunGTestJob.Parameters> {
@@ -23,29 +26,40 @@ private abstract class RunGTestJob : WorkAction<RunGTestJob.Parameters> {
val reportFileUnprocessed: RegularFileProperty
val filter: Property<String>
val tsanSuppressionsFile: RegularFileProperty
val platformManager: Property<PlatformManager>
// TODO: Figure out a way to pass KonanTarget, but it is used as a key into PlatformManager,
// so object identity matters, and platform managers are different between project and worker sides.
val targetName: Property<String>
}
@get:Inject
abstract val execOperations: ExecOperations
// The `Executor` is created for every `RunGTest` task execution. It's okay, testing tasks are few-ish and big.
private val executor: Executor by lazy {
val platformManager = parameters.platformManager.get()
val target = platformManager.targetByName(parameters.targetName.get())
val configurables = platformManager.platform(target).configurables
val hostTarget = HostManager.host
when {
target == hostTarget -> HostExecutor()
configurables is AppleConfigurables && configurables.targetTriple.isSimulator -> XcodeSimulatorExecutor(configurables)
else -> error("Cannot run for target $target")
}
}
override fun execute() {
// TODO: Use ExecutorService to allow running it on targets other than host.
// TODO: Try to make it like other gradle test tasks: report progress in a way gradle understands instead of dumping stdout of gtest.
with(parameters) {
reportFileUnprocessed.asFile.get().parentFile.mkdirs()
execOperations.exec {
executable = this@with.executable.asFile.get().absolutePath
executor.execute(ExecuteRequest(this@with.executable.asFile.get().absolutePath).apply {
this.args.add("--gtest_output=xml:${reportFileUnprocessed.asFile.get().absolutePath}")
filter.orNull?.also {
args("--gtest_filter=${it}")
this.args.add("--gtest_filter=${it}")
}
args("--gtest_output=xml:${reportFileUnprocessed.asFile.get().absolutePath}")
tsanSuppressionsFile.orNull?.also {
environment("TSAN_OPTIONS", "suppressions=${it.asFile.absolutePath}")
this.environment.put("TSAN_OPTIONS", "suppressions=${it.asFile.absolutePath}")
}
}
}).assertSuccess()
reportFile.asFile.get().parentFile.mkdirs()
@@ -65,6 +79,7 @@ private abstract class RunGTestJob : WorkAction<RunGTestJob.Parameters> {
*
* @see CompileToBitcodePlugin
*/
@UntrackedTask(because = "Test executables must always run when asked to")
abstract class RunGTest : DefaultTask() {
/**
* Decorating test names in the report.
@@ -117,6 +132,9 @@ abstract class RunGTest : DefaultTask() {
@get:Inject
protected abstract val workerExecutor: WorkerExecutor
@get:Input
abstract val target: Property<KonanTarget>
@TaskAction
fun run() {
val workQueue = workerExecutor.noIsolation()
@@ -128,6 +146,8 @@ abstract class RunGTest : DefaultTask() {
reportFileUnprocessed.set(this@RunGTest.reportFileUnprocessed)
filter.set(this@RunGTest.filter)
tsanSuppressionsFile.set(this@RunGTest.tsanSuppressionsFile)
platformManager.set(project.extensions.getByType<PlatformManager>())
targetName.set(this@RunGTest.target.get().name)
}
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.executors
import org.jetbrains.kotlin.konan.target.Configurables
import org.jetbrains.kotlin.konan.target.ConfigurablesWithEmulator
import org.jetbrains.kotlin.konan.target.KonanTarget
import java.io.File
/**
* [Executor] that runs the process in an emulator (e.g. qemu).
*
* @param configurables [Configurables] for emulated target
*/
class EmulatorExecutor(
private val configurables: ConfigurablesWithEmulator,
) : Executor {
private val hostExecutor: Executor = HostExecutor()
override fun execute(request: ExecuteRequest): ExecuteResponse {
val absoluteTargetSysRoot = configurables.absoluteTargetSysRoot
val workingDirectory = request.workingDirectory ?: File(request.executableAbsolutePath).parentFile
return hostExecutor.execute(request.copying {
this.executableAbsolutePath = configurables.absoluteEmulatorExecutable
this.workingDirectory = workingDirectory
this.args.add(0, request.executableAbsolutePath)
when (configurables.target) {
KonanTarget.LINUX_MIPS32,
KonanTarget.LINUX_MIPSEL32 -> {
// This is to workaround an endianess issue.
// See https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=731082 for details.
this.args.addAll(0, listOf("$absoluteTargetSysRoot/lib/ld.so.1", "--inhibit-cache"))
}
else -> Unit
}
// TODO: Move these to konan.properties when when it will be possible
// to represent absolute path there.
this.args.addAll(0, listOf("-L", absoluteTargetSysRoot))
})
}
}
@@ -0,0 +1,99 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@file:OptIn(kotlin.time.ExperimentalTime::class)
package org.jetbrains.kotlin.executors
import java.io.*
import kotlin.time.Duration
private class CloseProtectedOutputStream(stream : OutputStream) : FilterOutputStream(stream) {
override fun close() {
// Make sure all the data is written out.
super.flush()
// Exchange out for a new dummy stream.
this.out = ByteArrayOutputStream()
// And now properly close the stream from above.
super.close()
}
}
data class ExecuteRequest(
/**
* Path to the executable.
*/
var executableAbsolutePath: String,
/**
* Command line args.
*/
val args: MutableList<String> = mutableListOf(),
/**
* Optional working directory. By default its the parent directory of [executableAbsolutePath].
*/
var workingDirectory: File? = null,
/**
* Will be sent to the executable input and then closed. By default an empty stream.
*/
var stdin: InputStream = ByteArrayInputStream(byteArrayOf()),
/**
* The output stream of the executable will be sent to this stream and then this stream will be closed. By default stdout of current process.
*/
var stdout: OutputStream = CloseProtectedOutputStream(System.out),
/**
* The error stream of the executable will be sent to this stream and then this stream will be closed. By default stderr of current process.
*/
var stderr: OutputStream = CloseProtectedOutputStream(System.err),
/**
* Additional environment variables.
*/
val environment: MutableMap<String, String> = mutableMapOf(),
/**
* Bound execution time of the process. By default it's [Duration.INFINITE] meaning it's unbounded.
*/
val timeout: Duration = Duration.INFINITE
) {
/**
* Create a copy of this [ExecuteRequest], modify the copy by running [block] on it, and return that copy.
*/
inline fun copying(block: ExecuteRequest.() -> Unit): ExecuteRequest = copy().apply(block)
}
data class ExecuteResponse(
/**
* Process exit code if it exited by itself, or `null` if it was killed by a timeout.
*/
val exitCode: Int?,
/**
* Execution time of the process. Can be a bit bigger than [ExecuteRequest.timeout].
*/
val executionTime: Duration,
) {
/**
* Checks that [exitCode] is `0`.
*
* @throws IllegalStateException if [exitCode] is not 0.
*/
fun assertSuccess(): ExecuteResponse {
check(exitCode == 0) { "Exited with code $exitCode" }
return this
}
}
/**
* Run a process in a specific execution environment.
*
* See subclasses for supported execution environments.
*
* NOTE: an [Executor] may cache some details about the environment (e.g. availability of an Xcode simulator).
* So, if the [Executor] lives in a long-running process (e.g. gradle daemon), it will miss environment changes. In that case,
* consider just recreating [Executor].
*/
interface Executor {
/**
* Run the process and wait for its completion.
*/
fun execute(request: ExecuteRequest): ExecuteResponse
}
@@ -0,0 +1,82 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@file:OptIn(ExperimentalTime::class)
package org.jetbrains.kotlin.executors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.logging.Logger
import kotlin.time.*
/**
* [Executor] that runs the process on the host system.
*/
class HostExecutor : Executor {
private val logger = Logger.getLogger(HostExecutor::class.java.name)
override fun execute(request: ExecuteRequest): ExecuteResponse {
return runBlocking(Dispatchers.IO) {
val workingDirectory = request.workingDirectory ?: File(request.executableAbsolutePath).parentFile
val commandLine = "${request.executableAbsolutePath}${request.args.joinToString(separator = " ", prefix = " ")}"
logger.info("""
|Starting command: $commandLine
|In working directory: ${workingDirectory.absolutePath}
|With additional environment: ${request.environment.entries.joinToString(prefix = "{", postfix = "}") { "\"${it.key}\": \"${it.value}\"" }}
|And timeout: ${request.timeout}
""".trimMargin())
val exitCodeWithTime = measureTimedValue {
val process = ProcessBuilder(listOf(request.executableAbsolutePath) + request.args).apply {
directory(workingDirectory)
environment().putAll(request.environment)
}.start()
val jobs: MutableList<Job> = mutableListOf()
jobs.add(launch {
request.stdin.apply {
copyTo(process.outputStream)
close()
}
process.outputStream.close()
})
jobs.add(launch {
request.stdout.apply {
process.inputStream.copyTo(this)
close()
}
process.inputStream.close()
})
jobs.add(launch {
request.stderr.apply {
process.errorStream.copyTo(this)
close()
}
process.errorStream.close()
})
if (!process.waitFor(request.timeout.toLong(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS)) {
logger.warning("Timeout running $commandLine")
// Cancel every stream, no need to wait for them.
jobs.forEach {
it.cancel()
}
process.destroyForcibly()
null
} else {
// Drain every stream.
jobs.forEach {
it.join()
}
process.exitValue()
}
}
ExecuteResponse(exitCodeWithTime.value, exitCodeWithTime.duration)
}
}
}
@@ -0,0 +1,36 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@file:OptIn(kotlin.time.ExperimentalTime::class)
package org.jetbrains.kotlin.executors
import java.io.File
import java.util.logging.Logger
import kotlin.time.Duration
/**
* [Executor] that does not run the process and immediately returns a successful response.
*
* Can be used for "compile-only" tests that don't need to actually execute the binary.
*
* @param explanation optional explanation for skipping execution for the logs
*/
class NoOpExecutor(
private var explanation: String? = null,
) : Executor {
private val logger = Logger.getLogger(NoOpExecutor::class.java.name)
override fun execute(request: ExecuteRequest): ExecuteResponse {
val workingDirectory = request.workingDirectory ?: File(request.executableAbsolutePath).parentFile
logger.info("""
|Skipping execution${explanation?.let { " ($it)" } ?: ""}
|Command: ${request.executableAbsolutePath}${request.args.joinToString(separator = " ", prefix = " ")}
|In working directory: ${workingDirectory.absolutePath}
|With additional environment: ${request.environment.entries.joinToString(prefix = "{", postfix = "}") { "\"${it.key}\": \"${it.value}\"" }}
""".trimMargin())
return ExecuteResponse(exitCode = 0, executionTime = Duration.ZERO)
}
}
@@ -0,0 +1,36 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.executors
import org.jetbrains.kotlin.konan.target.Configurables
import org.jetbrains.kotlin.konan.target.WasmConfigurables
import java.io.File
/**
* [Executor] that runs the wasm process via `d8`.
*
* NOTE: Apart from [ExecuteRequest.executableAbsolutePath] a JS launcher is required,
* it must be located in the same folder with the same name and `.js` suffix appended.
*
* @param configurables [Configurables] for the wasm target
*/
class WasmExecutor(
private val configurables: WasmConfigurables,
) : Executor {
private val hostExecutor: Executor = HostExecutor()
override fun execute(request: ExecuteRequest): ExecuteResponse {
val absoluteTargetToolchain = configurables.absoluteTargetToolchain
val workingDirectory = request.workingDirectory ?: File(request.executableAbsolutePath).parentFile
val executable = request.executableAbsolutePath
val launcherJs = "$executable.js"
return hostExecutor.execute(request.copying {
this.executableAbsolutePath = "$absoluteTargetToolchain/bin/d8"
this.workingDirectory = workingDirectory
this.args.addAll(0, listOf("--expose-wasm", launcherJs, "--", executable))
})
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2010-2022 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.executors
import org.jetbrains.kotlin.konan.target.*
import java.io.ByteArrayOutputStream
import java.io.File
private fun defaultDeviceName(target: KonanTarget) = when (target.family) {
Family.TVOS -> "Apple TV 4K"
Family.IOS -> "iPhone 11"
Family.WATCHOS -> "Apple Watch Series 6 " + (if (Xcode.findCurrent().version.startsWith("14")) "(40mm)" else "- 40mm")
else -> error("Unexpected simulation target: $target")
}
private fun Executor.run(executableAbsolutePath: String, vararg args: String) = ByteArrayOutputStream().let {
this.execute(ExecuteRequest(executableAbsolutePath).apply {
this.args.addAll(args)
stdout = it
workingDirectory = File("").absoluteFile
}).assertSuccess()
it
}
/**
* [Executor] that runs the process in an Xcode simulator.
*
* @param configurables [Configurables] for simulated target
* @property deviceName which simulator to use (optional). When not provided the default simulator for [configurables.target] is used
*/
class XcodeSimulatorExecutor(
private val configurables: AppleConfigurables,
var deviceName: String = defaultDeviceName(configurables.target),
) : Executor {
private val hostExecutor: Executor = HostExecutor()
private val target by configurables::target
init {
require(configurables.targetTriple.isSimulator) {
"$target is not a simulator."
}
val hostArch = HostManager.host.architecture
val targetArch = target.architecture
val compatibleArchs = when (hostArch) {
Architecture.X64 -> listOf(Architecture.X64, Architecture.X86)
Architecture.ARM64 -> listOf(Architecture.ARM64, Architecture.ARM32)
else -> throw IllegalStateException("$hostArch is not a supported host architecture for the simulator")
}
require(targetArch in compatibleArchs) {
"Can't run simulator for $targetArch architecture on $hostArch host architecture"
}
}
private val archSpecification = when (target.architecture) {
Architecture.X86 -> listOf("-a", "i386")
Architecture.X64 -> listOf() // x86-64 is used by default on Intel Macs.
Architecture.ARM64 -> listOf() // arm64 is used by default on Apple Silicon.
else -> error("${target.architecture} can't be used in simulator.")
}.toTypedArray()
private var _deviceNameChecked: String? = null
private fun ensureSimulatorExists() {
// Already ensured that simulator for `deviceName` exists.
if (deviceName == _deviceNameChecked)
return
// Find out if the default device is available
val out = hostExecutor.run("/usr/bin/xcrun", "simctl", "list", "devices", "available")
val deviceNameExists = out.toString("UTF-8").trim().contains(deviceName)
// Create if it's not available
if (!deviceNameExists) {
hostExecutor.run("/usr/bin/xcrun", "simctl", "create", deviceName, deviceName)
}
// If successfully created, remember that.
_deviceNameChecked = deviceName
}
override fun execute(request: ExecuteRequest): ExecuteResponse {
ensureSimulatorExists()
val executable = request.executableAbsolutePath
val env = request.environment.mapKeys {
"SIMCTL_CHILD_" + it.key
}
val workingDirectory = request.workingDirectory ?: File(request.executableAbsolutePath).parentFile
// Starting Xcode 11 `simctl spawn` requires explicit `--standalone` flag.
return hostExecutor.execute(request.copying {
this.executableAbsolutePath = "/usr/bin/xcrun"
this.workingDirectory = workingDirectory
this.args.addAll(0, listOf("simctl", "spawn", "--standalone", *archSpecification, deviceName, executable))
this.environment.clear()
this.environment.putAll(env)
})
}
}