From 66e6389931e99f33a4a3ef52587cf891eb05237f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 8 Oct 2026 00:53:02 +0300 Subject: [PATCH 1/2] Terminate command descendants during timeout and interruption cleanup --- .../org/jacodb/ets/utils/ProcessUtil.kt | 10 +++ .../org/jacodb/ets/test/ProcessUtilTest.kt | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+) 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..35f88bb6c 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 @@ -29,6 +29,14 @@ private fun terminateAndReap( initialInterruption: InterruptedException? = null, ) { var interruption = initialInterruption + var cleanupFailure: RuntimeException? = null + try { + process.toHandle().descendants().use { descendants -> + descendants.forEach { child -> child.destroyForcibly() } + } + } catch (error: RuntimeException) { + cleanupFailure = error + } process.destroy() if (process.isAlive) { process.destroyForcibly() @@ -49,9 +57,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..280345017 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 @@ -22,12 +22,14 @@ 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 +151,65 @@ 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) { + 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 { ProcessHandle.of(it).ifPresent { child -> child.destroyForcibly() } } + watcher.close() + directory.toFile().deleteRecursively() + } + } } From 164e1d5b6a6a04f2230c6a1741661483b3b84dcf Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Thu, 8 Oct 2026 01:31:00 +0300 Subject: [PATCH 2/2] Preserve Java 8 compatibility in process cleanup --- .../org/jacodb/ets/utils/ProcessUtil.kt | 30 +++++++++++++++++-- .../org/jacodb/ets/test/ProcessUtilTest.kt | 11 ++++++- 2 files changed, 37 insertions(+), 4 deletions(-) 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 35f88bb6c..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,13 +17,39 @@ 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, @@ -31,9 +57,7 @@ private fun terminateAndReap( var interruption = initialInterruption var cleanupFailure: RuntimeException? = null try { - process.toHandle().descendants().use { descendants -> - descendants.forEach { child -> child.destroyForcibly() } - } + destroyDescendantsIfSupported(process) } catch (error: RuntimeException) { cleanupFailure = error } 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 280345017..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,6 +17,7 @@ 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 @@ -163,6 +164,10 @@ class ProcessUtilTest { } 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() @@ -207,7 +212,11 @@ class ProcessUtilTest { } finally { runner.interrupt() runner.join(5000) - pid?.let { ProcessHandle.of(it).ifPresent { child -> child.destroyForcibly() } } + pid?.let { childPid -> + ProcessBuilder(node, "-e", "try { process.kill(Number(process.argv[1]), 'SIGKILL'); } catch {}", "$childPid") + .start() + .waitFor() + } watcher.close() directory.toFile().deleteRecursively() }