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:
silverwind committed 2026-11-03 01:26:10 +00:00
1 parent 6e42621b4a
commit d6f5b68a0d
85 files changed
+2578 -620

No files matched your search

+5 -1
View File
@@ -46,7 +46,11 @@ func Execute(ctx context.Context) {
Use: "daemon",
Short: "Run as a runner daemon",
Args: cobra.MaximumNArgs(0),
RunE: runDaemon(ctx, &daemArgs, &configFile),
RunE: func(command *cobra.Command, args []string) error {
return runService(ctx, func(ctx context.Context) error {
return runDaemon(ctx, &daemArgs, &configFile)(command, args)
})
},
}
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
daemonCmd.Flags().StringVar(&daemArgs.Labels, "labels", os.Getenv("GITEA_RUNNER_LABELS"), "Runner labels, comma separated. Overrides the labels of an already registered runner")
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"os"
"path/filepath"
"strconv"
"testing"
"gitea.com/gitea/runner/internal/pkg/config"
@@ -38,7 +39,7 @@ func TestConfigCmdInitWritesTheMinimalConfig(t *testing.T) {
out, _, err := runConfigCmd(t, file, "init")
require.NoError(t, err)
assert.Contains(t, out, file)
assert.Equal(t, "wrote config file "+strconv.Quote(file)+"\n", out)
content, err := os.ReadFile(file)
require.NoError(t, err)
assert.Equal(t, config.Minimal, string(content))
+1 -1
View File
@@ -145,7 +145,7 @@ func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(
{"ephemeral stopped during declare outage", true, true, connect.NewError(connect.CodeUnavailable, "bad gateway"), nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
service := &daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel}
rpcServer := connect.NewServer()
+5 -2
View File
@@ -183,6 +183,9 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
func TestInitInputs(t *testing.T) {
t.Setenv(registerTokenEnvVar, "")
missingTokenFile := filepath.Join(t.TempDir(), "missing-token")
_, readErr := os.ReadFile(missingTokenFile)
require.ErrorIs(t, readErr, os.ErrNotExist)
for _, tc := range []struct {
name, tokenFile, wantErr string
env *string
@@ -190,8 +193,8 @@ func TestInitInputs(t *testing.T) {
{name: "missing token", wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{name: "empty token", env: new(""), wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{
name: "invalid token file", tokenFile: "/tmp/nonexistent", env: new("from-env"),
wantErr: "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory",
name: "invalid token file", tokenFile: missingTokenFile, env: new("from-env"),
wantErr: "cannot read the token file: " + missingTokenFile + ", " + readErr.Error(),
},
} {
t.Run(tc.name, func(t *testing.T) {
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package cmd
import "context"
func runService(ctx context.Context, run func(context.Context) error) error {
return run(ctx)
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"gitea.com/gitea/runner/act/common/logging"
"golang.org/x/sys/windows/svc"
)
func runService(ctx context.Context, run func(context.Context) error) error {
isService, err := svc.IsWindowsService()
if err != nil {
return fmt.Errorf("detect Windows service: %w", err)
}
if !isService {
return run(ctx)
}
handler := &daemonService{ctx: ctx, run: func(ctx context.Context) error {
executable, err := os.Executable()
if err != nil {
return err
}
if err := os.Chdir(filepath.Dir(executable)); err != nil {
return err
}
logFile, err := os.OpenFile("gitea-runner.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open service log: %w", err)
}
defer func() { _ = logFile.Close() }()
previousOutput := logging.Output()
logging.SetOutput(logFile)
defer logging.SetOutput(previousOutput)
err = run(ctx)
if errors.Is(err, context.Canceled) && ctx.Err() != nil {
return nil
}
if err != nil {
logging.WithError(err).Error("runner daemon failed")
}
return err
}}
if err := svc.Run("", handler); err != nil { // SCM supplies the name for an own-process service.
return fmt.Errorf("run Windows service: %w", err)
}
return handler.err
}
type daemonService struct {
ctx context.Context
run func(context.Context) error
err error
}
func (s *daemonService) Execute(_args []string, requests <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
return s.execute(requests, changes, ticker.C)
}
func (s *daemonService) execute(requests <-chan svc.ChangeRequest, changes chan<- svc.Status, ticks <-chan time.Time) (bool, uint32) {
changes <- svc.Status{State: svc.StartPending}
ctx, cancel := context.WithCancel(s.ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- s.run(ctx) }()
status := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPreShutdown}
changes <- status
for {
select {
case s.err = <-done:
if status.State != svc.StopPending {
changes <- svc.Status{State: svc.StopPending}
}
if s.err != nil {
return true, 1
}
return false, 0
case <-ticks:
if status.State == svc.StopPending {
status.CheckPoint++
changes <- status
}
case request := <-requests:
switch request.Cmd {
case svc.Interrogate:
changes <- status
case svc.Stop, svc.Shutdown, svc.PreShutdown:
if status.State != svc.StopPending {
status = svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 10000}
changes <- status
cancel()
}
}
}
}
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
type serviceExit struct {
specific bool
code uint32
}
func receiveServiceValue[T any](ctx context.Context, t *testing.T, values <-chan T) T {
t.Helper()
select {
case value := <-values:
return value
case <-ctx.Done():
t.Fatal(ctx.Err())
var zero T
return zero
}
}
func TestDaemonServiceControl(t *testing.T) {
for _, command := range []svc.Cmd{svc.Stop, svc.Shutdown, svc.PreShutdown} {
t.Run(fmt.Sprint(command), func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
release, finish := context.WithCancel(ctx)
defer finish()
cancelled := make(chan struct{})
requests := make(chan svc.ChangeRequest, 1)
ticks := make(chan time.Time)
changes := make(chan svc.Status, 8)
done := make(chan serviceExit, 1)
handler := &daemonService{ctx: ctx, run: func(ctx context.Context) error {
<-ctx.Done()
close(cancelled)
<-release.Done()
return nil
}}
go func() {
specific, code := handler.execute(requests, changes, ticks)
done <- serviceExit{specific, code}
}()
require.Equal(t, svc.Status{State: svc.StartPending}, receiveServiceValue(ctx, t, changes))
running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPreShutdown}
require.Equal(t, running, receiveServiceValue(ctx, t, changes))
requests <- svc.ChangeRequest{Cmd: svc.Interrogate}
require.Equal(t, running, receiveServiceValue(ctx, t, changes))
requests <- svc.ChangeRequest{Cmd: command}
stopping := svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 10000}
require.Equal(t, stopping, receiveServiceValue(ctx, t, changes))
receiveServiceValue(ctx, t, cancelled)
for range 2 {
select {
case ticks <- time.Time{}:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
stopping.CheckPoint++
require.Equal(t, stopping, receiveServiceValue(ctx, t, changes))
}
requests <- svc.ChangeRequest{Cmd: command}
requests <- svc.ChangeRequest{Cmd: svc.Interrogate}
require.Equal(t, stopping, receiveServiceValue(ctx, t, changes))
finish()
require.Equal(t, serviceExit{}, receiveServiceValue(ctx, t, done))
require.NoError(t, handler.err)
})
}
}
func TestDaemonServiceExit(t *testing.T) {
for _, daemonErr := range []error{nil, errors.New("daemon failed")} {
t.Run(fmt.Sprint(daemonErr), func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
changes := make(chan svc.Status, 3)
done := make(chan serviceExit, 1)
handler := &daemonService{ctx: ctx, run: func(context.Context) error { return daemonErr }}
go func() {
specific, code := handler.Execute(nil, nil, changes)
done <- serviceExit{specific, code}
}()
require.Equal(t, svc.StartPending, receiveServiceValue(ctx, t, changes).State)
require.Equal(t, svc.Running, receiveServiceValue(ctx, t, changes).State)
require.Equal(t, svc.Status{State: svc.StopPending}, receiveServiceValue(ctx, t, changes))
if daemonErr == nil {
require.Equal(t, serviceExit{}, receiveServiceValue(ctx, t, done))
} else {
require.Equal(t, serviceExit{true, 1}, receiveServiceValue(ctx, t, done))
}
require.Equal(t, daemonErr, handler.err)
})
}
}
func waitForServiceState(t *testing.T, service *mgr.Service, state svc.State) svc.Status {
t.Helper()
ctx, cancel := context.WithTimeout(context.WithoutCancel(t.Context()), 20*time.Second)
defer cancel()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
status, err := service.Query()
require.NoError(t, err)
if status.State == state {
return status
}
if state == svc.Running && status.State == svc.Stopped {
t.Fatalf("service stopped before running: %+v", status)
}
select {
case <-ticker.C:
case <-ctx.Done():
t.Fatalf("waiting for service state %d, last status: %+v", state, status)
}
}
}
func TestWindowsService(t *testing.T) {
isService, err := svc.IsWindowsService()
require.NoError(t, err)
if isService {
require.NoError(t, runService(t.Context(), func(ctx context.Context) error {
initLogging(&config.Config{})
logging.Info("service started")
<-ctx.Done()
logging.Info("service stopped")
return nil
}))
return
}
if !windows.GetCurrentProcessToken().IsElevated() {
t.Skip("Windows service test requires elevation")
}
executable, err := os.Executable()
require.NoError(t, err)
binary, err := os.ReadFile(executable)
require.NoError(t, err)
directory := t.TempDir()
serviceExecutable := filepath.Join(directory, "runner service.test.exe")
require.NoError(t, os.WriteFile(serviceExecutable, binary, 0o600))
manager, err := mgr.Connect()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, manager.Disconnect()) })
service, err := manager.CreateService(
fmt.Sprintf("gitea-runner-test-%d-%d", os.Getpid(), time.Now().UnixNano()),
serviceExecutable, mgr.Config{StartType: mgr.StartManual}, "-test.run=^TestWindowsService$", "-test.timeout=1m",
)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, service.Close()) })
t.Cleanup(func() { require.NoError(t, service.Delete()) })
var process windows.Handle
t.Cleanup(func() {
if process != 0 {
require.NoError(t, windows.CloseHandle(process))
}
})
t.Cleanup(func() {
if process == 0 {
return
}
result, err := windows.WaitForSingleObject(process, 20000)
require.NoError(t, err)
if result == uint32(windows.WAIT_TIMEOUT) {
t.Error("service process did not exit after stopping")
require.NoError(t, windows.TerminateProcess(process, 1))
result, err = windows.WaitForSingleObject(process, 20000)
require.NoError(t, err)
}
require.Equal(t, uint32(windows.WAIT_OBJECT_0), result)
})
t.Cleanup(func() {
status, err := service.Query()
require.NoError(t, err)
if status.State != svc.Stopped {
_, err := service.Control(svc.Stop)
require.NoError(t, err)
waitForServiceState(t, service, svc.Stopped)
}
})
require.NoError(t, service.Start())
running := waitForServiceState(t, service, svc.Running)
process, err = windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, running.ProcessId)
require.NoError(t, err)
_, err = service.Control(svc.Stop)
require.NoError(t, err)
stopped := waitForServiceState(t, service, svc.Stopped)
require.Zero(t, stopped.Win32ExitCode)
require.Zero(t, stopped.ServiceSpecificExitCode)
log, err := os.ReadFile(filepath.Join(directory, "gitea-runner.log"))
require.NoError(t, err)
require.Contains(t, string(log), "service started")
require.Contains(t, string(log), "service stopped")
}
+1 -2
View File
@@ -59,9 +59,8 @@ func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) {
func executeHealthCheck(ctx context.Context, script string, timeout time.Duration, env map[string]string) error {
checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(checkCtx, script)
cmd := scriptCommand(checkCtx, script)
cmd.Env = envListFromMap(env)
cmd.SysProcAttr = process.SysProcAttr(script, false)
writer := common.NewLineWriter(func(line string) bool {
line = strings.TrimRight(line, "\r\n")
if line != "" {
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestExecuteHealthCheckExeAndBatchPathsWithSpace(t *testing.T) {
whoami, err := os.ReadFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "whoami.exe"))
require.NoError(t, err)
dir := filepath.Join(t.TempDir(), "health check")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "probe.exe"), whoami, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "probe.cmd"), []byte("@exit /b 0\r\n"), 0o755))
for _, name := range []string{"probe.exe", "probe.cmd"} {
require.NoError(t, executeHealthCheck(t.Context(), filepath.Join(dir, name), time.Minute, nil))
}
}
+14 -2
View File
@@ -8,6 +8,8 @@ import (
"errors"
"io"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -39,9 +41,8 @@ func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporte
env := r.postTaskScriptEnv(reporter, task, workdir)
logging.Infof("running post-task script %q for task %d", script, task.Id)
cmd := exec.CommandContext(scriptCtx, script)
cmd := scriptCommand(scriptCtx, script)
cmd.Env = envListFromMap(env)
cmd.SysProcAttr = process.SysProcAttr(script, false)
stdout := postTaskScriptLogWriter("stdout")
stderr := postTaskScriptLogWriter("stderr")
@@ -75,6 +76,17 @@ func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporte
}
}
func scriptCommand(ctx context.Context, script string) *exec.Cmd { // Windows batch files need cmd.exe, CreateProcess cannot start them
if ext := strings.ToLower(filepath.Ext(script)); runtime.GOOS == "windows" && (ext == ".bat" || ext == ".cmd") {
cmd := exec.CommandContext(ctx, "cmd.exe")
cmd.SysProcAttr = process.SysProcAttr(`cmd.exe /d /s /c ""`+script+`""`, false)
return cmd
}
cmd := exec.CommandContext(ctx, script)
cmd.SysProcAttr = process.SysProcAttr("", false)
return cmd
}
func postTaskScriptContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
// Detach from the task context's deadline and cancellation: the task has
// already finished by the time the post-task script runs, so the script must
+17 -2
View File
@@ -7,10 +7,12 @@ import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/report"
@@ -39,9 +41,15 @@ func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
}
func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
hook := logtest.SetDefault(t)
dir := t.TempDir()
scriptPath := filepath.Join(dir, "fail.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 2\n"), 0o700))
script := "#!/bin/sh\nexit 2\n"
if runtime.GOOS == "windows" {
scriptPath = filepath.Join(dir, "fail.cmd")
script = "@exit /b 2\r\n"
}
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o700))
cfg, err := config.LoadDefault("")
require.NoError(t, err)
@@ -59,6 +67,8 @@ func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
require.NotPanics(t, func() {
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
})
require.NotNil(t, hook.LastRecord())
assert.Contains(t, hook.LastRecord().Message, "exited with code 2")
}
func TestPostTaskScriptContextUsesFullTimeout(t *testing.T) {
@@ -113,10 +123,15 @@ func TestPostTaskScriptEnv(t *testing.T) {
}
func TestRunPostTaskScriptIntegration(t *testing.T) {
dir := t.TempDir()
dir := filepath.Join(t.TempDir(), "script directory")
require.NoError(t, os.Mkdir(dir, 0o700))
outFile := filepath.Join(dir, "out.txt")
scriptPath := filepath.Join(dir, "post-task.sh")
script := "#!/bin/sh\nprintf '%s %s %s' \"$GITEA_TASK_ID\" \"$GITEA_JOB_RESULT\" \"$CUSTOM\" > \"" + outFile + "\"\n"
if runtime.GOOS == "windows" {
scriptPath = filepath.Join(dir, "post-task.cmd")
script = "@echo off\r\n<nul set /p \"=%GITEA_TASK_ID% %GITEA_JOB_RESULT% %CUSTOM%\" > \"" + outFile + "\"\r\nexit /b 0\r\n"
}
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o700))
cfg, err := config.LoadDefault("")
+14 -9
View File
@@ -168,7 +168,7 @@ func (r *Runner) OnIdle(ctx context.Context) {
}
// Bind-workdir mode: reclaim stale per-task workspace dirs (numeric task IDs).
if r.cfg.Container.BindWorkdir {
r.cleanupStaleDirs(ctx, filepath.FromSlash("/"+strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")), isTaskIDDir)
r.cleanupStaleDirs(ctx, containerWorkdirRoot(r.cfg.Container.WorkdirParent), isTaskIDDir)
}
// Host mode: reclaim per-job scratch dirs left behind when HostEnvironment
// cleanup timed out (e.g. a delete stalled by an AV/EDR filter driver). They
@@ -557,12 +557,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
actionCloneDepth = 0
}
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
workdirParent := containerWorkdirRoot(r.cfg.Container.WorkdirParent)
if r.cfg.Container.BindWorkdir {
// Append the task ID to isolate concurrent jobs from the same repo.
workdirParent = fmt.Sprintf("%s/%d", workdirParent, task.Id)
workdirParent = filepath.Join(workdirParent, strconv.FormatInt(task.Id, 10))
}
workdir := filepath.FromSlash(fmt.Sprintf("/%s/%s", workdirParent, preset.Repository))
workdir := filepath.Join(workdirParent, filepath.FromSlash(preset.Repository))
if runtime.GOOS == "windows" {
if abs, err := filepath.Abs(workdir); err == nil {
workdir = abs
@@ -648,10 +648,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
reporter.SetOutputs(job.Outputs)
if r.cfg.Container.BindWorkdir {
// Remove the entire task-specific directory (e.g. /workspace/<task_id>).
taskDir := filepath.FromSlash("/" + workdirParent)
if err := os.RemoveAll(taskDir); err != nil {
logging.Warnf("failed to clean up workspace %s: %v", taskDir, err)
if err := os.RemoveAll(workdirParent); err != nil {
logging.Warnf("failed to clean up workspace %s: %v", workdirParent, err)
}
}
@@ -821,7 +819,7 @@ func (r *Runner) setHealthStatus(ready bool, reason string) {
func checkFreeDisk(cfg *config.Config) (bool, string) {
root := cfg.Host.WorkdirParent
if cfg.Container.BindWorkdir {
root = filepath.FromSlash("/" + strings.TrimLeft(cfg.Container.WorkdirParent, "/"))
root = containerWorkdirRoot(cfg.Container.WorkdirParent)
}
root = nearestExistingPath(root)
available, err := disk.FreeBytes(root)
@@ -835,6 +833,13 @@ func checkFreeDisk(cfg *config.Config) (bool, string) {
return true, "ok"
}
func containerWorkdirRoot(parent string) string {
if filepath.IsAbs(parent) {
return filepath.Clean(parent)
}
return filepath.Join(string(filepath.Separator), parent)
}
func nearestExistingPath(path string) string {
for path != "" {
if _, err := os.Stat(path); err == nil {
+14 -4
View File
@@ -241,9 +241,6 @@ func TestRunnerShouldRunIdleCleanupSkipsWhenDisabled(t *testing.T) {
})
}
// TestRunnerOnIdleIntegratesCleanup wires the full OnIdle entry point and
// confirms it walks workdir_parent (after the leading-slash trim that
// matches the production path construction) and removes stale numeric dirs.
func TestRunnerOnIdleIntegratesCleanup(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
root := t.TempDir()
@@ -255,7 +252,7 @@ func TestRunnerOnIdleIntegratesCleanup(t *testing.T) {
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
WorkdirParent: root, // leading slash absent, OnIdle reattaches it
WorkdirParent: root,
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
@@ -371,3 +368,16 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
assert.Equal(t, []string{"a1b2c3d4e5f6", ""}, detached)
assert.Equal(t, []string{"runner-1"}, sweptPods)
}
func TestContainerWorkdirRoot(t *testing.T) {
absolute := t.TempDir()
rooted := filepath.Join(string(filepath.Separator), "workspace")
for input, want := range map[string]string{
"workspace": rooted,
"/workspace/": rooted,
absolute: absolute,
absolute + "/nested": filepath.Join(absolute, "nested"),
} {
assert.Equal(t, want, containerWorkdirRoot(input), input)
}
}
+25 -14
View File
@@ -42,7 +42,10 @@ func Main(ctx context.Context, c *action.Context) (err error) {
return fmt.Errorf("checkout: unsupported input %q", name)
}
}
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"])
runnerContext := c.Container.GetRunnerContext(ctx)
executionOS, _ := runnerContext["os"].(string)
windows := strings.EqualFold(executionOS, "windows")
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"], windows)
if err != nil {
return err
}
@@ -63,8 +66,8 @@ func Main(ctx context.Context, c *action.Context) (err error) {
return err
}
}
if output := c.Env["GITHUB_OUTPUT"]; output != "" {
return c.Container.Copy(path.Dir(output), &container.FileEntry{Name: path.Base(output), Mode: 0o666, Body: fmt.Sprintf("ref=%s\ncommit=%s\n", ghc.Ref, ghc.Sha)})(ctx)
if output := filepath.ToSlash(c.Env["GITHUB_OUTPUT"]); output != "" {
return c.Container.Copy(container.JoinPath(output, ".."), &container.FileEntry{Name: path.Base(output), Mode: 0o666, Body: fmt.Sprintf("ref=%s\ncommit=%s\n", ghc.Ref, ghc.Sha)})(ctx)
}
return nil
}
@@ -72,6 +75,7 @@ func Main(ctx context.Context, c *action.Context) (err error) {
co := &checkout{
url: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository,
dst: dst,
windows: windows,
ref: ref,
depth: depth,
fetchTags: boolInput(c.Inputs, "fetch-tags", false),
@@ -114,10 +118,11 @@ func Main(ctx context.Context, c *action.Context) (err error) {
}
// named like actions/checkout's, which create-pull-request knows to hide, and gone with the job
runnerTemp, _ := c.Container.GetRunnerContext(ctx)["temp"].(string)
runnerTemp, _ := runnerContext["temp"].(string)
runnerTemp = filepath.ToSlash(runnerTemp)
digest := sha256.Sum256([]byte(dst))
credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8])
co.credentials = path.Join(runnerTemp, credentials)
co.credentials = container.JoinPath(runnerTemp, credentials)
persist := boolInput(c.Inputs, "persist-credentials", true)
config, remoteConfig := serverConfig(ghc.ServerURL, cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS, co.submodules != "" && sshKey == "")
if boolInput(c.Inputs, "set-safe-directory", true) {
@@ -140,22 +145,21 @@ func Main(ctx context.Context, c *action.Context) (err error) {
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil {
return err
}
scope := "http." + strings.TrimSuffix(ghc.ServerURL, "/") + "/."
remoteConfig = append(remoteConfig, scope+"sslCert="+path.Join(runnerTemp, cert), scope+"sslKey="+path.Join(runnerTemp, key))
remoteConfig = append(remoteConfig, git.ClientCertConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), executionOS)...)
}
env := gitEnv(c, remoteConfig)
env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go
if sshKey != "" {
key := fmt.Sprintf("git-ssh-key-%x", digest[:8])
files := []*container.FileEntry{{Name: key, Mode: 0o600, Body: sshKey + "\n"}}
ssh := []string{"ssh", "-i", path.Join(runnerTemp, key)}
ssh := []string{"ssh", "-i", container.JoinPath(runnerTemp, key)}
if boolInput(c.Inputs, "ssh-strict", true) {
ssh = append(ssh, "-o", "StrictHostKeyChecking=yes", "-o", "CheckHostIP=no")
}
if knownHosts := strings.TrimSpace(c.Inputs["ssh-known-hosts"]); knownHosts != "" {
name := fmt.Sprintf("git-ssh-known-hosts-%x", digest[:8])
files = append(files, &container.FileEntry{Name: name, Mode: 0o600, Body: knownHosts + "\n"})
ssh = append(ssh, "-o", "UserKnownHostsFile=~/.ssh/known_hosts "+strconv.Quote(path.Join(runnerTemp, name)))
ssh = append(ssh, "-o", "UserKnownHostsFile=~/.ssh/known_hosts "+strconv.Quote(container.JoinPath(runnerTemp, name)))
}
if !persist {
defer func() {
@@ -218,7 +222,7 @@ func gitEnv(c *action.Context, config []string) map[string]string {
env := map[string]string{"GIT_TERMINAL_PROMPT": "0"}
maps.Copy(env, c.Env)
maps.DeleteFunc(env, func(name, _ string) bool { return slices.Contains(git.LocalEnvVars, name) })
maps.Copy(env, git.ConfigEnv(append([]string{"protocol.version=2", "init.defaultBranch=main", "advice.detachedHead=false", "maintenance.auto=false"}, config...)...))
maps.Copy(env, git.ConfigEnv(append([]string{"protocol.version=2", "init.defaultBranch=main", "advice.detachedHead=false", "maintenance.auto=false", "core.longpaths=true"}, config...)...))
return env
}
@@ -301,7 +305,7 @@ type checkout struct {
filter, submodules string
sparse []string
depth int
fetchTags, clean, lfs, progress, cone bool
fetchTags, clean, lfs, progress, cone, windows bool
}
const fullDepth = "2147483647" // git's INFINITE_DEPTH, also deepens a shallow dst
@@ -315,6 +319,9 @@ func (co *checkout) commands() (commands [][]string, fetched int) {
repo("config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"),
repo("config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials),
}
if co.windows {
commands = append(commands, repo("config", "core.longpaths", "true")) // later steps run git on the checkout too
}
if co.lfs {
commands = append(commands, repo("lfs", "install", "--local"))
}
@@ -451,9 +458,13 @@ func boolInput(inputs map[string]string, name string, fallback bool) bool {
return strings.EqualFold(value, "true") || value == "" && fallback
}
func workspaceDestination(workspace, checkoutPath string) (string, error) {
cleanPath := path.Clean(strings.TrimSpace(checkoutPath))
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") {
func workspaceDestination(workspace, checkoutPath string, windows bool) (string, error) {
cleanPath := strings.TrimSpace(checkoutPath)
if windows {
cleanPath = strings.ReplaceAll(cleanPath, `\`, "/")
}
cleanPath = path.Clean(cleanPath)
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || windows && len(cleanPath) > 1 && cleanPath[1] == ':' {
return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath)
}
return path.Join(workspace, cleanPath), nil
+31 -3
View File
@@ -13,9 +13,11 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -50,6 +52,7 @@ func gitLog(dir string) []string {
func TestCheckout(t *testing.T) {
server := t.TempDir()
serverURL := (&url.URL{Scheme: "file", Path: "/" + strings.TrimPrefix(filepath.ToSlash(server), "/")}).String()
var sha string
_, lfsErr := exec.LookPath("git-lfs")
require.True(t, t.Run("fixtures", func(t *testing.T) {
@@ -192,13 +195,16 @@ exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git up
if tc.inputs["lfs"] == "true" && lfsErr != nil {
t.Skip("needs git-lfs")
}
if tc.inputs["ssh-key"] != "" && runtime.GOOS == "windows" {
t.Skip("Git for Windows puts its own ssh ahead of the fake one on PATH")
}
workspace, runnerTemp := t.TempDir(), t.TempDir()
output := filepath.Join(runnerTemp, "output")
checkout := func(inputs map[string]string) error {
return Main(t.Context(), &action.Context{
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
Github: &model.GithubContext{
Repository: "org/repo", Ref: cmp.Or(tc.eventRef, "refs/heads/main"), Sha: sha, ServerURL: "file://" + server, Token: "tok", Event: tc.event,
Repository: "org/repo", Ref: cmp.Or(tc.eventRef, "refs/heads/main"), Sha: sha, ServerURL: serverURL, Token: "tok", Event: tc.event,
},
Inputs: inputs,
HostWorkdir: host,
@@ -207,7 +213,7 @@ exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git up
NoSkipCheckout: tc.noSkip,
Env: map[string]string{
"PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1",
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": server, "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
},
})
}
@@ -270,7 +276,7 @@ exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git up
for _, repo := range repos {
assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo)
assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo)
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http.file://"+server+"/.extraheader").Output()
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
}
if tc.inputs["ssh-key"] != "" {
@@ -354,3 +360,25 @@ func fileExists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
func TestWorkspaceDestination(t *testing.T) {
for _, tc := range []struct {
path string
windows bool
want string
}{
{`..\x`, true, ""},
{`C:x`, true, ""},
{`a\b`, true, "/w/a/b"},
{`..\x`, false, `/w/..\x`},
{`a:b`, false, "/w/a:b"},
} {
got, err := workspaceDestination("/w", tc.path, tc.windows)
if tc.want == "" {
assert.ErrorContains(t, err, "must be relative", tc.path)
} else {
require.NoError(t, err, tc.path)
assert.Equal(t, tc.want, got, tc.path)
}
}
}
+2 -2
View File
@@ -327,8 +327,8 @@ health_check:
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
#min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
# Optional additional executable, or a .bat or .cmd file on Windows. A non-zero
# exit, timeout, or startup failure marks the runner unavailable.
#script: ''
# How long a script result is cached and its maximum execution time.
#interval: 30s
+6 -6
View File
@@ -109,7 +109,7 @@ cache:
endpoint: "http://minio:9000"
bucket: "caches"
prefix: "/runners/"
access_key_file: "` + accessPath + `"
access_key_file: '` + accessPath + `'
secret_key: "secret"
`
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
@@ -341,20 +341,20 @@ func TestLoadDefault_ExternalCacheSecret(t *testing.T) {
{name: "without secret", yaml: externalCache, wantErr: "external_secret"},
{name: "inline secret", yaml: externalCache + " external_secret: \"shh\"\n"},
{
name: "from file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n",
name: "from file", yaml: externalCache + " external_secret_file: '$DIR/cache.secret'\n",
fileContent: " s3cr3t\n", wantSecret: "s3cr3t",
},
{
name: "from file when disabled", yaml: "\ncache:\n enabled: false\n external_secret_file: \"$DIR/cache.secret\"\n",
name: "from file when disabled", yaml: "\ncache:\n enabled: false\n external_secret_file: '$DIR/cache.secret'\n",
fileContent: "s3cr3t", wantSecret: "s3cr3t",
},
{
name: "inline and file conflict", yaml: externalCache + " external_secret: \"inline\"\n external_secret_file: \"$DIR/cache.secret\"\n",
name: "inline and file conflict", yaml: externalCache + " external_secret: \"inline\"\n external_secret_file: '$DIR/cache.secret'\n",
fileContent: "s3cr3t", wantErr: "both set",
},
{name: "missing file", yaml: externalCache + " external_secret_file: \"$DIR/absent.secret\"\n", wantErr: "read cache.external_secret_file"},
{name: "missing file", yaml: externalCache + " external_secret_file: '$DIR/absent.secret'\n", wantErr: "read cache.external_secret_file"},
{
name: "empty file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n",
name: "empty file", yaml: externalCache + " external_secret_file: '$DIR/cache.secret'\n",
fileContent: "\n \n", wantErr: "contains no secret",
},
} {
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package process
func UTF8Console() {}
+11
View File
@@ -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)
}
+1 -3
View File
@@ -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 -48
View File
@@ -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")
+7 -12
View File
@@ -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())
+1 -3
View File
@@ -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
}
+147 -59
View File
@@ -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, &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 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) {
+53 -37
View File
@@ -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)
}
+4 -3
View File
@@ -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
+86 -3
View File
@@ -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)
}
})
}
}