Skip to content

Commit 13442e8

Browse files
FaanbariaBLannoo
andauthored
fix: timeout not respected because join() called before timeout check (#1005)
The executor kept running past the timeout limit. This happened because join() was called on stream jobs before checking if timeout occurred. Now we check for timeout first, then call join(). Updated tests to verify timeouts work correctly. --- #### Type of the changes - [ ] New feature (non-breaking change which adds functionality) - [x] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Tests improvement - [ ] Refactoring #### Checklist - [x] The pull request has a description of the proposed change - [x] I read the [Contributing Guidelines](https://github.com/JetBrains/koog/blob/main/CONTRIBUTING.md) before opening the pull request - [x] The pull request uses **`develop`** as the base branch - [x] Tests for the changes have been added - [x] All new and existing tests passed ##### Additional steps for pull requests adding a new feature - [ ] An issue describing the proposed change exists - [ ] The pull request includes a link to the issue - [ ] The change was discussed and approved in the issue - [ ] Docs have been added / updated --------- Co-authored-by: Bruno Lannoo <[email protected]>
1 parent 59816da commit 13442e8

File tree

2 files changed

+54
-39
lines changed

2 files changed

+54
-39
lines changed

agents/agents-ext/src/jvmMain/kotlin/ai/koog/agents/ext/tool/shell/JvmShellCommandExecutor.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ public class JvmShellCommandExecutor : ShellCommandExecutor {
8787
} != null
8888

8989
if (!isCompleted) {
90+
// descendants need to be deleted for windows
91+
process.descendants().forEach { it.destroyForcibly() }
9092
process.destroyForcibly()
9193
}
9294

@@ -112,6 +114,8 @@ public class JvmShellCommandExecutor : ShellCommandExecutor {
112114
} finally {
113115
// Kill the process even when canceled, otherwise it keeps running
114116
if (process.isAlive) {
117+
// descendants need to be deleted for windows
118+
process.descendants().forEach { it.destroyForcibly() }
115119
process.destroyForcibly()
116120
}
117121
}

agents/agents-ext/src/jvmTest/kotlin/ai/koog/agents/ext/tool/shell/ExecuteShellCommandToolJvmTest.kt

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import kotlinx.coroutines.cancelAndJoin
55
import kotlinx.coroutines.delay
66
import kotlinx.coroutines.launch
77
import kotlinx.coroutines.runBlocking
8+
import kotlinx.coroutines.withTimeout
89
import org.junit.jupiter.api.RepeatedTest
910
import org.junit.jupiter.api.Test
1011
import org.junit.jupiter.api.condition.EnabledOnOs
@@ -16,6 +17,7 @@ import kotlin.io.path.createFile
1617
import kotlin.io.path.writeText
1718
import kotlin.system.measureTimeMillis
1819
import kotlin.test.assertEquals
20+
import kotlin.test.assertFalse
1921
import kotlin.test.assertNull
2022
import kotlin.test.assertTrue
2123
import kotlin.test.fail
@@ -324,58 +326,67 @@ class ExecuteShellCommandToolJvmTest {
324326

325327
@Test
326328
@EnabledOnOs(OS.LINUX, OS.MAC)
327-
fun `long running command times out`() = runBlocking {
328-
val command = "sleep 5"
329-
val timeout = 1
330-
val result = executeShellCommand(command, timeoutSeconds = timeout)
329+
fun `command with partial output times out`() = runBlocking {
330+
val result: ExecuteShellCommandTool.Result
331+
val executionTimeMs = measureTimeMillis {
332+
result = withTimeout(4000L) {
333+
executeShellCommand(
334+
"echo beforeSleep && sleep 10 && echo afterSleep",
335+
timeoutSeconds = 1
336+
)
337+
}
338+
}
331339

332-
val expected = """
333-
Command: $command
334-
Command timed out after $timeout seconds
340+
val partialExpected = """
341+
Command: echo beforeSleep && sleep 10 && echo afterSleep
342+
beforeSleep
335343
""".trimIndent()
336344

337-
assertEquals(expected, result.textForLLM())
338-
assertNull(result.exitCode)
345+
val output = result.textForLLM()
346+
assertTrue(output.contains(partialExpected), "Partial output not found. Actual: $output")
347+
assertTrue(output.contains("Command timed out after 1 seconds"), "Timeout message not found. Actual: $output")
348+
349+
// We have to remove the command because it DOES contain afterSleep
350+
assertFalse(
351+
output.replace(partialExpected, "").contains("afterSleep"),
352+
"afterSleep should not appear since command timed out"
353+
)
354+
355+
assertNull(result.exitCode, "Exit code should be null for timed out command")
356+
assertTrue(executionTimeMs < 3000, "Should timeout at 1s, but took ${executionTimeMs}ms")
339357
}
340358

341359
@Test
342360
@EnabledOnOs(OS.WINDOWS)
343-
fun `long running command times out on Windows`() = runBlocking {
344-
val result = executeShellCommand("powershell -Command \"Start-Sleep -Milliseconds 1100\"", timeoutSeconds = 1)
361+
fun `command with partial output times out on Windows`() = runBlocking {
362+
val result: ExecuteShellCommandTool.Result
363+
val executionTimeMs = measureTimeMillis {
364+
result = withTimeout(5000L) {
365+
executeShellCommand(
366+
"""
367+
powershell -Command "'beforeSleep'; Start-Sleep -Seconds 10; 'afterSleep'"
368+
""".trimIndent(),
369+
timeoutSeconds = 1
370+
)
371+
}
372+
}
345373

346-
val expected = """
347-
Command: powershell -Command "Start-Sleep -Milliseconds 1100"
348-
Command timed out after 1 seconds
374+
val partialExpected = """
375+
Command: powershell -Command "'beforeSleep'; Start-Sleep -Seconds 10; 'afterSleep'"
349376
""".trimIndent()
350377

351-
assertEquals(expected, result.textForLLM())
352-
assertNull(result.exitCode)
353-
}
378+
val output = result.textForLLM()
379+
assertTrue(output.contains(partialExpected), "Partial output not found. Actual: $output")
380+
assertTrue(output.contains("Command timed out after 1 seconds"), "Timeout message not found. Actual: $output")
354381

355-
@Test
356-
@EnabledOnOs(OS.LINUX, OS.MAC)
357-
fun `command with partial output times out`() = runBlocking {
358-
val result = executeShellCommand("for i in {1..3}; do echo \$i; sleep 0.5; done", timeoutSeconds = 1)
359-
360-
assertTrue(result.textForLLM().contains("Command timed out after 1 seconds"))
361-
assertTrue(result.textForLLM().startsWith("Command: for i in {1..3}; do echo \$i; sleep 0.5; done"))
362-
assertNull(result.exitCode)
363-
}
364-
365-
@Test
366-
@EnabledOnOs(OS.WINDOWS)
367-
fun `command with partial output times out on Windows`() = runBlocking {
368-
val result = executeShellCommand(
369-
"powershell -Command \"1..3 | ForEach-Object { Write-Output \$_; Start-Sleep -Milliseconds 500 }\"",
370-
timeoutSeconds = 1
382+
// We have to remove the command because it DOES contain afterSleep
383+
assertFalse(
384+
output.replace(partialExpected, "").contains("afterSleep"),
385+
"afterSleep should not appear since command timed out"
371386
)
372387

373-
assertTrue(result.textForLLM().contains("Command timed out after 1 seconds"))
374-
assertTrue(
375-
result.textForLLM()
376-
.startsWith("Command: powershell -Command \"1..3 | ForEach-Object { Write-Output \$_; Start-Sleep -Milliseconds 500 }\"")
377-
)
378-
assertNull(result.exitCode)
388+
assertNull(result.exitCode, "Exit code should be null for timed out command")
389+
assertTrue(executionTimeMs < 4000, "Should timeout at 1s, but took ${executionTimeMs}ms")
379390
}
380391

381392
// CANCELLATION TESTS

0 commit comments

Comments
 (0)