Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions packages/envd/internal/services/process/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ func New(
Groups: groups,
},
}
configureProcessGroup(cmd.SysProcAttr, req.GetPty() != nil)
applyCgroupFD(cmd.SysProcAttr, cgroupFD, ok)

resolvedPath, err := permissions.ExpandAndResolve(req.GetProcess().GetCwd(), user, defaults.Workdir)
Expand Down Expand Up @@ -462,25 +463,50 @@ func getProcType(req *rpc.StartRequest) cgroups.ProcessType {
return cgroups.ProcessTypeUser
}

func (p *Handler) SendSignal(signal syscall.Signal) error {
if signal == syscall.SIGKILL || signal == syscall.SIGTERM {
p.outCancel()
func configureProcessGroup(attr *syscall.SysProcAttr, hasPTY bool) {
// PTY startup creates a new session (and therefore a new process group).
// Non-PTY commands need an explicit process group so callers can opt into
// signalling the command and its descendants without affecting envd.
if !hasPTY {
attr.Setpgid = true
}
}

// Re-adopted handler (post live-upgrade): no cmd, signal by stored pid.
if p.cmd == nil {
if p.pid == 0 {
return errors.New("process not started")
func (p *Handler) SendSignal(signal syscall.Signal, descendants bool) error {
pid := int(p.Pid())
if pid == 0 {
return errors.New("process not started")
}

var err error
switch {
case descendants:
pgid, groupErr := syscall.Getpgid(pid)
if groupErr != nil {
return fmt.Errorf("get process group for pid %d: %w", pid, groupErr)
}
if pgid != pid {
return fmt.Errorf("process %d does not own a process group", pid)
}

return syscall.Kill(int(p.pid), signal)
err = syscall.Kill(-pgid, signal)
case p.cmd == nil:
// Re-adopted handler (post live-upgrade): no cmd, signal by stored pid.
err = syscall.Kill(pid, signal)
default:
err = p.cmd.Process.Signal(signal)
}
if err != nil {
return err
}

if p.cmd.Process == nil {
return errors.New("process not started")
// Keep delivering output when signal validation or delivery fails. Once a terminal signal has
// actually been sent, stop the pumps promptly instead of waiting for every inherited pipe to close.
if signal == syscall.SIGKILL || signal == syscall.SIGTERM {
p.outCancel()
}

return p.cmd.Process.Signal(signal)
return nil
}

func (p *Handler) ResizeTty(size *pty.Winsize) error {
Expand Down
91 changes: 91 additions & 0 deletions packages/envd/internal/services/process/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ package handler

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The process wrapper must degrade cleanly when the priority helpers are absent
Expand Down Expand Up @@ -50,3 +57,87 @@ func TestWrapperPrefix(t *testing.T) {
assert.Equal(t, "/bin/ionice -c 1 -n 6 ", ioniceNicePrefix(1, 6, 0, only("ionice")))
})
}

func TestSendSignalTargetsOnlyLeaderByDefault(t *testing.T) {
t.Parallel()

cmd := startCommandGroup(t)

h := &Handler{cmd: cmd, outCancel: func() {}}
t.Cleanup(func() {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_, _ = cmd.Process.Wait()
})

require.NoError(t, h.SendSignal(syscall.SIGTERM, false))
state, err := cmd.Process.Wait()
require.NoError(t, err)
assert.False(t, state.Success())
require.NoError(t, syscall.Kill(-cmd.Process.Pid, syscall.Signal(0)), "child should remain in the group")
}

func TestSendSignalTargetsProcessGroupWhenDescendantsRequested(t *testing.T) {
t.Parallel()

cmd := startCommandGroup(t)

h := &Handler{cmd: cmd, outCancel: func() {}}
t.Cleanup(func() {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_, _ = cmd.Process.Wait()
})

require.NoError(t, h.SendSignal(syscall.SIGTERM, true))
state, err := cmd.Process.Wait()
require.NoError(t, err)
assert.False(t, state.Success())
require.Eventually(t, func() bool {
return syscall.Kill(-cmd.Process.Pid, syscall.Signal(0)) != nil
}, 2*time.Second, 10*time.Millisecond)
}

func TestSendSignalRejectsDescendantsWithoutOwnedProcessGroup(t *testing.T) {
t.Parallel()

cmd := exec.CommandContext(t.Context(), "sleep", "60")
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})

outputCancelled := false
h := &Handler{cmd: cmd, outCancel: func() { outputCancelled = true }}
err := h.SendSignal(syscall.SIGKILL, true)
require.ErrorContains(t, err, "does not own a process group")
require.NoError(t, cmd.Process.Signal(syscall.Signal(0)), "leader should remain alive")
assert.False(t, outputCancelled, "a rejected signal must keep the live process output connected")
}

func TestConfigureProcessGroup(t *testing.T) {
t.Parallel()

nonPTY := &syscall.SysProcAttr{}
configureProcessGroup(nonPTY, false)
assert.True(t, nonPTY.Setpgid)

pty := &syscall.SysProcAttr{}
configureProcessGroup(pty, true)
assert.False(t, pty.Setpgid, "PTY startup creates its own session")
}

func startCommandGroup(t *testing.T) *exec.Cmd {
t.Helper()

childReady := filepath.Join(t.TempDir(), "child.pid")
cmd := exec.CommandContext(t.Context(), "sh", "-c", fmt.Sprintf("sleep 60 & echo $! > %q; exec sleep 60", childReady))
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
require.NoError(t, cmd.Start())
require.Eventually(t, func() bool {
_, err := os.Stat(childReady)

return err == nil
}, 2*time.Second, 10*time.Millisecond)

return cmd
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ func (p *Handler) BeginReaping() {

select {
case <-t.C:
_ = p.SendSignal(syscall.SIGKILL)
_ = p.SendSignal(syscall.SIGKILL, false)
case <-p.outCtx.Done():
}
}()
Expand Down
2 changes: 1 addition & 1 deletion packages/envd/internal/services/process/signal.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (s *Service) SendSignal(
return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid signal: %s", req.Msg.GetSignal()))
}

err = handler.SendSignal(signal)
err = handler.SendSignal(signal, req.Msg.GetDescendants())
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error sending signal: %w", err))
}
Expand Down
Loading