// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT //go:build !windows && !plan9 package process import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" "github.com/stretchr/testify/require" ) // processAlive reports whether pid refers to a still-running process. Signal 0 // performs error checking without delivering a signal: a nil error (or EPERM) // means the process exists, ESRCH means it is gone. // // On Linux, zombie processes (state Z in /proc//stat) appear alive to // kill(0) but have already terminated — their corpse lingers until the parent // calls wait(). In a Docker container the child may be reparented to a PID 1 // that does not reap promptly, so we treat zombies as not alive. func processAlive(pid int) bool { err := syscall.Kill(pid, 0) if err != nil { return false } // On Linux /proc is available; check whether the process is a zombie. if b, readErr := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); readErr == nil { // Format: "pid (comm) state ..." — state follows the closing ')' of the // command name (which may itself contain spaces and parens). if _, after, found := strings.CutLast(string(b), ") "); found { fields := strings.Fields(after) if len(fields) > 0 && fields[0] == "Z" { return false // zombie: terminated but not yet reaped } } } return true } func TestKillerKillsTree(t *testing.T) { cmd, ready := newTestProcess(t, "parent", false) require.NoError(t, cmd.Start()) t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) _ = cmd.Wait() }) killer, err := NewKiller(cmd.Process) require.NoError(t, err) defer killer.Close() childPID := ready() require.True(t, processAlive(childPID), "child process should start") require.NoError(t, killer.Kill()) _ = cmd.Wait() require.Eventually(t, func() bool { return !processAlive(childPID) }, 20*time.Second, 100*time.Millisecond, "backgrounded child should be terminated") } func TestTreeKillCancelBeforeCaptureKillsTree(t *testing.T) { stdin, input, err := os.Pipe() require.NoError(t, err) t.Cleanup(func() { _ = stdin.Close() }) t.Cleanup(func() { _ = input.Close() }) cmd := exec.CommandContext(t.Context(), "/bin/sh", "-c", "exec 3<&0; cat <&3 & echo $!; read value") cmd.Stdin = stdin cmd.SysProcAttr = SysProcAttr("", false) output, err := cmd.StdoutPipe() require.NoError(t, err) treeKill := NewTreeKill(cmd) require.NoError(t, cmd.Start()) t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) _ = cmd.Wait() }) var childPID int _, err = fmt.Fscan(output, &childPID) require.NoError(t, err) require.True(t, processAlive(childPID)) require.NoError(t, cmd.Cancel()) killer, err := treeKill.Capture(cmd.Process) require.NoError(t, err) defer killer.Close() require.Error(t, cmd.Wait()) require.Eventually(t, func() bool { return !processAlive(childPID) }, 5*time.Second, 10*time.Millisecond) } func TestTreeKillCancelRunsInterruptTrap(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() marker := filepath.Join(t.TempDir(), "interrupted") cmd := exec.CommandContext(ctx, "/bin/sh", "-c", `trap 'echo interrupted > "$1"; exit 0' INT; echo ready; read value`, "sh", marker) cmd.SysProcAttr = SysProcAttr("", false) input, err := cmd.StdinPipe() require.NoError(t, err) defer input.Close() output, err := cmd.StdoutPipe() require.NoError(t, err) treeKill := NewTreeKill(cmd) treeKill.GracefulCancel = true require.NoError(t, cmd.Start()) killer, err := treeKill.Capture(cmd.Process) require.NoError(t, err) defer killer.Close() t.Cleanup(func() { _ = killer.Kill() _ = cmd.Wait() }) var ready string _, err = fmt.Fscanln(output, &ready) require.NoError(t, err) require.Equal(t, "ready", ready) cancel() require.ErrorIs(t, treeKill.Wait(ctx, cmd), context.Canceled) require.FileExists(t, marker) }