Run exec commands in dedicated process groups

This commit is contained in:
Nikolay Edigaryev 2026-08-10 18:45:23 +01:00
parent 53eb992a09
commit 1f4bcba670
2 changed files with 39 additions and 1 deletions

View File

@ -96,6 +96,11 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return nil
}
// Kill the whole process group when the exec stream is canceled
cmd.Cancel = func() error {
return signalProcessGroup(cmd.Process, syscall.SIGKILL)
}
var stdin io.WriteCloser
var stdout, stderr io.ReadCloser
var ptmx *os.File
@ -112,6 +117,9 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
stdout = ptmx
stderr = ptmx
} else {
// Start the command in its own process group so signals reach all descendants
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if firstExecRequestCommand.Command.Interactive {
stdin, err = cmd.StdinPipe()
if err != nil {
@ -227,7 +235,7 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
return
}
if err := cmd.Process.Signal(signal); err != nil && !errors.Is(err, os.ErrProcessDone) {
if err := signalProcessGroup(cmd.Process, signal); err != nil && !errors.Is(err, os.ErrProcessDone) {
fromClientErrCh <- err
return
@ -340,6 +348,19 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
})
}
func signalProcessGroup(process *os.Process, signal syscall.Signal) error {
if err := syscall.Kill(-process.Pid, signal); err != nil {
// Translate a missing process group into the process-finished error expected by os/exec
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
return nil
}
func sendStartSuccess(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
return stream.Send(&ExecResponse{
Type: &ExecResponse_Started_{

View File

@ -152,6 +152,23 @@ func TestExecSignalsProcess(t *testing.T) {
}
}
func TestExecSignalsProcessGroup(t *testing.T) {
stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/sh",
Args: []string{"-c", "sleep 30 & printf ready; wait"},
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
require.Equal(t, []byte("ready"), receiveExecResponse(t, stream).GetStandardOutput().GetData())
stream.requests <- &ExecRequest{
Type: &ExecRequest_Signal_{Signal: ExecRequest_SIGNAL_SIGTERM},
}
response := receiveExecResponse(t, stream)
require.EqualValues(t, signalExitCodeOffset+syscall.SIGTERM, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func startExecTest(
t *testing.T,
command *ExecRequest_Command,