Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,50 @@
package org.jacodb.ets.utils

import mu.KotlinLogging
import java.lang.reflect.InvocationTargetException
import java.nio.charset.Charset
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import java.util.stream.Stream
import kotlin.time.Duration

private val logger = KotlinLogging.logger {}

/** Java 8 exposes parent cleanup only; process-tree cleanup is available on Java 9 and later. */
private fun destroyDescendantsIfSupported(process: Process) {
val handleClass = try {
Class.forName("java.lang.ProcessHandle")
} catch (_: ClassNotFoundException) {
return
}

try {
val handle = Process::class.java.getMethod("toHandle").invoke(process)
val descendants = handleClass.getMethod("descendants").invoke(handle) as Stream<*>
val destroy = handleClass.getMethod("destroyForcibly")
descendants.use { children ->
children.forEach { child -> destroy.invoke(child) }
}
} catch (error: InvocationTargetException) {
val cause = error.targetException
if (cause is RuntimeException) throw cause
throw IllegalStateException("Failed to terminate process descendants", cause)
} catch (error: ReflectiveOperationException) {
throw IllegalStateException("Cannot access process-tree cleanup on this runtime", error)
}
}

private fun terminateAndReap(
process: Process,
initialInterruption: InterruptedException? = null,
) {
var interruption = initialInterruption
var cleanupFailure: RuntimeException? = null
try {
destroyDescendantsIfSupported(process)
} catch (error: RuntimeException) {
cleanupFailure = error
}
process.destroy()
if (process.isAlive) {
process.destroyForcibly()
Expand All @@ -49,9 +81,11 @@ private fun terminateAndReap(
}
}
if (interruption != null) {
cleanupFailure?.let { interruption.addSuppressed(it) }
Thread.currentThread().interrupt()
throw interruption
}
cleanupFailure?.let { throw it }
}

object ProcessUtil {
Expand Down
72 changes: 72 additions & 0 deletions jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@
package org.jacodb.ets.test

import org.jacodb.ets.utils.ProcessUtil
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import java.net.ConnectException
import java.net.Socket
import java.nio.file.FileSystems
import java.nio.file.StandardWatchEventKinds
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.io.path.createDirectory
import kotlin.io.path.createTempDirectory
import kotlin.io.path.readText
import kotlin.time.Duration.Companion.seconds
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
Expand Down Expand Up @@ -149,4 +152,73 @@ class ProcessUtilTest {
testDirectory.toFile().deleteRecursively()
}
}

@Test
fun `timeout terminates processes spawned by the command`() {
checkDescendantCleanup(interrupt = false)
}

@Test
fun `interruption terminates processes spawned by the command`() {
checkDescendantCleanup(interrupt = true)
}

private fun checkDescendantCleanup(interrupt: Boolean) {
assumeTrue(
runCatching { Class.forName("java.lang.ProcessHandle") }.isSuccess,
"Java 8 supports parent-only cleanup; descendant cleanup requires Java 9 or later",
)
val directory = createTempDirectory("process-util-descendant")
val ready = directory.resolve("ready")
val failure = AtomicReference<Throwable>()
val outcome = AtomicReference<ProcessUtil.Result>()
val watcher = FileSystems.getDefault().newWatchService()
directory.register(watcher, StandardWatchEventKinds.ENTRY_CREATE)
val childScript = "const fs = require('fs'); const net = require('net'); " +
"const server = net.createServer(socket => socket.end()); " +
"server.listen(0, '127.0.0.1', () => fs.writeFileSync(process.argv[1], " +
"process.pid + ' ' + server.address().port));"
val parentScript = "require('child_process').spawn(process.execPath, " +
"['-e', process.argv[1], process.argv[2]], {stdio: 'inherit'});"
val runner = thread(start = false) {
try {
outcome.set(ProcessUtil.run(
command = listOf(node, "-e", parentScript, childScript, ready.toString()),
timeout = if (interrupt) null else 1.seconds,
))
} catch (error: Throwable) {
failure.set(error)
}
}
var pid: Long? = null

try {
runner.start()
assertTrue(watcher.poll(5, TimeUnit.SECONDS) != null, "descendant did not publish readiness")
val (childPid, port) = ready.readText().trim().split(' ')
pid = childPid.toLong()

if (interrupt) runner.interrupt()
runner.join(5000)

assertFalse(runner.isAlive, "process cleanup did not finish")
if (interrupt) assertIs<InterruptedException>(failure.get())
else {
assertEquals(null, failure.get())
assertTrue(outcome.get().isTimeout)
}
val connection = runCatching { Socket("127.0.0.1", port.toInt()).use { } }
assertIs<ConnectException>(connection.exceptionOrNull(), "descendant survived command cleanup")
} finally {
runner.interrupt()
runner.join(5000)
pid?.let { childPid ->
ProcessBuilder(node, "-e", "try { process.kill(Number(process.argv[1]), 'SIGKILL'); } catch {}", "$childPid")
.start()
.waitFor()
}
watcher.close()
directory.toFile().deleteRecursively()
}
}
}
Loading