mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 07:10:28 +00:00
enhance: test on Windows and fix what it found (#1348)
Adds a weekly and on-demand workflow that runs the cross-compiled tests on Windows Server 2025 Core in a [dockur/windows](https://github.com/dockur/windows) QEMU VM, and fixes the Windows bugs it found in host and UNC paths, env handling, shell quoting, post-task scripts, health checks, checkout, action symlinks, step summaries, Git client certificates and docker mode from a Windows host. Host-mode steps now emit UTF-8, `bash` falls back to Git's bash instead of the WSL launcher, the runner can run as a native Windows service, steps can keep background helpers with `RUNNER_TRACKING_ID`, and Windows arm64 binaries are released. Fixes: https://gitea.com/gitea/runner/issues/1339 Fixes: https://gitea.com/gitea/runner/issues/637 Fixes: https://gitea.com/gitea/runner/issues/1172 Fixes: https://gitea.com/gitea/runner/issues/347 Reviewed-on: https://gitea.com/gitea/runner/pulls/1348 Reviewed-by: bircni <[email protected]>
This commit is contained in:
1 parent
6e42621b4a
commit
d6f5b68a0d
85 files changed
+2578
-620
No files matched your search
@@ -0,0 +1,8 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package process
|
||||
|
||||
func UTF8Console() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func UTF8Console() { // per step, as GitHub's runner runs chcp 65001 before its steps
|
||||
_ = windows.SetConsoleCP(65001)
|
||||
_ = windows.SetConsoleOutputCP(65001)
|
||||
}
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Group is a job-scoped Windows Job Object holding every process the job's steps
|
||||
// start; closing it terminates whatever is still assigned, reaching orphans the
|
||||
// per-step Killer misses (a completed step's leftover has no parent to walk from).
|
||||
// Group holds tracked step processes in a job-scoped Windows Job Object.
|
||||
type Group struct {
|
||||
mu sync.Mutex
|
||||
job windows.Handle
|
||||
|
||||
@@ -4,69 +4,25 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// startOrphanMaker starts a process that spawns a detached, long-lived child and
|
||||
// exits, leaving the child parentless. Returns the cmd and the child's PID file.
|
||||
func startOrphanMaker(t *testing.T) (*exec.Cmd, string) {
|
||||
t.Helper()
|
||||
|
||||
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
script := fmt.Sprintf(
|
||||
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
||||
`Set-Content -LiteralPath %q -Value $c.Id`, pidFile)
|
||||
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
|
||||
return cmd, pidFile
|
||||
}
|
||||
|
||||
// awaitChildPID waits until the spawned child has reported its PID and is running.
|
||||
func awaitChildPID(t *testing.T, pidFile string) int {
|
||||
t.Helper()
|
||||
|
||||
var childPID int
|
||||
require.Eventually(t, func() bool {
|
||||
b, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
childPID, _ = strconv.Atoi(s)
|
||||
return childPID > 0 && processAlive(childPID)
|
||||
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
||||
|
||||
return childPID
|
||||
}
|
||||
|
||||
// TestGroupClosePropagatesToOrphan covers what the per-step Killer cannot: a
|
||||
// completed step's orphan, reachable only by closing the job object.
|
||||
func TestGroupClosePropagatesToOrphan(t *testing.T) {
|
||||
group, err := NewGroup()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = group.Close() })
|
||||
|
||||
cmd, pidFile := startOrphanMaker(t)
|
||||
cmd, ready := newTestProcess(t, "orphan", false)
|
||||
require.NoError(t, cmd.Start())
|
||||
require.NoError(t, group.Assign(cmd.Process))
|
||||
|
||||
childPID := awaitChildPID(t, pidFile)
|
||||
childPID := ready()
|
||||
|
||||
require.NoError(t, cmd.Wait()) // the step process exits cleanly, like a passing step
|
||||
require.NoError(t, cmd.Wait())
|
||||
require.Eventually(t, func() bool {
|
||||
return !processAlive(cmd.Process.Pid)
|
||||
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,20 +26,18 @@ func processAlive(pid int) bool {
|
||||
}
|
||||
|
||||
func TestKillerKillsTree(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
|
||||
script := fmt.Sprintf(
|
||||
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
||||
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
||||
cmd, ready := newTestProcess(t, "parent", false)
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
|
||||
killer, err := NewKiller(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
defer killer.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = killer.Kill()
|
||||
_ = killer.Close()
|
||||
})
|
||||
|
||||
childPID := awaitChildPID(t, pidFile)
|
||||
childPID := ready()
|
||||
require.True(t, processAlive(childPID), "child process should start")
|
||||
|
||||
require.NoError(t, killer.Kill())
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ package process
|
||||
|
||||
import "context"
|
||||
|
||||
// KillProcessesWithCWDUnder is a no-op outside Windows: an open handle does not
|
||||
// block os.RemoveAll there.
|
||||
func KillProcessesWithCWDUnder(_ context.Context, _ []string) (int, error) {
|
||||
func KillProcessesWithPaths(_ctx context.Context, _dirs []string, _trackingID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -8,16 +8,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// KillProcessesWithCWDUnder terminates every process in the runner's session
|
||||
// whose cwd is one of dirs or below, catching leftovers the path scan misses
|
||||
// (Win32_Process exposes no cwd) that still pin a handle. Best-effort.
|
||||
func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error) {
|
||||
if len(dirs) == 0 {
|
||||
func KillProcessesWithPaths(ctx context.Context, dirs []string, trackingID string) (int, error) {
|
||||
if len(dirs) == 0 || trackingID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -38,7 +36,6 @@ func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// Never touch ourselves, System Idle (0) or System (4).
|
||||
if pid == self || pid == 0 || pid == 4 {
|
||||
continue
|
||||
}
|
||||
@@ -46,7 +43,7 @@ func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error)
|
||||
if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != ownSession {
|
||||
continue
|
||||
}
|
||||
ok, err := terminateIfCWDUnder(pid, dirs)
|
||||
ok, err := terminateIfPathsMatch(pid, dirs, trackingID)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
@@ -59,29 +56,50 @@ func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error)
|
||||
return killed, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// terminateIfCWDUnder opens pid once and both reads its cwd and terminates it
|
||||
// through that one handle, so a PID recycled between the two cannot be hit.
|
||||
func terminateIfCWDUnder(pid uint32, dirs []string) (bool, error) {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_TERMINATE, false, pid)
|
||||
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 // best-effort: gone, higher integrity, or another user
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(h) }()
|
||||
|
||||
cwd, err := processCWDHandle(h)
|
||||
if err != nil || cwd == "" {
|
||||
return false, nil
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
for _, d := range dirs {
|
||||
if dirContainsPath(d, cwd) {
|
||||
if err := windows.TerminateProcess(h, 1); err != nil {
|
||||
return false, fmt.Errorf("terminate pid %d: %w", pid, err)
|
||||
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
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
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.
|
||||
@@ -107,65 +125,135 @@ func listProcessIDs() ([]uint32, error) {
|
||||
return pids, nil
|
||||
}
|
||||
|
||||
// processCWDHandle reads a process's working directory from its PEB (walking
|
||||
// PEB -> ProcessParameters -> CurrentDirectory.DosPath). h must carry
|
||||
// PROCESS_QUERY_INFORMATION|PROCESS_VM_READ.
|
||||
func processCWDHandle(h windows.Handle) (string, error) {
|
||||
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(h, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pbi.PebBaseAddress == nil {
|
||||
return "", errors.New("nil PEB base address")
|
||||
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))
|
||||
|
||||
paramsAddr, err := readRemotePtr(h, pebAddr+unsafe.Offsetof(windows.PEB{}.ProcessParameters))
|
||||
if err != nil || paramsAddr == 0 {
|
||||
return "", err
|
||||
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
|
||||
}
|
||||
|
||||
// DosPath is the leading NTUnicodeString of CURDIR, so it shares CurrentDirectory's address.
|
||||
dosPathAddr := paramsAddr + unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory)
|
||||
length, err := readRemoteU16(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Length))
|
||||
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
|
||||
}
|
||||
bufAddr, err := readRemotePtr(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Buffer))
|
||||
if err != nil || bufAddr == 0 {
|
||||
buffer, err := p.pointer(handle, address+p.offset(unsafe.Offsetof(windows.NTUnicodeString{}.Buffer), 4))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u16 := make([]uint16, (length+1)/2) // round up so an odd Length cannot overflow the buffer
|
||||
var n uintptr
|
||||
if err := windows.ReadProcessMemory(h, bufAddr, (*byte)(unsafe.Pointer(&u16[0])), uintptr(length), &n); err != nil {
|
||||
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 n != uintptr(length) {
|
||||
return "", errors.New("short read of working directory")
|
||||
if read != uintptr(length) {
|
||||
return "", errors.New("short string read")
|
||||
}
|
||||
return windows.UTF16ToString(u16), nil
|
||||
return windows.UTF16ToString(value), nil
|
||||
}
|
||||
|
||||
// readRemotePtr reads a single pointer-sized value from another process.
|
||||
func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) {
|
||||
var v uintptr
|
||||
var n uintptr
|
||||
if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
|
||||
return 0, err
|
||||
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
|
||||
}
|
||||
if n != unsafe.Sizeof(v) {
|
||||
return 0, errors.New("short pointer read")
|
||||
var entry []uint16
|
||||
for consumed := uintptr(0); consumed < 32*1024*1024; {
|
||||
var region windows.MemoryBasicInformation
|
||||
if err := windows.VirtualQueryEx(handle, address+consumed, ®ion, 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 v, nil
|
||||
return "", errors.New("environment exceeds read limit")
|
||||
}
|
||||
|
||||
// readRemoteU16 reads a single uint16 from another process.
|
||||
func readRemoteU16(h windows.Handle, addr uintptr) (uint16, error) {
|
||||
func readRemoteU16(handle windows.Handle, addr uintptr) (uint16, error) {
|
||||
var v uint16
|
||||
var n uintptr
|
||||
if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
|
||||
if err := windows.ReadProcessMemory(handle, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n != unsafe.Sizeof(v) {
|
||||
|
||||
@@ -7,21 +7,21 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// startSleeperIn launches a long-running child whose cwd is dir but whose
|
||||
// executable lives outside it — what the path scan cannot see.
|
||||
func startSleeperIn(t *testing.T, dir string) *exec.Cmd {
|
||||
func startSleeperIn(t *testing.T, system, dir, trackingID string) *exec.Cmd {
|
||||
t.Helper()
|
||||
ping := filepath.Join(os.Getenv("SystemRoot"), "System32", "ping.exe")
|
||||
cmd := exec.Command(ping, "-n", "60", "127.0.0.1")
|
||||
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() {
|
||||
@@ -31,19 +31,19 @@ func startSleeperIn(t *testing.T, dir string) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// processCWD opens pid and reads its working directory. Test-only; the reaper
|
||||
// shares one handle across read and kill via processCWDHandle.
|
||||
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) }()
|
||||
return processCWDHandle(h)
|
||||
params, err := processParametersHandle(h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return params.unicodeString(h, unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory), 0x24)
|
||||
}
|
||||
|
||||
// awaitCWD waits until pid's PEB reports a readable cwd (not populated the
|
||||
// instant the process starts) and returns it.
|
||||
func awaitCWD(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
var cwd string
|
||||
@@ -55,36 +55,52 @@ func awaitCWD(t *testing.T, pid int) string {
|
||||
return cwd
|
||||
}
|
||||
|
||||
func TestProcessCWDReadsWorkingDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmd := startSleeperIn(t, dir)
|
||||
cwd := awaitCWD(t, cmd.Process.Pid)
|
||||
require.True(t, dirContainsPath(dir, cwd), "cwd %q should be within %q", cwd, dir)
|
||||
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 TestKillProcessesWithCWDUnderTerminatesMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Executable outside dir and arguments carry no workspace path, so only the
|
||||
// working directory links this process to dir.
|
||||
cmd := startSleeperIn(t, dir)
|
||||
awaitCWD(t, cmd.Process.Pid)
|
||||
|
||||
killed, err := KillProcessesWithCWDUnder(t.Context(), []string{dir})
|
||||
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.GreaterOrEqual(t, killed, 1)
|
||||
|
||||
require.Equal(t, 1, killed)
|
||||
require.Eventually(t, func() bool {
|
||||
return !processAlive(cmd.Process.Pid)
|
||||
}, 5*time.Second, 50*time.Millisecond, "matched process should exit")
|
||||
}
|
||||
|
||||
func TestKillProcessesWithCWDUnderSparesUnrelated(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
cmd := startSleeperIn(t, elsewhere)
|
||||
awaitCWD(t, cmd.Process.Pid)
|
||||
|
||||
_, err := KillProcessesWithCWDUnder(t.Context(), []string{workspace})
|
||||
require.NoError(t, err)
|
||||
require.True(t, processAlive(cmd.Process.Pid), "process outside owned dirs must be spared")
|
||||
}, 5*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
@@ -32,7 +32,8 @@ const treeKillWaitDelay = 10 * time.Second
|
||||
// Callers still set cmd.SysProcAttr (via SysProcAttr) themselves, because the
|
||||
// value differs between the plain and PTY execution paths.
|
||||
type TreeKill struct {
|
||||
killer atomic.Pointer[Killer]
|
||||
killer atomic.Pointer[Killer]
|
||||
KeepLeftovers bool
|
||||
}
|
||||
|
||||
// NewTreeKill sets cmd.Cancel and cmd.WaitDelay. Call it before cmd.Start, then
|
||||
@@ -72,13 +73,13 @@ func (t *TreeKill) Capture(p *os.Process) (*Killer, error) {
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// Wait is cmd.Wait, but like GitHub's runner a leftover holding a successful command's output does not fail it, and is killed only on Windows.
|
||||
// Wait is cmd.Wait, but like GitHub's runner a leftover holding a successful command's output does not fail it.
|
||||
func (t *TreeKill) Wait(ctx context.Context, cmd *exec.Cmd) error {
|
||||
err := cmd.Wait()
|
||||
if !errors.Is(err, exec.ErrWaitDelay) || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if runtime.GOOS == "windows" && !t.KeepLeftovers {
|
||||
_ = t.killer.Load().Kill()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,8 +5,11 @@ package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -60,11 +63,10 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTreeKillCaptureStoresKiller(t *testing.T) {
|
||||
cmd := exec.CommandContext(t.Context(), "sleep", "10")
|
||||
cmd.SysProcAttr = SysProcAttr("", false)
|
||||
cmd, ready := newTestProcess(t, "child", false)
|
||||
tk := NewTreeKill(cmd)
|
||||
require.NoError(t, cmd.Start())
|
||||
defer func() { _ = cmd.Wait() }()
|
||||
ready()
|
||||
|
||||
killer, err := tk.Capture(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
@@ -72,3 +74,84 @@ func TestTreeKillCaptureStoresKiller(t *testing.T) {
|
||||
require.NoError(t, cmd.Cancel())
|
||||
require.NoError(t, killer.Close())
|
||||
}
|
||||
|
||||
func newTestProcess(t *testing.T, mode string, copiedOutput bool) (*exec.Cmd, func() int) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestProcessHelper$")
|
||||
cmd.Env = append(os.Environ(), "GITEA_PROCESS_HELPER="+mode)
|
||||
cmd.SysProcAttr = SysProcAttr("", false)
|
||||
cmd.Stderr = os.Stderr
|
||||
input, err := cmd.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
var output io.ReadCloser
|
||||
if copiedOutput {
|
||||
var writer *io.PipeWriter
|
||||
output, writer = io.Pipe()
|
||||
cmd.Stdout = writer
|
||||
} else {
|
||||
output, err = cmd.StdoutPipe()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
_ = input.Close()
|
||||
_ = output.Close()
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
_ = input.Close()
|
||||
_ = output.Close()
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
})
|
||||
return cmd, func() int {
|
||||
t.Helper()
|
||||
_, err := input.Write([]byte{1})
|
||||
require.NoError(t, err)
|
||||
var childPID int
|
||||
_, err = fmt.Fscan(output, &childPID)
|
||||
require.NoError(t, err, "process should report its PID")
|
||||
require.Positive(t, childPID)
|
||||
return childPID
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHelper(t *testing.T) {
|
||||
mode := os.Getenv("GITEA_PROCESS_HELPER")
|
||||
switch mode {
|
||||
case "":
|
||||
return
|
||||
case "orphan-child":
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
_, err := fmt.Fprintln(os.Stdout, os.Getpid())
|
||||
require.NoError(t, err)
|
||||
<-interrupt
|
||||
case "child":
|
||||
_, err := fmt.Fprintln(os.Stdout, os.Getpid())
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(io.Discard, os.Stdin)
|
||||
require.NoError(t, err)
|
||||
default:
|
||||
var start [1]byte
|
||||
_, err := io.ReadFull(os.Stdin, start[:])
|
||||
require.NoError(t, err)
|
||||
child := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
|
||||
child.Env = append(os.Environ(), "GITEA_PROCESS_HELPER=child")
|
||||
if mode == "orphan" {
|
||||
child.Env = append(os.Environ(), "GITEA_PROCESS_HELPER=orphan-child")
|
||||
}
|
||||
child.Stdin = os.Stdin
|
||||
child.Stdout = os.Stdout
|
||||
child.Stderr = os.Stderr
|
||||
require.NoError(t, child.Start())
|
||||
if mode == "orphan" {
|
||||
os.Exit(0)
|
||||
}
|
||||
require.NoError(t, child.Wait())
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestTreeKillWaitTrackingLeftoverOutput(t *testing.T) {
|
||||
for _, keep := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "tracked", true: "opted out"}[keep], func(t *testing.T) {
|
||||
cmd, ready := newTestProcess(t, "orphan", true)
|
||||
tree := NewTreeKill(cmd)
|
||||
tree.KeepLeftovers = keep
|
||||
cmd.WaitDelay = 50 * time.Millisecond
|
||||
require.NoError(t, cmd.Start())
|
||||
killer, err := tree.Capture(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
defer killer.Close()
|
||||
childPID := ready()
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, uint32(childPID))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = windows.TerminateProcess(handle, 1)
|
||||
_, _ = windows.WaitForSingleObject(handle, 5000)
|
||||
_ = windows.CloseHandle(handle)
|
||||
})
|
||||
require.NoError(t, tree.Wait(t.Context(), cmd))
|
||||
if keep {
|
||||
result, err := windows.WaitForSingleObject(handle, 0)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, windows.WAIT_TIMEOUT, result)
|
||||
} else {
|
||||
result, err := windows.WaitForSingleObject(handle, 5000)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, windows.WAIT_OBJECT_0, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user