26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
92 lines
4.0 KiB
Go
92 lines
4.0 KiB
Go
//go:build unix
|
|
|
|
package shellenv
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// defaultWaitDelay is the pipe backstop installed on cmd.WaitDelay, mirroring
|
|
// the Windows helper. After a cancelled command's leader is signalled, a
|
|
// grandchild that inherited and still holds open the leader's stdout/stderr
|
|
// pipe would otherwise wedge cmd.Wait (and any in-flight pipe Read) forever.
|
|
// A nonzero WaitDelay lets the exec package close those inherited pipes and
|
|
// return instead of blocking. It is a worst-case ceiling only: on a clean exit
|
|
// the pipes close immediately and Wait returns without waiting.
|
|
const defaultWaitDelay = 5 * time.Second
|
|
|
|
// ConfigureShellCommand isolates cmd in its own process group (Setpgid) and
|
|
// installs a cmd.Cancel that SIGKILLs the whole group when cmd's context is
|
|
// cancelled. exec.CommandContext otherwise only kills the direct child PID,
|
|
// leaving grandchildren (a test runner's worker processes, an agent-spawned
|
|
// git/build/editor) running and holding the worktree locked.
|
|
//
|
|
// Cancellation is only half the lifecycle: cmd.Cancel never fires when the
|
|
// command exits on its own (success or failure). Use RunShellCommand,
|
|
// OutputShellCommand, or CombinedOutputShellCommand for one-shot commands, or
|
|
// use StartShellCommand and defer TerminateShellCommandGroup immediately after
|
|
// a successful start when the caller needs manual pipe handling. If a parser
|
|
// reads stdout/stderr until EOF, the goroutine that owns Wait should terminate
|
|
// the group when the leader exits so inherited pipe holders cannot wedge the
|
|
// parser.
|
|
//
|
|
// Apply this to every long-lived subprocess no-mistakes spawns on behalf of a
|
|
// cancellable step/agent invocation.
|
|
func ConfigureShellCommand(cmd *exec.Cmd) {
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
// Install the WaitDelay backstop unless the caller picked one explicitly
|
|
// (the short login-shell probe uses a tighter bound of its own).
|
|
if cmd.WaitDelay == 0 {
|
|
cmd.WaitDelay = defaultWaitDelay
|
|
}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process == nil {
|
|
return os.ErrProcessDone
|
|
}
|
|
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
if errors.Is(err, syscall.ESRCH) {
|
|
return os.ErrProcessDone
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// StartShellCommand starts cmd after ConfigureShellCommand has prepared its
|
|
// process-group lifecycle. Unix needs no extra setup beyond cmd.Start, but the
|
|
// wrapper keeps call sites aligned with Windows job-object setup.
|
|
func StartShellCommand(cmd *exec.Cmd) error {
|
|
return cmd.Start()
|
|
}
|
|
|
|
// TerminateShellCommandGroup SIGKILLs the whole process group led by a command
|
|
// configured with ConfigureShellCommand. It is the success/failure-path
|
|
// counterpart to cmd.Cancel: callers defer it right after a successful Start so
|
|
// the group is reaped however Run returns - clean exit, parse error, or
|
|
// wait error - not only on context cancellation.
|
|
//
|
|
// Why this matters: Setpgid puts each agent/command in its own group, but a
|
|
// test runner's worker pool, a build watcher, or a dev server the agent spawned
|
|
// can outlive the leader. On a normal exit nothing signals the group, so those
|
|
// grandchildren reparent to init and keep running (and keep their memory). They
|
|
// accumulate across runs until the host is out of memory, at which point the OS
|
|
// OOM-killer reaps processes - including the daemon - with an uncatchable
|
|
// SIGKILL, surfacing as "daemon crashed during execution". Reaping the group on
|
|
// every exit path closes that leak so the test step can never take the daemon
|
|
// down.
|
|
//
|
|
// It is safe to call unconditionally after Wait: the group persists only while
|
|
// a member is alive, so when the leader exited cleanly with no survivors the
|
|
// kill is a harmless no-op (ESRCH). A nil or never-started command is a no-op.
|
|
func TerminateShellCommandGroup(cmd *exec.Cmd) {
|
|
if cmd == nil || cmd.Process == nil {
|
|
return
|
|
}
|
|
// Negative PID targets the whole group (Setpgid made the leader's PID the
|
|
// group ID). errors.Is(ESRCH) is the expected, benign "no survivors" case.
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|