Skip to content

Commit 5b525f6

Browse files
authored
Merge pull request #378 from lunareed720/fix/exec-timeout-process-tree
fix(exec): kill child process tree on timeout to prevent orphaned tasks
2 parents 5522776 + acac197 commit 5b525f6

4 files changed

Lines changed: 148 additions & 2 deletions

File tree

pkg/tools/shell.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tools
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"fmt"
78
"os"
89
"os/exec"
@@ -177,18 +178,43 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
177178
cmd.Dir = cwd
178179
}
179180

181+
prepareCommandForTermination(cmd)
182+
180183
var stdout, stderr bytes.Buffer
181184
cmd.Stdout = &stdout
182185
cmd.Stderr = &stderr
183186

184-
err := cmd.Run()
187+
if err := cmd.Start(); err != nil {
188+
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
189+
}
190+
191+
done := make(chan error, 1)
192+
go func() {
193+
done <- cmd.Wait()
194+
}()
195+
196+
var err error
197+
select {
198+
case err = <-done:
199+
case <-cmdCtx.Done():
200+
_ = terminateProcessTree(cmd)
201+
select {
202+
case err = <-done:
203+
case <-time.After(2 * time.Second):
204+
if cmd.Process != nil {
205+
_ = cmd.Process.Kill()
206+
}
207+
err = <-done
208+
}
209+
}
210+
185211
output := stdout.String()
186212
if stderr.Len() > 0 {
187213
output += "\nSTDERR:\n" + stderr.String()
188214
}
189215

190216
if err != nil {
191-
if cmdCtx.Err() == context.DeadlineExceeded {
217+
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
192218
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
193219
return &ToolResult{
194220
ForLLM: msg,

pkg/tools/shell_process_unix.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//go:build !windows
2+
3+
package tools
4+
5+
import (
6+
"os/exec"
7+
"syscall"
8+
)
9+
10+
func prepareCommandForTermination(cmd *exec.Cmd) {
11+
if cmd == nil {
12+
return
13+
}
14+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
15+
}
16+
17+
func terminateProcessTree(cmd *exec.Cmd) error {
18+
if cmd == nil || cmd.Process == nil {
19+
return nil
20+
}
21+
22+
pid := cmd.Process.Pid
23+
if pid <= 0 {
24+
return nil
25+
}
26+
27+
// Kill the entire process group spawned by the shell command.
28+
_ = syscall.Kill(-pid, syscall.SIGKILL)
29+
// Fallback kill on the shell process itself.
30+
_ = cmd.Process.Kill()
31+
return nil
32+
}

pkg/tools/shell_process_windows.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build windows
2+
3+
package tools
4+
5+
import (
6+
"os/exec"
7+
"strconv"
8+
)
9+
10+
func prepareCommandForTermination(cmd *exec.Cmd) {
11+
// no-op on Windows
12+
}
13+
14+
func terminateProcessTree(cmd *exec.Cmd) error {
15+
if cmd == nil || cmd.Process == nil {
16+
return nil
17+
}
18+
19+
pid := cmd.Process.Pid
20+
if pid <= 0 {
21+
return nil
22+
}
23+
24+
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
25+
_ = cmd.Process.Kill()
26+
return nil
27+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build !windows
2+
3+
package tools
4+
5+
import (
6+
"context"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"syscall"
12+
"testing"
13+
"time"
14+
)
15+
16+
func processExists(pid int) bool {
17+
if pid <= 0 {
18+
return false
19+
}
20+
err := syscall.Kill(pid, 0)
21+
return err == nil || err == syscall.EPERM
22+
}
23+
24+
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
25+
tool := NewExecTool(t.TempDir(), false)
26+
tool.SetTimeout(500 * time.Millisecond)
27+
28+
args := map[string]interface{}{
29+
// Spawn a child process that would outlive the shell unless process-group kill is used.
30+
"command": "sleep 60 & echo $! > child.pid; wait",
31+
}
32+
33+
result := tool.Execute(context.Background(), args)
34+
if !result.IsError {
35+
t.Fatalf("expected timeout error, got success: %s", result.ForLLM)
36+
}
37+
if !strings.Contains(result.ForLLM, "timed out") {
38+
t.Fatalf("expected timeout message, got: %s", result.ForLLM)
39+
}
40+
41+
childPIDPath := filepath.Join(tool.workingDir, "child.pid")
42+
data, err := os.ReadFile(childPIDPath)
43+
if err != nil {
44+
t.Fatalf("failed to read child pid file: %v", err)
45+
}
46+
47+
childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
48+
if err != nil {
49+
t.Fatalf("failed to parse child pid: %v", err)
50+
}
51+
52+
deadline := time.Now().Add(2 * time.Second)
53+
for time.Now().Before(deadline) {
54+
if !processExists(childPID) {
55+
return
56+
}
57+
time.Sleep(50 * time.Millisecond)
58+
}
59+
60+
t.Fatalf("child process %d is still running after timeout", childPID)
61+
}

0 commit comments

Comments
 (0)