fix: align process handling and fix Windows crashes (#1376)

The intermittent Windows test crashes came from step errors formatting a nil `rc.Run`. `fmt` recovers the nil dereference, but on CPUs with AMX the recovered fault overflows into the heap (https://github.com/golang/go/issues/81238). The errors now name the job, and the Windows test VM hides AMX and runs on UTC, so cached boots no longer jump back 8 hours mid-test.

Windows commands now start suspended and resume once they are in their job objects, so no child escapes them. That drops the process scan reading other processes' memory and the last `unsafe` code, which `depguard` now denies. Step processes outlive a crashed runner, and commands are no longer looked up in the runner's working directory, both as with GitHub's runner.

Like GitHub's runner, host jobs on Linux and macOS now end the processes carrying the job's `RUNNER_TRACKING_ID`, and image pulls and Dockerfile action builds are retried.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1376
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and bircni committed 2026-11-04 16:32:22 +00:00
1 parent 142d9f8c88
commit dcba546cb3
32 files changed
+374 -593

No files matched your search

+3 -20
View File
@@ -4,9 +4,9 @@
package process
import (
"errors"
"os"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
@@ -24,22 +24,6 @@ func NewGroup() (*Group, error) {
if err != nil {
return nil, err
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
return nil, err
}
return &Group{job: job}, nil
}
@@ -65,8 +49,7 @@ func (g *Group) Assign(p *os.Process) error {
return windows.AssignProcessToJobObject(g.job, h)
}
// Close drops the job handle, terminating every process still assigned to it.
// Nil-safe and idempotent.
// Close terminates every process still assigned to the job and drops its handle, nil-safe and idempotent.
func (g *Group) Close() error {
if g == nil {
return nil
@@ -80,5 +63,5 @@ func (g *Group) Close() error {
h := g.job
g.job = 0
return windows.CloseHandle(h)
return errors.Join(windows.TerminateJobObject(h, 1), windows.CloseHandle(h))
}
+14 -2
View File
@@ -5,6 +5,7 @@ package process
import (
"os"
"sync"
"golang.org/x/sys/windows"
)
@@ -23,6 +24,7 @@ import (
// on cancellation (TerminateJobObject), which also closes the inherited pipe
// handles so cmd.Wait() can return.
type Killer struct {
mu sync.Mutex
job windows.Handle
}
@@ -55,7 +57,12 @@ func NewKiller(p *os.Process) (*Killer, error) {
// Kill terminates every process currently assigned to the job (the process and
// all of its descendants).
func (k *Killer) Kill() error {
if k == nil || k.job == 0 {
if k == nil {
return nil
}
k.mu.Lock()
defer k.mu.Unlock()
if k.job == 0 {
return nil
}
return windows.TerminateJobObject(k.job, 1)
@@ -63,7 +70,12 @@ func (k *Killer) Kill() error {
// Close releases the job handle. It does not terminate the processes.
func (k *Killer) Close() error {
if k == nil || k.job == 0 {
if k == nil {
return nil
}
k.mu.Lock()
defer k.mu.Unlock()
if k.job == 0 {
return nil
}
h := k.job
-21
View File
@@ -1,21 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"path/filepath"
"strings"
)
func dirContainsPath(dir, target string) bool {
dir = strings.ToLower(filepath.Clean(dir))
target = strings.ToLower(filepath.Clean(target))
if dir == "." || target == "." {
return false
}
if dir == target {
return true
}
return strings.HasPrefix(target, strings.TrimSuffix(dir, string(filepath.Separator))+string(filepath.Separator))
}
-12
View File
@@ -1,12 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package process
import "context"
func KillProcessesWithPaths(_ctx context.Context, _dirs []string, _trackingID string) (int, error) {
return 0, nil
}
-39
View File
@@ -1,39 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"path/filepath"
"testing"
)
func TestDirContainsPath(t *testing.T) {
base := filepath.Join("base", "job1")
cases := []struct {
name string
dir string
target string
want bool
}{
{"same dir", base, base, true},
{"direct child", base, filepath.Join(base, "app.exe"), true},
{"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), true},
{"parent is not within child", filepath.Join(base, "sub"), base, false},
{"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false},
{"unrelated", base, filepath.Join("other", "app.exe"), false},
{"empty dir", "", base, false},
{"empty target", base, "", false},
{"dot dir", ".", base, false},
{"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := dirContainsPath(tc.dir, tc.target)
if got != tc.want {
t.Fatalf("dirContainsPath(%q, %q) = %v, want %v", tc.dir, tc.target, got, tc.want)
}
})
}
}
-263
View File
@@ -1,263 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"context"
"errors"
"fmt"
"os"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
func KillProcessesWithPaths(ctx context.Context, dirs []string, trackingID string) (int, error) {
if len(dirs) == 0 || trackingID == "" {
return 0, nil
}
pids, err := listProcessIDs()
if err != nil {
return 0, err
}
self := uint32(os.Getpid())
var ownSession uint32
if err := windows.ProcessIdToSessionId(self, &ownSession); err != nil {
return 0, fmt.Errorf("look up own session: %w", err)
}
var killed int
var errs []error
for _, pid := range pids {
if ctx.Err() != nil {
break
}
if pid == self || pid == 0 || pid == 4 {
continue
}
var session uint32
if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != ownSession {
continue
}
ok, err := terminateIfPathsMatch(pid, dirs, trackingID)
if err != nil {
errs = append(errs, err)
continue
}
if ok {
killed++
}
}
return killed, errors.Join(errs...)
}
func terminateIfPathsMatch(pid uint32, dirs []string, trackingID string) (bool, error) {
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return false, nil
}
defer func() { _ = windows.CloseHandle(handle) }()
params, err := processParametersHandle(handle)
if err != nil {
return false, nil
}
matches := false
fields:
for _, field := range []struct {
offset uintptr
wow64 uintptr
command bool
}{
{unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory), 0x24, false},
{unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.ImagePathName), 0x38, false},
{unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CommandLine), 0x40, true},
} {
value, err := params.unicodeString(handle, field.offset, field.wow64)
if err != nil {
continue
}
for _, dir := range dirs {
if !field.command && dirContainsPath(dir, value) || field.command && strings.Contains(strings.ToLower(value), strings.ToLower(strings.TrimRight(dir, `\/`)+`\`)) {
matches = true
break fields
}
}
}
if !matches {
return false, nil
}
value, err := params.environmentVariable(handle, "RUNNER_TRACKING_ID")
if err != nil || value != trackingID {
return false, nil
}
if err := windows.TerminateProcess(handle, 1); err != nil {
return false, fmt.Errorf("terminate pid %d: %w", pid, err)
}
return true, nil
}
// listProcessIDs returns the PIDs of every process in a Toolhelp snapshot.
func listProcessIDs() ([]uint32, error) {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
defer func() { _ = windows.CloseHandle(snap) }()
var entry windows.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
pids := make([]uint32, 0, 256)
err = windows.Process32First(snap, &entry)
for err == nil {
pids = append(pids, entry.ProcessID)
err = windows.Process32Next(snap, &entry)
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return pids, err
}
return pids, nil
}
type remoteParameters struct {
address uintptr
wow64 bool
}
func processParametersHandle(handle windows.Handle) (remoteParameters, error) {
var pbi windows.PROCESS_BASIC_INFORMATION
var retLen uint32
if err := windows.NtQueryInformationProcess(handle, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
return remoteParameters{}, err
}
pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
wow64 := false
if unsafe.Sizeof(uintptr(0)) == 8 {
var peb32 uintptr
if err := windows.NtQueryInformationProcess(handle, windows.ProcessWow64Information, unsafe.Pointer(&peb32), uint32(unsafe.Sizeof(peb32)), &retLen); err != nil {
return remoteParameters{}, err
}
if peb32 != 0 {
pebAddr = peb32
wow64 = true
}
}
if pebAddr == 0 {
return remoteParameters{}, errors.New("nil PEB base address")
}
params := remoteParameters{wow64: wow64}
address, err := params.pointer(handle, pebAddr+params.offset(unsafe.Offsetof(windows.PEB{}.ProcessParameters), 0x10))
if err != nil {
return remoteParameters{}, err
}
if address == 0 {
return remoteParameters{}, errors.New("nil process parameters")
}
params.address = address
return params, nil
}
func (p remoteParameters) offset(native, wow64 uintptr) uintptr {
if p.wow64 {
return wow64
}
return native
}
func (p remoteParameters) pointer(handle windows.Handle, address uintptr) (uintptr, error) {
var value uintptr
size := unsafe.Sizeof(value)
if p.wow64 {
size = 4
}
var read uintptr
if err := windows.ReadProcessMemory(handle, address, (*byte)(unsafe.Pointer(&value)), size, &read); err != nil {
return 0, err
}
if read != size {
return 0, errors.New("short pointer read")
}
return value, nil
}
func (p remoteParameters) unicodeString(handle windows.Handle, native, wow64 uintptr) (string, error) {
address := p.address + p.offset(native, wow64)
length, err := readRemoteU16(handle, address)
if err != nil || length == 0 {
return "", err
}
buffer, err := p.pointer(handle, address+p.offset(unsafe.Offsetof(windows.NTUnicodeString{}.Buffer), 4))
if err != nil {
return "", err
}
value := make([]uint16, (int(length)+1)/2)
var read uintptr
if err := windows.ReadProcessMemory(handle, buffer, (*byte)(unsafe.Pointer(&value[0])), uintptr(length), &read); err != nil {
return "", err
}
if read != uintptr(length) {
return "", errors.New("short string read")
}
return windows.UTF16ToString(value), nil
}
func (p remoteParameters) environmentVariable(handle windows.Handle, name string) (string, error) {
address, err := p.pointer(handle, p.address+p.offset(unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.Environment), 0x48))
if err != nil || address == 0 {
return "", err
}
var entry []uint16
for consumed := uintptr(0); consumed < 32*1024*1024; {
var region windows.MemoryBasicInformation
if err := windows.VirtualQueryEx(handle, address+consumed, &region, unsafe.Sizeof(region)); err != nil {
return "", err
}
size := min(uintptr(4096), region.BaseAddress+region.RegionSize-address-consumed)
if size < 2 {
return "", errors.New("invalid environment region")
}
buffer := make([]uint16, size/2)
var read uintptr
if err := windows.ReadProcessMemory(handle, address+consumed, (*byte)(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)*2), &read); err != nil {
return "", err
}
if read != uintptr(len(buffer)*2) {
return "", errors.New("short environment read")
}
for _, value := range buffer {
if value != 0 {
entry = append(entry, value)
continue
}
if len(entry) == 0 {
return "", nil
}
key, value, ok := strings.Cut(windows.UTF16ToString(entry), "=")
if ok && strings.EqualFold(key, name) {
return value, nil
}
entry = entry[:0]
}
consumed += read
}
return "", errors.New("environment exceeds read limit")
}
// readRemoteU16 reads a single uint16 from another process.
func readRemoteU16(handle windows.Handle, addr uintptr) (uint16, error) {
var v uint16
var n uintptr
if err := windows.ReadProcessMemory(handle, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
return 0, err
}
if n != unsafe.Sizeof(v) {
return 0, errors.New("short uint16 read")
}
return v, nil
}
@@ -1,106 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
"unsafe"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func startSleeperIn(t *testing.T, system, dir, trackingID string) *exec.Cmd {
t.Helper()
cmd := exec.Command(filepath.Join(os.Getenv("SystemRoot"), system, "ping.exe"), "-n", "60", "127.0.0.1")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "RUNNER_TRACKING_ID="+trackingID)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
return cmd
}
func processCWD(pid uint32) (string, error) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return "", err
}
defer func() { _ = windows.CloseHandle(h) }()
params, err := processParametersHandle(h)
if err != nil {
return "", err
}
return params.unicodeString(h, unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory), 0x24)
}
func awaitCWD(t *testing.T, pid int) string {
t.Helper()
var cwd string
require.Eventually(t, func() bool {
var err error
cwd, err = processCWD(uint32(pid))
return err == nil && cwd != ""
}, 5*time.Second, 50*time.Millisecond, "process cwd should become readable")
return cwd
}
func TestKillProcessesWithPathsTracking(t *testing.T) {
for _, system := range []string{"System32", "SysWOW64"} {
for _, trackingID := range []string{"leftover-test", "", "another-job"} {
t.Run(system+"/tracking="+trackingID, func(t *testing.T) {
if system == "SysWOW64" {
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
t.Skip("needs a 64-bit runner")
}
if _, err := os.Stat(filepath.Join(os.Getenv("SystemRoot"), system, "ping.exe")); err != nil {
t.Skip("32-bit Windows tools unavailable")
}
}
dir := t.TempDir()
cmd := startSleeperIn(t, system, dir, trackingID)
require.True(t, dirContainsPath(dir, awaitCWD(t, cmd.Process.Pid)))
killed, err := KillProcessesWithPaths(t.Context(), []string{t.TempDir()}, "leftover-test")
require.NoError(t, err)
require.Zero(t, killed)
killed, err = KillProcessesWithPaths(t.Context(), []string{dir}, "leftover-test")
require.NoError(t, err)
if trackingID == "leftover-test" {
require.Equal(t, 1, killed)
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 5*time.Second, 50*time.Millisecond)
} else {
require.Zero(t, killed)
require.True(t, processAlive(cmd.Process.Pid))
}
})
}
}
}
func TestKillProcessesWithPathsCommandLine(t *testing.T) {
workspace := t.TempDir()
cmd, ready := newTestProcess(t, "child", false)
cmd.Dir = t.TempDir()
cmd.Args = append(cmd.Args, filepath.Join(workspace, "reference"))
cmd.Env = append(cmd.Env, "RUNNER_TRACKING_ID=command-test")
require.NoError(t, cmd.Start())
ready()
killed, err := KillProcessesWithPaths(t.Context(), []string{workspace}, "command-test")
require.NoError(t, err)
require.Equal(t, 1, killed)
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid)
}, 5*time.Second, 50*time.Millisecond)
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package process
import (
"os"
"os/exec"
)
func startSuspended(_cmd *exec.Cmd) {}
func resume(_process *os.Process) error {
return nil
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
var procNtResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess")
// startSuspended holds the process until Capture has assigned it to its job objects, so no child it starts escapes them.
func startSuspended(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED
}
func resume(process *os.Process) error {
var status windows.NTStatus
if err := process.WithHandle(func(handle uintptr) {
ret, _, _ := procNtResumeProcess.Call(handle)
status = windows.NTStatus(ret)
}); err != nil {
return err
}
if status == windows.STATUS_SUCCESS || status == windows.STATUS_PROCESS_IS_TERMINATING { // cancellation may have ended it already
return nil
}
return status
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build linux || darwin
package process
import (
"bytes"
"errors"
"fmt"
"os"
"slices"
"syscall"
)
// KillTrackedProcesses kills every process whose environment carries the job's RUNNER_TRACKING_ID, like GitHub's orphan cleanup.
func KillTrackedProcesses(trackingID string) (int, error) {
if trackingID == "" {
return 0, nil
}
pids, err := listPIDs()
if err != nil {
return 0, err
}
marker := []byte("RUNNER_TRACKING_ID=" + trackingID)
self := os.Getpid()
var killed int
var errs []error
for _, pid := range pids {
if pid == self {
continue
}
env, err := environment(pid)
if err != nil || !slices.ContainsFunc(bytes.Split(env, []byte{0}), func(entry []byte) bool { return bytes.Equal(entry, marker) }) {
continue
}
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
errs = append(errs, fmt.Errorf("kill pid %d: %w", pid, err))
continue
}
killed++
}
return killed, errors.Join(errs...)
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"bytes"
"encoding/binary"
"errors"
"golang.org/x/sys/unix"
)
func listPIDs() ([]int, error) {
procs, err := unix.SysctlKinfoProcSlice("kern.proc.all")
if err != nil {
return nil, err
}
pids := make([]int, 0, len(procs))
for _, proc := range procs {
pids = append(pids, int(proc.Proc.P_pid))
}
return pids, nil
}
// environment skips kern.procargs2's argc, executable path and arguments to the NUL-separated environment.
func environment(pid int) ([]byte, error) {
args, err := unix.SysctlRaw("kern.procargs2", pid)
if err != nil {
return nil, err
}
if len(args) < 4 {
return nil, errors.New("short kern.procargs2")
}
_, rest, _ := bytes.Cut(args[4:], []byte{0})
rest = bytes.TrimLeft(rest, "\x00")
for range binary.NativeEndian.Uint32(args) {
_, rest, _ = bytes.Cut(rest, []byte{0})
}
return rest, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package process
import (
"os"
"strconv"
)
func listPIDs() ([]int, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, err
}
var pids []int
for _, entry := range entries {
if pid, err := strconv.Atoi(entry.Name()); err == nil {
pids = append(pids, pid)
}
}
return pids, nil
}
func environment(pid int) ([]byte, error) {
return os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !linux && !darwin
package process
// KillTrackedProcesses does nothing, Windows ends a job's processes through its job object.
func KillTrackedProcesses(_trackingID string) (int, error) {
return 0, nil
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build linux || darwin
package process
import (
"os"
"os/exec"
"strconv"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestKillTrackedProcessesKillsOnlyTheJobsProcesses(t *testing.T) {
trackingID := "gitea_test_" + strconv.FormatInt(time.Now().UnixNano(), 10)
start := func(id string) *exec.Cmd {
cmd := exec.Command("sleep", "60")
cmd.Env = append(os.Environ(), "RUNNER_TRACKING_ID="+id)
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
return cmd
}
tracked, other := start(trackingID), start(trackingID+"_other")
killed, err := KillTrackedProcesses(trackingID)
require.NoError(t, err)
require.Equal(t, 1, killed)
require.ErrorContains(t, tracked.Wait(), "killed")
require.NoError(t, other.Process.Signal(syscall.Signal(0)))
}
+10 -7
View File
@@ -36,9 +36,9 @@ type TreeKill struct {
KeepLeftovers bool
}
// NewTreeKill sets cmd.Cancel and cmd.WaitDelay. Call it before cmd.Start, then
// call Capture once after a successful Start.
// NewTreeKill sets cmd.Cancel, cmd.WaitDelay and CREATE_SUSPENDED. Call it after setting cmd.SysProcAttr, and Capture once after a successful Start.
func NewTreeKill(cmd *exec.Cmd) *TreeKill {
startSuspended(cmd)
t := &TreeKill{}
cmd.Cancel = func() error {
if k := t.killer.Load(); k != nil {
@@ -63,14 +63,17 @@ func NewTreeKill(cmd *exec.Cmd) *TreeKill {
// Call it once after cmd.Start. On failure the command falls back to the default
// single-process kill and the returned error is for logging only; WaitDelay
// still bounds the wait. The returned Killer should be closed when the command
// finishes (Close is nil-safe).
// finishes (Close is nil-safe). It also resumes p.
func (t *TreeKill) Capture(p *os.Process) (*Killer, error) {
k, err := NewKiller(p)
if err != nil {
return nil, err
if err == nil {
t.killer.Store(k)
}
t.killer.Store(k)
return k, nil
if resumeErr := resume(p); resumeErr != nil {
_ = p.Kill()
return nil, errors.Join(err, k.Close(), resumeErr)
}
return k, err
}
// Wait is cmd.Wait, but like GitHub's runner a leftover holding a successful command's output does not fail it.
+7 -5
View File
@@ -33,14 +33,13 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
}
for _, tc := range []struct {
name string
timeout time.Duration
wantErr bool
}{
{name: "succeeds", timeout: time.Minute},
{name: "fails once the context is done", timeout: 10 * time.Millisecond, wantErr: true},
{name: "succeeds"},
{name: "fails once the context is done", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), tc.timeout)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 30 &")
cmd.Stdout = io.Discard
@@ -51,6 +50,9 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
killer, err := tk.Capture(cmd.Process)
require.NoError(t, err)
t.Cleanup(func() { _ = killer.Kill() })
if tc.wantErr {
cancel()
}
err = tk.Wait(ctx, cmd)
if tc.wantErr {
@@ -66,11 +68,11 @@ func TestTreeKillCaptureStoresKiller(t *testing.T) {
cmd, ready := newTestProcess(t, "child", false)
tk := NewTreeKill(cmd)
require.NoError(t, cmd.Start())
ready()
killer, err := tk.Capture(cmd.Process)
require.NoError(t, err)
require.NotNil(t, killer)
ready()
require.NoError(t, cmd.Cancel())
require.NoError(t, killer.Close())
}