From 1f4bcba670103b2cb8e4664734be51bd6746aa18 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Mon, 10 Aug 2026 18:45:23 +0100 Subject: [PATCH] Run exec commands in dedicated process groups --- internal/rpc/exec.go | 23 ++++++++++++++++++++++- internal/rpc/exec_test.go | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/rpc/exec.go b/internal/rpc/exec.go index 36f59c7..78fcb62 100644 --- a/internal/rpc/exec.go +++ b/internal/rpc/exec.go @@ -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_{ diff --git a/internal/rpc/exec_test.go b/internal/rpc/exec_test.go index c4cd7a1..c57fe3c 100644 --- a/internal/rpc/exec_test.go +++ b/internal/rpc/exec_test.go @@ -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,