Repository: analysis-dev/diktat (kotlin)

Issue:
FNFE: "File system element for parameter 'source' does not exist" when executing `DiktatSaveSmokeTest.beforeAll`
The problem started to occur after 54c8ccefc3009b8dd2a493c30590168318765b89, initially on my _TeamCity_ instance. Now, I encounter the  same problem trying to [perform a release](https://github.com/saveourtool/diktat/runs/7503805748):

```stacktrace
Error:  org.cqfn.diktat.ruleset.smoke.DiktatSaveSmokeTest  Time elapsed: 4.207 s  <<< ERROR!
java.io.FileNotFoundException: File system element for parameter 'source' does not exist: ''
	at org.apache.commons.io.FileUtils.requireExistsChecked(FileUtils.java:2719)
	at org.apache.commons.io.FileUtils.requireFileCopy(FileUtils.java:2751)
	at org.apache.commons.io.FileUtils.copyFile(FileUtils.java:840)
	at org.apache.commons.io.FileUtils.copyFile(FileUtils.java:756)
	at org.cqfn.diktat.ruleset.smoke.DiktatSaveSmokeTest$Companion.beforeAll$diktat_rules(DiktatSaveSmokeTest.kt:114)
	at org.cqfn.diktat.ruleset.smoke.DiktatSaveSmokeTest.beforeAll$diktat_rules(DiktatSaveSmokeTest.kt)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:725)
	...
	at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86)
	at org.apache.maven.surefire.junitplatform.LazyLauncher.execute(LazyLauncher.java:55)
	at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:223)
	at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:175)
	at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:139)
	at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:456)
	at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:169)
	at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:595)
	at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:581)
```

Pull request:
### What's done:

 * `JAVA_HOME` is now correctly set for child processes (works around
   compatibility problems with Java 17).
 * Added the retry logic for download and delete operations.
 * Added test timeouts of 20s.
 * Added debug logging.
 * Dropped the dependency on Apache HttpClient (not really necessary).
 * See #1476.

Patch:
diff --git a/diktat-rules/pom.xml b/diktat-rules/pom.xml
index 5f5e28c1a5..40d0bd03a5 100644
--- a/diktat-rules/pom.xml
+++ b/diktat-rules/pom.xml
@@ -15,7 +15,6 @@
     <properties>
         <maven.compiler.source>1.8</maven.compiler.source>
         <maven.compiler.target>1.8</maven.compiler.target>
-        <apache.httpclient.version>4.5.13</apache.httpclient.version>
     </properties>
 
     <dependencies>
@@ -94,11 +93,6 @@
             <groupId>com.bpodgursky</groupId>
             <artifactId>jbool_expressions</artifactId>
         </dependency>
-        <dependency>
-            <groupId>org.apache.httpcomponents</groupId>
-            <artifactId>httpclient</artifactId>
-            <version>${apache.httpclient.version}</version>
-        </dependency>
     </dependencies>
 
     <build>


Test patch:
diff --git a/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSaveSmokeTest.kt b/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSaveSmokeTest.kt
index 1aa21c7036..3b404fe869 100644
--- a/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSaveSmokeTest.kt
+++ b/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSaveSmokeTest.kt
@@ -1,22 +1,35 @@
 package org.cqfn.diktat.ruleset.smoke
 
+import org.cqfn.diktat.common.utils.loggerWithKtlintConfig
 import org.cqfn.diktat.util.SAVE_VERSION
-import org.apache.commons.io.FileUtils
-import org.apache.http.client.methods.CloseableHttpResponse
-import org.apache.http.client.methods.HttpGet
-import org.apache.http.impl.client.HttpClients
+import org.cqfn.diktat.util.deleteIfExistsSilently
+import org.cqfn.diktat.util.prependPath
+import org.cqfn.diktat.util.retry
+
+import mu.KotlinLogging
+import org.assertj.core.api.Assertions.fail
+import org.assertj.core.api.SoftAssertions.assertSoftly
 import org.junit.jupiter.api.AfterAll
-import org.junit.jupiter.api.Assertions
 import org.junit.jupiter.api.BeforeAll
 import org.junit.jupiter.api.condition.DisabledOnOs
 import org.junit.jupiter.api.condition.OS
-import java.io.File
-import java.io.FileOutputStream
-import java.nio.file.Paths
+
+import java.net.URL
+import java.nio.file.Path
+import kotlin.io.path.Path
+import kotlin.io.path.absolute
+import kotlin.io.path.copyTo
+import kotlin.io.path.createDirectories
+import kotlin.io.path.div
 import kotlin.io.path.exists
+import kotlin.io.path.isSameFileAs
 import kotlin.io.path.listDirectoryEntries
 import kotlin.io.path.name
+import kotlin.io.path.outputStream
 import kotlin.io.path.pathString
+import kotlin.io.path.readText
+import kotlin.io.path.relativeTo
+import kotlin.system.measureNanoTime
 
 @DisabledOnOs(OS.MAC)
 class DiktatSaveSmokeTest : DiktatSmokeTestBase() {
@@ -26,7 +39,7 @@ class DiktatSaveSmokeTest : DiktatSmokeTestBase() {
         expected: String,
         test: String,
     ) {
-        saveSmokeTest(config, test)
+        saveSmokeTest(Path(config), test)
     }
 
     /**
@@ -35,55 +48,96 @@ class DiktatSaveSmokeTest : DiktatSmokeTestBase() {
      */
     @Suppress("TOO_LONG_FUNCTION")
     private fun saveSmokeTest(
-        configFilePath: String,
+        configFilePath: Path,
         testPath: String
     ) {
-        val processBuilder = createProcessBuilderWithCmd(testPath)
+        assertSoftly { softly ->
+            softly.assertThat(configFilePath).isRegularFile
 
-        val file = File("src/test/resources/test/smoke/tmpSave.txt")
-        val configFile = File("src/test/resources/test/smoke/diktat-analysis.yml")
-        val configFileFrom = File(configFilePath)
+            val configFile = (baseDirectory / "diktat-analysis.yml").apply {
+                parent.createDirectories()
+            }
+            val saveLog = (baseDirectory / "tmpSave.txt").apply {
+                parent.createDirectories()
+                deleteIfExistsSilently()
+            }
 
-        configFile.createNewFile()
-        file.createNewFile()
+            try {
+                configFilePath.copyTo(configFile, overwrite = true)
 
-        FileUtils.copyFile(configFileFrom, configFile)
+                val processBuilder = createProcessBuilderWithCmd(testPath).apply {
+                    redirectErrorStream(true)
+                    redirectOutput(ProcessBuilder.Redirect.appendTo(saveLog.toFile()))
 
-        processBuilder.redirectErrorStream(true)
-        processBuilder.redirectOutput(ProcessBuilder.Redirect.appendTo(file))
+                    /*
+                     * Inherit JAVA_HOME for the child process.
+                     */
+                    val javaHome = System.getProperty("java.home")
+                    environment()["JAVA_HOME"] = javaHome
+                    prependPath(Path(javaHome) / "bin")
+                }
 
-        val process = processBuilder.start()
-        process.waitFor()
+                val saveProcess = processBuilder.start()
+                val saveExitCode = saveProcess.waitFor()
+                softly.assertThat(saveExitCode).describedAs("The exit code of SAVE").isZero
 
-        val output = file.readLines()
-        val saveOutput = output.joinToString("\n")
+                softly.assertThat(saveLog).isRegularFile
 
-        file.delete()
+                val saveOutput = saveLog.readText()
 
-        Assertions.assertTrue(
-            saveOutput.contains("SUCCESS")
-        )
+                val saveCommandLine = processBuilder.command().joinToString(separator = " ")
+                softly.assertThat(saveOutput)
+                    .describedAs("The output of \"$saveCommandLine\"")
+                    .isNotEmpty
+                    .contains("SUCCESS")
+            } finally {
+                configFile.deleteIfExistsSilently()
+                saveLog.deleteIfExistsSilently()
+            }
+        }
     }
 
+    @Suppress("WRONG_ORDER_IN_CLASS_LIKE_STRUCTURES")  // False positives
     companion object {
         private const val KTLINT_VERSION = "0.46.1"
 
-        private fun getSaveForCurrentOs() = when {
-            System.getProperty("os.name").startsWith("Linux", ignoreCase = true) -> "save-$SAVE_VERSION-linuxX64.kexe"
-            System.getProperty("os.name").startsWith("Mac", ignoreCase = true) -> "save-$SAVE_VERSION-macosX64.kexe"
-            System.getProperty("os.name").startsWith("Windows", ignoreCase = true) -> "save-$SAVE_VERSION-mingwX64.exe"
-            else -> ""
+        @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR")
+        private val logger = KotlinLogging.loggerWithKtlintConfig { }
+        private val baseDirectory = Path("src/test/resources/test/smoke").absolute()
+
+        private fun getSaveForCurrentOs(): String {
+            val osName = System.getProperty("os.name")
+
+            return when {
+                osName.startsWith("Linux", ignoreCase = true) -> "save-$SAVE_VERSION-linuxX64.kexe"
+                osName.startsWith("Mac", ignoreCase = true) -> "save-$SAVE_VERSION-macosX64.kexe"
+                osName.startsWith("Windows", ignoreCase = true) -> "save-$SAVE_VERSION-mingwX64.exe"
+                else -> fail("SAVE doesn't support $osName (version ${System.getProperty("os.version")})")
+            }
         }
 
-        private fun downloadFile(url: String, file: File) {
-            val httpClient = HttpClients.createDefault()
-            val request = HttpGet(url)
-            httpClient.use {
-                val response: CloseableHttpResponse = httpClient.execute(request)
-                response.use {
-                    val fileSave = response.entity
-                    fileSave?.let {
-                        FileOutputStream(file).use { outstream -> fileSave.writeTo(outstream) }
+        @Suppress("FLOAT_IN_ACCURATE_CALCULATIONS")
+        private fun downloadFile(from: URL, to: Path) {
+            logger.info {
+                "Downloading $from to ${to.relativeTo(baseDirectory)}..."
+            }
+
+            val attempts = 5
+
+            val lazyDefault: (Throwable) -> Unit = { error ->
+                fail("Failure downloading $from after $attempts attempt(s)", error)
+            }
+
+            retry(attempts, lazyDefault = lazyDefault) {
+                from.openStream().use { source ->
+                    to.outputStream().use { target ->
+                        val bytesCopied: Long
+                        val timeNanos = measureNanoTime {
+                            bytesCopied = source.copyTo(target)
+                        }
+                        logger.info {
+                            "$bytesCopied byte(s) copied in ${timeNanos / 1000 / 1e3} ms."
+                        }
                     }
                 }
             }
@@ -91,41 +145,53 @@ class DiktatSaveSmokeTest : DiktatSmokeTestBase() {
 
         @BeforeAll
         @JvmStatic
+        @Suppress("AVOID_NULL_CHECKS")
         internal fun beforeAll() {
+            val forkedJavaHome = System.getenv("JAVA_HOME")
+            if (forkedJavaHome != null) {
+                val javaHome = System.getProperty("java.home")
+                if (javaHome != null && !Path(javaHome).isSameFileAs(Path(forkedJavaHome))) {
+                    logger.warn {
+                        "Current JDK home is $javaHome. Forked tests may use a different JDK at $forkedJavaHome."
+                    }
+                }
+                logger.warn {
+                    "Make sure JAVA_HOME ($forkedJavaHome) points to a Java 8 or Java 11 home. Java 17 is not yet supported."
+                }
+            }
+
+            logger.info {
+                "The base directory for the smoke test is $baseDirectory."
+            }
+
             val diktatDir: String =
-                Paths.get("../diktat-ruleset/target")
+                Path("../diktat-ruleset/target")
                     .takeIf { it.exists() }
                     ?.listDirectoryEntries()
                     ?.single { it.name.contains("diktat") }
                     ?.pathString ?: ""
 
-            val diktat = File("src/test/resources/test/smoke/diktat.jar")
-            val diktatFrom = File(diktatDir)
-            val save = File("src/test/resources/test/smoke/${getSaveForCurrentOs()}")
-            val ktlint = File("src/test/resources/test/smoke/ktlint")
-
-            ktlint.createNewFile()
-            save.createNewFile()
-            diktat.createNewFile()
+            val diktatFrom = Path(diktatDir)
+            val diktat = baseDirectory / "diktat.jar"
+            val save = baseDirectory / getSaveForCurrentOs()
+            val ktlint = baseDirectory / "ktlint"
 
-            downloadFile("https://github.com/saveourtool/save-cli/releases/download/v$SAVE_VERSION/${getSaveForCurrentOs()}", save)
-            downloadFile("https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint", ktlint)
+            downloadFile(URL("https://github.com/saveourtool/save-cli/releases/download/v$SAVE_VERSION/${getSaveForCurrentOs()}"), save)
+            downloadFile(URL("https://github.com/pinterest/ktlint/releases/download/$KTLINT_VERSION/ktlint"), ktlint)
 
-            FileUtils.copyFile(diktatFrom, diktat)
+            diktatFrom.copyTo(diktat)
         }
 
         @AfterAll
         @JvmStatic
         internal fun afterAll() {
-            val diktat = File("src/test/resources/test/smoke/diktat.jar")
-            val configFile = File("src/test/resources/test/smoke/diktat-analysis.yml")
-            val save = File("src/test/resources/test/smoke/${getSaveForCurrentOs()}")
-            val ktlint = File("src/test/resources/test/smoke/ktlint")
-
-            diktat.delete()
-            configFile.delete()
-            ktlint.delete()
-            save.delete()
+            val diktat = baseDirectory / "diktat.jar"
+            val save = baseDirectory / getSaveForCurrentOs()
+            val ktlint = baseDirectory / "ktlint"
+
+            diktat.deleteIfExistsSilently()
+            ktlint.deleteIfExistsSilently()
+            save.deleteIfExistsSilently()
         }
     }
 }
diff --git a/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSmokeTestBase.kt b/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSmokeTestBase.kt
index 4ab8f86e4a..6d53ea9590 100644
--- a/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSmokeTestBase.kt
+++ b/diktat-rules/src/test/kotlin/org/cqfn/diktat/ruleset/smoke/DiktatSmokeTestBase.kt
@@ -43,8 +43,10 @@ import org.junit.jupiter.api.Assertions
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Tag
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
 
 import java.time.LocalDate
+import java.util.concurrent.TimeUnit.SECONDS
 
 import kotlinx.serialization.builtins.ListSerializer
 
@@ -106,6 +108,7 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `regression - should not fail if package is not set`() {
         overrideRulesConfig(listOf(Warnings.PACKAGE_NAME_MISSING, Warnings.PACKAGE_NAME_INCORRECT_PATH,
             Warnings.PACKAGE_NAME_INCORRECT_PREFIX))
@@ -114,18 +117,21 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #8 - anonymous function`() {
         fixAndCompare(configFilePath, "Example8Expected.kt", "Example8Test.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #7`() {
         fixAndCompare(configFilePath, "Example7Expected.kt", "Example7Test.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #6`() {
         overrideRulesConfig(
             rulesToDisable = emptyList(),
@@ -142,6 +148,7 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #5`() {
         overrideRulesConfig(emptyList(),
             mapOf(
@@ -176,24 +183,28 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #4`() {
         fixAndCompare(configFilePath, "Example4Expected.kt", "Example4Test.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #3`() {
         fixAndCompare(configFilePath, "Example3Expected.kt", "Example3Test.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `regression - shouldn't throw exception in cases similar to #371`() {
         fixAndCompare(configFilePath, "Bug1Expected.kt", "Bug1Test.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #2`() {
         overrideRulesConfig(
             rulesToDisable = emptyList(),
@@ -219,6 +230,7 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test #1`() {
         overrideRulesConfig(
             rulesToDisable = emptyList(),
@@ -250,30 +262,35 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test with kts files #2`() {
         fixAndCompare(configFilePath, "script/SimpleRunInScriptExpected.kts", "script/SimpleRunInScriptTest.kts")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `smoke test with kts files with package name`() {
         fixAndCompare(configFilePath, "script/PackageInScriptExpected.kts", "script/PackageInScriptTest.kts")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `regression - should correctly handle tags with empty lines`() {
         fixAndCompare(configFilePath, "KdocFormattingMultilineTagsExpected.kt", "KdocFormattingMultilineTagsTest.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `regression - FP of local variables rule`() {
         fixAndCompare(configFilePath, "LocalVariableWithOffsetExpected.kt", "LocalVariableWithOffsetTest.kt")
     }
 
     @Test
     @Tag("DiktatRuleSetProvider")
+    @Timeout(TEST_TIMEOUT_SECONDS, unit = SECONDS)
     fun `fix can cause long line`() {
         overrideRulesConfig(
             rulesToDisable = emptyList(),
@@ -294,6 +311,7 @@ abstract class DiktatSmokeTestBase : FixTestBase("test/smoke/src/main/kotlin",
 
     companion object {
         const val DEFAULT_CONFIG_PATH = "../diktat-analysis.yml"
+        private const val TEST_TIMEOUT_SECONDS = 20L
         val unfixedLintErrors: MutableList<LintError> = mutableListOf()
 
         // by default using same yml config as for diktat code style check, but allow to override
diff --git a/diktat-rules/src/test/kotlin/org/cqfn/diktat/util/TestUtils.kt b/diktat-rules/src/test/kotlin/org/cqfn/diktat/util/TestUtils.kt
index c3cf75d6e2..00a151663b 100644
--- a/diktat-rules/src/test/kotlin/org/cqfn/diktat/util/TestUtils.kt
+++ b/diktat-rules/src/test/kotlin/org/cqfn/diktat/util/TestUtils.kt
@@ -2,6 +2,8 @@
  * Utility classes and methods for tests
  */
 
+@file:Suppress("TOP_LEVEL_ORDER")// False positives
+
 package org.cqfn.diktat.util
 
 import org.cqfn.diktat.common.config.rules.RulesConfig
@@ -20,8 +22,13 @@ import org.assertj.core.api.SoftAssertions
 import org.intellij.lang.annotations.Language
 import org.jetbrains.kotlin.com.intellij.lang.ASTNode
 
+import java.io.File
+import java.nio.file.Path
 import java.util.concurrent.atomic.AtomicInteger
 import java.util.function.Consumer
+import kotlin.io.path.absolute
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.isDirectory
 
 internal const val TEST_FILE_NAME = "TestFileName.kt"
 
@@ -138,3 +145,112 @@ internal fun applyToCode(code: String,
         .`as`("Number of expected asserts")
         .isEqualTo(expectedAsserts)
 }
+
+/**
+ * Retries the execution of the [block].
+ *
+ * @param attempts the number of attempts (must be positive).
+ * @param delayMillis the timeout (in milliseconds) between the consecutive
+ *   attempts. The default is 0. Ignored if [attempts] is 1.
+ * @param lazyDefault allows to override the return value if none of the
+ *   attempts succeeds. By default, the last exception is thrown.
+ * @param block the block to execute.
+ * @return the result of the execution of the [block], or whatever [lazyDefault]
+ *   evaluates to if none of the attempts is successful.
+ */
+internal fun <T> retry(
+    attempts: Int,
+    delayMillis: Long = 0L,
+    lazyDefault: (Throwable) -> T = { error -> throw error },
+    block: () -> T
+): T {
+    require(attempts > 0) {
+        "The number of attempts should be positive: $attempts"
+    }
+
+    var lastError: Throwable? = null
+
+    for (i in 1..attempts) {
+        try {
+            return block()
+        } catch (error: Throwable) {
+            lastError = error
+        }
+
+        if (delayMillis > 0L) {
+            Thread.sleep(delayMillis)
+        }
+    }
+
+    return lazyDefault(lastError ?: Exception("The block was never executed"))
+}
+
+/**
+ * Deletes the file if it exists, retrying as necessary if the file is
+ * blocked by another process (on Windows).
+ *
+ * @receiver the file or empty directory.
+ * @see Path.deleteIfExists
+ */
+@Suppress(
+    "EMPTY_BLOCK_STRUCTURE_ERROR",
+    "MAGIC_NUMBER",
+)
+internal fun Path.deleteIfExistsSilently() {
+    val attempts = 10
+
+    val deleted = retry(attempts, delayMillis = 100L, lazyDefault = { false }) {
+        deleteIfExists()
+
+        /*
+         * Ignore the return code of `deleteIfExists()` (will be `false`
+         * if the file doesn't exist).
+         */
+        true
+    }
+
+    if (!deleted) {
+        log.warn {
+            "File \"${absolute()}\" not deleted after $attempts attempt(s)."
+        }
+    }
+}
+
+/**
+ * Prepends the `PATH` of this process builder with [pathEntry].
+ *
+ * @param pathEntry the entry to be prepended to the `PATH`.
+ */
+internal fun ProcessBuilder.prependPath(pathEntry: Path) {
+    require(pathEntry.isDirectory()) {
+        "$pathEntry is not a directory"
+    }
+
+    val environment = environment()
+
+    val defaultPathKey = "PATH"
+    val defaultWindowsPathKey = "Path"
+
+    val pathKey = when {
+        /*-
+         * Keys of the Windows environment are case-insensitive ("PATH" == "Path").
+         * Keys of the Java interface to the environment are not ("PATH" != "Path").
+         * This is an attempt to work around the inconsistency.
+         */
+        System.getProperty("os.name").startsWith("Windows") -> environment.keys.firstOrNull { key ->
+            key.equals(defaultPathKey, ignoreCase = true)
+        } ?: defaultWindowsPathKey
+
+        else -> defaultPathKey
+    }
+
+    val pathSeparator = File.pathSeparatorChar
+    val oldPath = environment[pathKey]
+
+    val newPath = when {
+        oldPath.isNullOrEmpty() -> pathEntry.toString()
+        else -> "$pathEntry$pathSeparator$oldPath"
+    }
+
+    environment[pathKey] = newPath
+}