diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt index b7579fbd5..5b0dea907 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ProcessUtil.kt @@ -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() @@ -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 { diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt index c5922ac0c..4e3b4af08 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/ProcessUtilTest.kt @@ -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 @@ -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() + val outcome = AtomicReference() + 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(failure.get()) + else { + assertEquals(null, failure.get()) + assertTrue(outcome.get().isTimeout) + } + val connection = runCatching { Socket("127.0.0.1", port.toInt()).use { } } + assertIs(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() + } + } }