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
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ The agent inside every VM (started by systemd very early in boot), port 49983, c

- **Process service** (`spec/process/process.proto`): start/list/connect to processes, stream
stdout/stderr, stdin, signals, PTYs — this is what SDKs use to "run code".
Signals target the managed leader by default. `SendSignalRequest.descendants` opts into
signaling its process group, after checking that the leader owns that group. Non-PTY commands
start in their own group; PTYs create a session/group at startup. Descendants that leave the
group (for example with `setsid`) are outside this guarantee. Automatic command timeouts
and natural leader exit retain their existing behavior; this option does not destroy the VM.
- **Filesystem service** (`spec/filesystem/filesystem.proto`): stat/list/make/move/remove/watch.
- **REST**: `/health`, `/metrics`, `/envs`, `/files` upload/download, `/init` (orchestrator pushes
env vars, access token, metadata after boot/resume), `/upgrade` (live self-upgrade, below),
Expand Down
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