fix(shell): interruptible timeout + kill the whole process tree (#62)

withTimeout wrapped a blocking process.waitFor() that coroutine cancellation
can't interrupt, so a hung process hung the stage until it exited on its own;
and destroyForcibly() killed only the direct child, leaving sh -c grandchildren
(the real npm/tsc) as orphans. Use process.waitFor(timeout, MILLISECONDS) for
an on-the-clock deadline, and kill via toHandle().descendants() + the parent.
This commit is contained in:
2026-07-12 12:42:04 +04:00
parent 44e15ea1b7
commit ddf2c014f1
@@ -16,10 +16,9 @@ import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.tools.process.ChildProcess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -28,6 +27,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path
import java.util.concurrent.TimeUnit
class ShellTool(
private val allowedExecutables: Set<String> = emptySet(),
@@ -195,21 +195,13 @@ class ShellTool(
runCatching {
runCmd(request, process)
}.getOrElse {
process.destroyForcibly()
if (it is TimeoutCancellationException) {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
} else {
killTree(process)
ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred during execution",
recoverable = false,
)
}
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
@@ -254,11 +246,32 @@ class ShellTool(
.joinToString("\n")
}
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
// Kill the whole process tree. destroyForcibly() signals only the direct child, so an `sh -c`
// command's grandchildren (the actual npm/tsc/…) would survive as orphans; descendants() reaches
// them. Snapshot descendants BEFORE destroying the parent — reparenting can hide them afterwards.
private fun killTree(process: Process) {
process.toHandle().descendants().forEach { it.destroyForcibly() }
process.destroyForcibly()
}
val exitCode = process.waitFor()
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = coroutineScope {
val stdoutDeferred = async(Dispatchers.IO) { process.inputStream.bufferedReader().use { it.readText() } }
val stderrDeferred = async(Dispatchers.IO) { process.errorStream.bufferedReader().use { it.readText() } }
// waitFor(timeout) enforces the deadline on the clock — the plain blocking waitFor() is not
// coroutine-cancellable, so a withTimeout around it can't actually interrupt a hung process.
val finished = withContext(Dispatchers.IO) { process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) }
if (!finished) {
killTree(process)
stdoutDeferred.cancel()
stderrDeferred.cancel()
return@coroutineScope ToolResult.Failure(
invocationId = request.invocationId,
reason = "Process timed out after ${timeoutMs}ms",
recoverable = false,
)
}
val exitCode = process.exitValue()
val stdout = stdoutDeferred.await()
val stderr = stderrDeferred.await()