Skip to content

Commit f37c99e

Browse files
authored
feat: kill entire process group on shell command timeout (#2253)
1 parent 7814740 commit f37c99e

3 files changed

Lines changed: 137 additions & 31 deletions

File tree

tool/shell.go

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ func shellStopBackground(arguments map[string]interface{}) (*protocol.CallToolRe
501501
func shellBuildCommand(ctx context.Context, command string) (*exec.Cmd, error) {
502502
cmd := exec.CommandContext(ctx, shellPlatformShell(), shellPlatformShellArg(), command)
503503
cmd.Env = shellCommandEnv(false)
504+
shellSetProcessGroup(cmd)
504505
return cmd, nil
505506
}
506507

@@ -511,28 +512,42 @@ func shellBuildPtyCommand(ctx context.Context, command string) (gopty.Pty, *gopt
511512
}
512513
cmd := ptmx.CommandContext(ctx, shellPlatformShell(), shellPlatformShellArg(), command)
513514
cmd.Env = shellCommandEnv(true)
515+
shellSetProcessGroupPty(cmd)
514516
return ptmx, cmd, nil
515517
}
516518

517519
func shellRunForeground(ctx context.Context, command string, workdir string, usePTY bool) (string, error) {
518-
cmd, err := shellBuildCommand(ctx, command)
519-
if err != nil {
520-
return fmt.Sprintf("Error: %s", err.Error()), err
520+
if !usePTY {
521+
return shellRunForegroundCmd(ctx, command, workdir)
521522
}
523+
return shellRunForegroundPTY(ctx, command, workdir)
524+
}
522525

523-
if !usePTY {
524-
if workdir != "" {
525-
cmd.Dir = workdir
526-
}
526+
func shellRunForegroundCmd(ctx context.Context, command string, workdir string) (string, error) {
527+
cmd := exec.Command(shellPlatformShell(), shellPlatformShellArg(), command)
528+
cmd.Env = shellCommandEnv(false)
529+
shellSetProcessGroup(cmd)
530+
if workdir != "" {
531+
cmd.Dir = workdir
532+
}
533+
534+
var stdout, stderr bytes.Buffer
535+
cmd.Stdout = &stdout
536+
cmd.Stderr = &stderr
527537

528-
var stdout, stderr bytes.Buffer
529-
cmd.Stdout = &stdout
530-
cmd.Stderr = &stderr
538+
if startErr := cmd.Start(); startErr != nil {
539+
return fmt.Sprintf("Error: %s", startErr.Error()), startErr
540+
}
531541

532-
runErr := cmd.Run()
542+
waitDone := make(chan error, 1)
543+
go func() {
544+
waitDone <- cmd.Wait()
545+
}()
546+
547+
select {
548+
case runErr := <-waitDone:
533549
stdoutStr := stdout.String()
534550
stderrStr := stderr.String()
535-
536551
if runErr != nil {
537552
var parts []string
538553
parts = append(parts, fmt.Sprintf("Error: %s", runErr.Error()))
@@ -544,7 +559,6 @@ func shellRunForeground(ctx context.Context, command string, workdir string, use
544559
}
545560
return strings.Join(parts, "\n"), runErr
546561
}
547-
548562
result := stdoutStr
549563
if stderrStr != "" {
550564
if result != "" {
@@ -557,8 +571,15 @@ func shellRunForeground(ctx context.Context, command string, workdir string, use
557571
result = "(no output)"
558572
}
559573
return result, nil
574+
575+
case <-ctx.Done():
576+
shellKillProcessGroup(cmd.Process)
577+
<-waitDone
578+
return "", ctx.Err()
560579
}
580+
}
561581

582+
func shellRunForegroundPTY(ctx context.Context, command string, workdir string) (string, error) {
562583
ptmx, ptyCmd, err := shellBuildPtyCommand(ctx, command)
563584
if err != nil {
564585
return fmt.Sprintf("Error: %s", err.Error()), err
@@ -586,25 +607,34 @@ func shellRunForeground(ctx context.Context, command string, workdir string, use
586607
copyDone <- nil
587608
}()
588609

589-
runErr := ptyCmd.Wait()
590-
_ = ptmx.Close()
591-
copyErr := <-copyDone
610+
waitDone := make(chan error, 1)
611+
go func() {
612+
waitDone <- ptyCmd.Wait()
613+
}()
592614

593-
if ctx.Err() == context.DeadlineExceeded {
594-
return "", ctx.Err()
595-
}
596-
if copyErr != nil && runErr == nil {
597-
runErr = copyErr
598-
}
615+
select {
616+
case runErr := <-waitDone:
617+
_ = ptmx.Close()
618+
copyErr := <-copyDone
619+
if copyErr != nil && runErr == nil {
620+
runErr = copyErr
621+
}
622+
text := output.String()
623+
if text == "" {
624+
text = "(no output)"
625+
}
626+
if runErr != nil {
627+
return fmt.Sprintf("Error: %s\nOutput:\n%s", runErr.Error(), text), runErr
628+
}
629+
return text, nil
599630

600-
text := output.String()
601-
if text == "" {
602-
text = "(no output)"
603-
}
604-
if runErr != nil {
605-
return fmt.Sprintf("Error: %s\nOutput:\n%s", runErr.Error(), text), runErr
631+
case <-ctx.Done():
632+
shellKillProcessGroup(ptyCmd.Process)
633+
_ = ptmx.Close()
634+
<-waitDone
635+
<-copyDone
636+
return "", ctx.Err()
606637
}
607-
return text, nil
608638
}
609639

610640
func shellStringArg(arguments map[string]interface{}, key string, defaultValue string) string {
@@ -971,13 +1001,13 @@ func (s *shellSession) stop() {
9711001
return
9721002
}
9731003
if err := process.Signal(os.Interrupt); err != nil {
974-
_ = process.Kill()
1004+
shellKillProcessGroup(process)
9751005
return
9761006
}
9771007
select {
9781008
case <-done:
9791009
case <-time.After(2 * time.Second):
980-
_ = process.Kill()
1010+
shellKillProcessGroup(process)
9811011
}
9821012
}
9831013

tool/shell_kill_unix.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build unix
16+
17+
package tool
18+
19+
import (
20+
"os"
21+
"os/exec"
22+
"syscall"
23+
24+
gopty "github.com/aymanbagabas/go-pty"
25+
)
26+
27+
func shellSetProcessGroup(cmd *exec.Cmd) {
28+
if cmd.SysProcAttr == nil {
29+
cmd.SysProcAttr = &syscall.SysProcAttr{}
30+
}
31+
cmd.SysProcAttr.Setpgid = true
32+
}
33+
34+
func shellSetProcessGroupPty(cmd *gopty.Cmd) {
35+
if cmd.SysProcAttr == nil {
36+
cmd.SysProcAttr = &syscall.SysProcAttr{}
37+
}
38+
cmd.SysProcAttr.Setpgid = true
39+
}
40+
41+
func shellKillProcessGroup(process *os.Process) {
42+
_ = syscall.Kill(-process.Pid, syscall.SIGKILL)
43+
}

tool/shell_kill_windows.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build windows
16+
17+
package tool
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
24+
gopty "github.com/aymanbagabas/go-pty"
25+
)
26+
27+
func shellSetProcessGroup(cmd *exec.Cmd) {}
28+
29+
func shellSetProcessGroupPty(cmd *gopty.Cmd) {}
30+
31+
func shellKillProcessGroup(process *os.Process) {
32+
_ = exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", process.Pid)).Run()
33+
}

0 commit comments

Comments
 (0)