[K/N] Make executors be separate included project

This change will make possible use Executors in old, gtest and new
test infrastructures.

Merge-request: KT-MR-8964
This commit is contained in:
Pavel Punegov
2023-02-07 19:49:11 +01:00
committed by Space Team
parent c105e63bbb
commit 76ab130011
15 changed files with 91 additions and 20 deletions
+53
View File
@@ -0,0 +1,53 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
buildscript {
dependencies {
classpath("com.google.code.gson:gson:2.8.9")
}
}
plugins {
kotlin("jvm")
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.google.code.gson:gson:2.8.9")
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group == "com.google.code.gson" && requested.name == "gson") {
useVersion("2.8.9")
because("Force using same gson version because of https://github.com/google/gson/pull/1991")
}
}
}
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
if (kotlinBuildProperties.isKotlinNativeEnabled) {
implementation(project(":kotlin-native-shared"))
} else {
implementation(project(":native:kotlin-native-utils"))
}
}
group = "org.jetbrains.kotlin"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(8))
}
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs += listOf(
"-Xskip-prerelease-check",
"-Xsuppress-version-warnings",
"-opt-in=kotlin.ExperimentalStdlibApi",
"-opt-in=kotlin.RequiresOptIn"
)
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2010-2023 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.native.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,107 @@
/*
* Copyright 2010-2023 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.native.executors
import java.io.*
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
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)
}
/**
* Helper for [ExecuteRequest] creation.
* Allows to create it without opting in [kotlin.time.ExperimentalTime] for API < 1.6.
*/
fun executeRequest(executableAbsolutePath: String, args: MutableList<String> = mutableListOf()): ExecuteRequest =
ExecuteRequest(executableAbsolutePath, args)
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-2023 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.native.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.inWholeMilliseconds, 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,37 @@
/*
* Copyright 2010-2023 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.native.executors
import java.io.File
import java.util.logging.Logger
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
/**
* [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-2023 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.native.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,138 @@
/*
* Copyright 2010-2023 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.native.executors
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.annotations.Expose
import org.jetbrains.kotlin.konan.target.Family
import org.jetbrains.kotlin.konan.target.Xcode
import kotlin.math.min
internal val gson: Gson by lazy { GsonBuilder().excludeFieldsWithoutExposeAnnotation().create()!! }
/**
* Compares two strings assuming that both are representing numeric version strings.
* Examples of numeric version strings: "12.4.1.2", "9", "0.5".
*/
private fun compareStringsAsVersions(version1: String, version2: String): Int {
val splitVersion1 = version1.split('.').map { it.toInt() }
val splitVersion2 = version2.split('.').map { it.toInt() }
val minimalLength = min(splitVersion1.size, splitVersion2.size)
for (index in 0 until minimalLength) {
if (splitVersion1[index] < splitVersion2[index]) return -1
if (splitVersion1[index] > splitVersion2[index]) return 1
}
return splitVersion1.size.compareTo(splitVersion2.size)
}
internal fun simulatorOsName(family: Family): String {
return when (family) {
Family.IOS -> "iOS"
Family.WATCHOS -> "watchOS"
Family.TVOS -> "tvOS"
else -> error("Unexpected simulator OS: $family")
}
}
/**
* Returns parsed output of `xcrun simctl list runtimes -j`.
*/
private fun Xcode.getSimulatorRuntimeDescriptors(): List<SimulatorRuntimeDescriptor> =
gson.fromJson(simulatorRuntimes, ListRuntimesReport::class.java).runtimes
private fun getSimulatorRuntimeDescriptors(json: String): List<SimulatorRuntimeDescriptor> =
gson.fromJson(json, ListRuntimesReport::class.java).runtimes
private fun getLatestSimulatorRuntimeFor(
descriptors: List<SimulatorRuntimeDescriptor>,
family: Family,
osMinVersion: String
): SimulatorRuntimeDescriptor? {
val osName = simulatorOsName(family)
return descriptors.firstOrNull {
it.checkAvailability() && it.name.startsWith(osName) && compareStringsAsVersions(it.version, osMinVersion) >= 0
}
}
private fun getSimulatorRuntimesFor(
descriptors: List<SimulatorRuntimeDescriptor>,
family: Family,
osMinVersion: String
): List<SimulatorRuntimeDescriptor> {
val osName = simulatorOsName(family)
return descriptors.filter {
it.checkAvailability() && it.name.startsWith(osName) && compareStringsAsVersions(it.version, osMinVersion) >= 0
}
}
/**
* Returns first available simulator runtime for [target] with at least [osMinVersion] OS version.
* */
fun Xcode.getLatestSimulatorRuntimeFor(family: Family, osMinVersion: String): SimulatorRuntimeDescriptor? =
getLatestSimulatorRuntimeFor(getSimulatorRuntimeDescriptors(), family, osMinVersion)
fun getLatestSimulatorRuntimeFor(json: String, family: Family, osMinVersion: String): SimulatorRuntimeDescriptor? =
getLatestSimulatorRuntimeFor(getSimulatorRuntimeDescriptors(json), family, osMinVersion)
fun getSimulatorRuntimesFor(json: String, family: Family, osMinVersion: String): List<SimulatorRuntimeDescriptor> =
getSimulatorRuntimesFor(getSimulatorRuntimeDescriptors(json), family, osMinVersion)
// Result of `xcrun simctl list runtimes -j`.
data class ListRuntimesReport(
@Expose val runtimes: List<SimulatorRuntimeDescriptor>
)
data class SimulatorRuntimeDescriptor(
@Expose val version: String,
// bundlePath field may not exist in the old Xcode (prior to 10.3).
@Expose val bundlePath: String? = null,
@Expose val isAvailable: Boolean? = null,
@Expose val availability: String? = null,
@Expose val name: String,
@Expose val identifier: String,
@Expose val buildversion: String,
@Expose val supportedDeviceTypes: List<DeviceType>
) {
/**
* Different Xcode/macOS combinations give different fields that checks
* runtime availability. This method is an umbrella for these fields.
*/
fun checkAvailability(): Boolean {
if (isAvailable == true) return true
if (availability?.contains("unavailable") == true) return false
return false
}
}
data class DeviceType(
@Expose val bundlePath: String,
@Expose val name: String,
@Expose val identifier: String,
@Expose val productFamily: String
)
/**
* Returns map of simulator devices from the json input
*/
fun getSimulatorDevices(json: String): Map<String, List<SimulatorDeviceDescriptor>> =
gson.fromJson(json, ListDevicesReport::class.java).devices
// Result of `xcrun simctl list devices -j`
data class ListDevicesReport(
@Expose val devices: Map<String, List<SimulatorDeviceDescriptor>>
)
data class SimulatorDeviceDescriptor(
@Expose val lastBootedAt: String?,
@Expose val dataPath: String,
@Expose val dataPathSize: Long,
@Expose val logPath: String,
@Expose val udid: String,
@Expose val isAvailable: Boolean?,
@Expose val deviceTypeIdentifier: String,
@Expose val state: String,
@Expose val name: String
)
@@ -0,0 +1,181 @@
/*
* Copyright 2010-2023 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.native.executors
import org.jetbrains.kotlin.konan.target.*
import org.jetbrains.kotlin.*
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.logging.Logger
private fun defaultDeviceId(target: KonanTarget) = when (target.family) {
Family.TVOS -> "com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-4K"
Family.IOS -> "com.apple.CoreSimulator.SimDeviceType.iPhone-14"
Family.WATCHOS -> "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-6-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 deviceId: String = defaultDeviceId(configurables.target),
) : Executor {
private val hostExecutor: Executor = HostExecutor()
private val logger = Logger.getLogger(this::class.java.name)
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 fun simctl(vararg args: String): String {
val out = hostExecutor.run("/usr/bin/xcrun", *arrayOf("simctl", *args))
return out.toString("UTF-8").trim()
}
private var deviceChecked: SimulatorDeviceDescriptor? = null
private fun ensureSimulatorExists() {
// Already ensured that simulator for `deviceId` exists.
if (deviceId == deviceChecked?.deviceTypeIdentifier) {
logger.info("Device already exists: ${deviceChecked?.deviceTypeIdentifier} with name ${deviceChecked?.name}")
return
}
logger.info("Device was not cheked before. Find the device with id: $deviceId")
val simulatorRuntime = getSimulatorRuntime()
logger.info("Runtime used for the $deviceId is $simulatorRuntime")
val device = getDeviceFor(simulatorRuntime)
logger.info("Found device: $device")
// If successfully found, remember that.
deviceChecked = device
}
private fun getDeviceFor(simulatorRuntime: SimulatorRuntimeDescriptor): SimulatorDeviceDescriptor {
val runtimeIdentifier = simulatorRuntime.identifier
val deviceOrNull = {
getSimulatorDevices(simctl("list", "devices", "--json"))[runtimeIdentifier]
?.find { it.deviceTypeIdentifier == deviceId && it.isAvailable == true }
}
// If the device already exists, nothing to do.
deviceOrNull()?.let { return it }
// Create the device
logger.info("Current runtime doesn't have $deviceId available")
val deviceType = simulatorRuntime.supportedDeviceTypes.find { it.identifier == deviceId }
checkNotNull(deviceType) {
"""
Default device $deviceId is not available for the runtime ${simulatorRuntime.name}
Supported devices: ${simulatorRuntime.supportedDeviceTypes.map { it.identifier }.joinToString(", ")}
""".trimIndent()
}
val out = simctl("create", deviceType.name, deviceType.identifier, runtimeIdentifier)
logger.info("Create device $deviceId: simctl output is: $out")
// Return freshly created device if it fits
deviceOrNull()?.let { return it }
// Looks like device is unavailable. This may happen if
// `~/Library/Developer/CoreSimulator/RuntimeMap.plist` has empty preferred runtimes
logger.info("Runtimes are available but devices can't use it. Download runtimes again with Xcode")
downloadRuntimeFor(simulatorOsName(target.family))
return checkNotNull(deviceOrNull()) { "Unable to get or create simulator device $deviceId for $target with runtime ${simulatorRuntime.name}" }
}
private fun getSimulatorRuntime(): SimulatorRuntimeDescriptor {
val simulatorRuntimeOrNull = {
getSimulatorRuntimesFor(
json = simctl("list", "runtimes", "--json"),
family = target.family,
osMinVersion = configurables.osVersionMin
).firstOrNull {
it.supportedDeviceTypes.any { deviceType -> deviceType.identifier == deviceId }
}
}
// If the simulator runtime already exists, nothing to do.
simulatorRuntimeOrNull()?.let { return it }
// Download this platform if not available
logger.info("Runtime for the $deviceId is not available. Downloading runtimes for $target with Xcode")
downloadRuntimeFor(simulatorOsName(target.family))
return checkNotNull(simulatorRuntimeOrNull()) { "Runtime is not available for the selected $deviceId. Check Xcode installation" }
}
private fun downloadRuntimeFor(osName: String) {
val version = Xcode.findCurrent().version
val versionSplit = version.split(".")
check(versionSplit.size >= 2) {
"Unrecognised version of Xcode $version was split to $versionSplit"
}
val major = versionSplit[0].toInt()
val minor = versionSplit[1].toInt()
check(major >= 14) {
"Was unable to get the required runtimes running on Xcode $version. Check the Xcode installation"
}
if (minor >= 1) {
// Option -downloadPlatform NAME available only since 14.1
hostExecutor.run("/usr/bin/xcrun", "xcodebuild", "-downloadPlatform", osName)
} else {
// Have to download all platforms :(
hostExecutor.run("/usr/bin/xcrun", "xcodebuild", "-downloadAllPlatforms")
}
}
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
val name = deviceChecked?.name ?: error("No device available for $deviceId")
// 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, name, executable))
this.environment.clear()
this.environment.putAll(env)
})
}
}