mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 03:43:42 +00:00
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:
1 parent
142d9f8c88
commit
dcba546cb3
32 files changed
+374
-593
No files matched your search
@@ -65,10 +65,13 @@ jobs:
|
|||||||
- uses: ./.gitea/actions/go-setup
|
- uses: ./.gitea/actions/go-setup
|
||||||
- name: prepare anonymous docker config
|
- name: prepare anonymous docker config
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||||
|
- name: windows vm cache key
|
||||||
|
id: windows-vm
|
||||||
|
run: echo "key=windows-vm-$(grep '^WINDOWS_TEST_IMAGE' Makefile | cat - scripts/test-windows.sh scripts/test-windows-setup.ps1 | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"
|
||||||
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/windows-vm
|
path: ~/.cache/windows-vm
|
||||||
key: windows-vm
|
key: ${{ steps.windows-vm.outputs.key }}
|
||||||
- name: test on windows
|
- name: test on windows
|
||||||
id: test-windows
|
id: test-windows
|
||||||
run: WINDOWS_TEST_CACHE="$HOME/.cache/windows-vm" make test-windows
|
run: WINDOWS_TEST_CACHE="$HOME/.cache/windows-vm" make test-windows
|
||||||
@@ -78,7 +81,7 @@ jobs:
|
|||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/windows-vm
|
path: ~/.cache/windows-vm
|
||||||
key: windows-vm
|
key: ${{ steps.windows-vm.outputs.key }}
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: gitea compatibility (${{ matrix.gitea_image }})
|
name: gitea compatibility (${{ matrix.gitea_image }})
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ linters:
|
|||||||
deny:
|
deny:
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
|
- pkg: unsafe
|
||||||
|
desc: use typed APIs instead
|
||||||
logging:
|
logging:
|
||||||
files:
|
files:
|
||||||
- $all
|
- $all
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
|||||||
|
|
||||||
[gitea/runner-images](https://gitea.com/gitea/runner-images) has suitable images. Jobs matching no label run in `runner.default_image`, or on the host when docker is unavailable.
|
[gitea/runner-images](https://gitea.com/gitea/runner-images) has suitable images. Jobs matching no label run in `runner.default_image`, or on the host when docker is unavailable.
|
||||||
|
|
||||||
Host jobs still need a Docker daemon for Docker actions and `docker://` steps. Windows hosts do not support those, as on GitHub. On Windows, a step's background processes end with the job unless it opts out, see [host mode](docs/host-mode.md).
|
Host jobs still need a Docker daemon for Docker actions and `docker://` steps. Windows hosts do not support those, as on GitHub. A step's background processes end with the job unless it opts out, see [host mode](docs/host-mode.md).
|
||||||
|
|
||||||
#### Registration vs config labels
|
#### Registration vs config labels
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/logging"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
@@ -158,6 +160,28 @@ func (e Executor) IfBool(conditional bool) Executor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryBackoff is the wait between Retry's attempts, random between 1 and 10 seconds like GitHub's runner.
|
||||||
|
var RetryBackoff = func() time.Duration { return time.Second + rand.N(9*time.Second) }
|
||||||
|
|
||||||
|
// Retry runs the executor up to attempts times until it succeeds.
|
||||||
|
func (e Executor) Retry(attempts int) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
err := e(ctx)
|
||||||
|
if err == nil || attempt == attempts || ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
backoff := RetryBackoff()
|
||||||
|
Logger(ctx).Warnf("%v, retrying in %s", err, backoff.Round(time.Second))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Finally adds an executor to run after other executor
|
// Finally adds an executor to run after other executor
|
||||||
func (e Executor) Finally(finally Executor) Executor {
|
func (e Executor) Finally(finally Executor) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -134,6 +135,31 @@ func TestExecutorConditionalsAndFinally(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
|
assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutorRetryStopsOnSuccessOrAfterAllAttempts(t *testing.T) {
|
||||||
|
backoff := RetryBackoff
|
||||||
|
RetryBackoff = func() time.Duration { return 0 }
|
||||||
|
t.Cleanup(func() { RetryBackoff = backoff })
|
||||||
|
transient := errors.New("transient")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
failures, calls int
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{failures: 2, calls: 3},
|
||||||
|
{failures: 5, calls: 3, err: transient},
|
||||||
|
} {
|
||||||
|
var calls int
|
||||||
|
err := Executor(func(context.Context) error {
|
||||||
|
calls++
|
||||||
|
if calls <= tc.failures {
|
||||||
|
return transient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).Retry(3)(t.Context())
|
||||||
|
require.Equal(t, tc.err, err)
|
||||||
|
require.Equal(t, tc.calls, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||||
finalErr := errors.New("cleanup failed")
|
finalErr := errors.New("cleanup failed")
|
||||||
for _, mainErr := range []error{context.Canceled, nil} {
|
for _, mainErr := range []error{context.Canceled, nil} {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
|
|
||||||
// NewDockerPullExecutor function to create a run executor for the container
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return common.Executor(func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("docker pull %v", input.Image)
|
logger.Debugf("docker pull %v", input.Image)
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||||
}
|
}).Retry(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
|||||||
e.procGroupOnce.Do(func() {
|
e.procGroupOnce.Do(func() {
|
||||||
group, err := process.NewGroup()
|
group, err := process.NewGroup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
common.Logger(ctx).Warnf("could not create the job's process group, processes a step leaves behind may outlive the job: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.procGroup.Store(group)
|
e.procGroup.Store(group)
|
||||||
@@ -415,46 +415,6 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
|
||||||
// server cancel has already cancelled the parent ctx.
|
|
||||||
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer killCancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
|
||||||
// across jobs, and Workdir may be a caller-owned checkout.
|
|
||||||
owned := []string{e.Path, e.TmpDir}
|
|
||||||
if e.CleanWorkdir {
|
|
||||||
owned = append(owned, e.Workdir)
|
|
||||||
}
|
|
||||||
dirs := make([]string, 0, len(owned))
|
|
||||||
for _, d := range owned {
|
|
||||||
if d == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
abs, err := filepath.Abs(d)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirs = append(dirs, abs)
|
|
||||||
}
|
|
||||||
if len(dirs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if killed, err := process.KillProcessesWithPaths(killCtx, dirs, e.TrackingID); err != nil {
|
|
||||||
logger.Debugf("workspace process cleanup reported errors: %v", err)
|
|
||||||
} else if killed > 0 {
|
|
||||||
logger.Debugf("terminated %d leftover workspace process(es)", killed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||||
// wedge the runner slot. A var so tests can shrink it.
|
// wedge the runner slot. A var so tests can shrink it.
|
||||||
var hostCleanupTimeout = 30 * time.Second
|
var hostCleanupTimeout = 30 * time.Second
|
||||||
@@ -463,12 +423,15 @@ func (e *HostEnvironment) Remove() common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// End lingering processes before removing the workspace; on Windows their
|
// End lingering processes before removing the workspace, on Windows their file locks block cleanup.
|
||||||
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
|
||||||
if err := e.procGroup.Load().Close(); err != nil {
|
if err := e.procGroup.Load().Close(); err != nil {
|
||||||
logger.Debugf("closing the job's process group failed: %v", err)
|
logger.Debugf("closing the job's process group failed: %v", err)
|
||||||
}
|
}
|
||||||
e.terminateRunningProcesses(ctx)
|
if killed, err := process.KillTrackedProcesses(e.TrackingID); err != nil {
|
||||||
|
logger.Debugf("killing the job's leftover processes reported errors: %v", err)
|
||||||
|
} else if killed > 0 {
|
||||||
|
logger.Debugf("killed %d leftover process(es) of the job", killed)
|
||||||
|
}
|
||||||
|
|
||||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||||
// which absorb Windows file-handle release lag after the kill above.
|
// which absorb Windows file-handle release lag after the kill above.
|
||||||
|
|||||||
@@ -4,18 +4,15 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
@@ -40,7 +37,6 @@ func TestHostEnvironmentTrackingCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
env["GITEA_TRACKING_HELPER"] = "step"
|
env["GITEA_TRACKING_HELPER"] = "step"
|
||||||
env["GITEA_TRACKING_WORKSPACE"] = workspace
|
|
||||||
env["GITEA_TRACKING_ADDRESS"] = listener.Addr().String()
|
env["GITEA_TRACKING_ADDRESS"] = listener.Addr().String()
|
||||||
if tracking == "default" {
|
if tracking == "default" {
|
||||||
env["RUNNER_TRACKING_ID"] = environment.TrackingID
|
env["RUNNER_TRACKING_ID"] = environment.TrackingID
|
||||||
@@ -52,23 +48,6 @@ func TestHostEnvironmentTrackingCleanup(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
result <- environment.Exec([]string{os.Args[0], "-test.run=^TestHostTrackingHelper$"}, env, "", workspace)(ctx)
|
result <- environment.Exec([]string{os.Args[0], "-test.run=^TestHostTrackingHelper$"}, env, "", workspace)(ctx)
|
||||||
}()
|
}()
|
||||||
step, err := listener.Accept()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer step.Close()
|
|
||||||
require.NoError(t, step.SetDeadline(time.Now().Add(20*time.Second)))
|
|
||||||
var stepPID int
|
|
||||||
_, err = fmt.Fscanln(step, &stepPID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
stepHandle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(stepPID))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = windows.CloseHandle(stepHandle) }()
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var assigned int32
|
|
||||||
result, _, _ := windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob").Call(uintptr(stepHandle), 0, uintptr(unsafe.Pointer(&assigned)))
|
|
||||||
return result != 0 && assigned != 0
|
|
||||||
}, 5*time.Second, 10*time.Millisecond)
|
|
||||||
_, err = step.Write([]byte{1})
|
|
||||||
require.NoError(t, err)
|
|
||||||
var helpers []windows.Handle
|
var helpers []windows.Handle
|
||||||
for range 2 {
|
for range 2 {
|
||||||
connection, err := listener.Accept()
|
connection, err := listener.Accept()
|
||||||
@@ -114,23 +93,18 @@ func TestHostTrackingHelper(t *testing.T) {
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if mode != "descendant" {
|
||||||
|
child := exec.Command(os.Args[0], "-test.run=^TestHostTrackingHelper$")
|
||||||
|
child.Env = append(os.Environ(), "GITEA_TRACKING_HELPER="+map[string]string{"step": "helper", "helper": "descendant"}[mode])
|
||||||
|
require.NoError(t, child.Start())
|
||||||
|
if mode == "step" {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
connection, err := net.Dial("tcp4", os.Getenv("GITEA_TRACKING_ADDRESS"))
|
connection, err := net.Dial("tcp4", os.Getenv("GITEA_TRACKING_ADDRESS"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = fmt.Fprintln(connection, os.Getpid())
|
_, err = fmt.Fprintln(connection, os.Getpid())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
if mode == "step" {
|
|
||||||
_, err := bufio.NewReader(connection).ReadByte()
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
if mode != "descendant" {
|
|
||||||
child := exec.Command(os.Args[0], "-test.run=^TestHostTrackingHelper$", filepath.Join(os.Getenv("GITEA_TRACKING_WORKSPACE"), "tracking-reference"))
|
|
||||||
child.Env = append(os.Environ(), "GITEA_TRACKING_HELPER="+map[string]string{"step": "helper", "helper": "descendant"}[mode])
|
|
||||||
require.NoError(t, child.Start())
|
|
||||||
if mode == "step" {
|
|
||||||
require.NoError(t, connection.Close())
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = io.Copy(io.Discard, connection)
|
_, err = io.Copy(io.Discard, connection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, connection.Close())
|
require.NoError(t, connection.Close())
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ func lookPath(file string, env map[string]string) (string, error) {
|
|||||||
return "", &Error{file, err}
|
return "", &Error{file, err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
path := getenv(env, "path")
|
path := getenv(env, "path")
|
||||||
for _, dir := range filepath.SplitList(path) {
|
for _, dir := range filepath.SplitList(path) {
|
||||||
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
|
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package lookpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLookPath2IgnoresTheCurrentDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "tool.exe"), nil, 0o755))
|
||||||
|
t.Chdir(dir)
|
||||||
|
_, err := LookPath2("tool", map[string]string{"PATH": t.TempDir()})
|
||||||
|
require.EqualError(t, err, ErrNotFound.Error())
|
||||||
|
}
|
||||||
+18
-22
@@ -339,34 +339,30 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
|
|
||||||
if !correctArchExists || rc.Config.ForceRebuild {
|
if !correctArchExists || rc.Config.ForceRebuild {
|
||||||
logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir)
|
logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir)
|
||||||
var buildContext io.ReadCloser
|
prepImage = common.Executor(func(ctx context.Context) error {
|
||||||
if localAction {
|
input := container.NewDockerBuildExecutorInput{
|
||||||
buildContext, err = rc.JobContainer.GetContainerArchive(ctx, contextDir+"/.")
|
ContextDir: contextDir,
|
||||||
if err != nil {
|
Dockerfile: fileName,
|
||||||
return err
|
ImageTag: image,
|
||||||
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
|
BuildArgs: rc.proxyBuildArgs(),
|
||||||
}
|
}
|
||||||
defer buildContext.Close()
|
if localAction {
|
||||||
}
|
buildContext, err := rc.JobContainer.GetContainerArchive(ctx, contextDir+"/.")
|
||||||
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
if err != nil {
|
||||||
ContextDir: contextDir,
|
return err
|
||||||
Dockerfile: fileName,
|
}
|
||||||
ImageTag: image,
|
defer buildContext.Close()
|
||||||
BuildContext: buildContext,
|
input.BuildContext = buildContext
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
} else {
|
||||||
BuildArgs: rc.proxyBuildArgs(),
|
unlock, err := git.AcquireCloneLock(ctx, actionDir) // held across the whole build, the daemon drains contextDir lazily
|
||||||
})
|
|
||||||
if buildContext == nil {
|
|
||||||
// Held across the whole build: the daemon drains contextDir lazily.
|
|
||||||
inner := prepImage
|
|
||||||
prepImage = func(ctx context.Context) error {
|
|
||||||
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer unlock()
|
defer unlock()
|
||||||
return inner(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
return ContainerNewDockerBuildExecutor(input)(ctx)
|
||||||
|
}).Retry(3)
|
||||||
} else {
|
} else {
|
||||||
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type stepFactoryImpl struct{}
|
|||||||
func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) {
|
func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) {
|
||||||
switch stepModel.Type() {
|
switch stepModel.Type() {
|
||||||
case model.StepTypeInvalid:
|
case model.StepTypeInvalid:
|
||||||
return nil, fmt.Errorf("invalid run/uses syntax for job:%s step:%+v", rc.Run, stepModel)
|
return nil, fmt.Errorf("invalid run/uses syntax for job:%s step:%+v", rc.Name, stepModel)
|
||||||
case model.StepTypeRun:
|
case model.StepTypeRun:
|
||||||
return &stepRun{
|
return &stepRun{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
@@ -40,7 +40,7 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
run, ok := builtinActions[name]
|
run, ok := builtinActions[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
|
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
|
||||||
name, slices.Sorted(maps.Keys(builtinActions)), rc.Run, stepModel)
|
name, slices.Sorted(maps.Keys(builtinActions)), rc.Name, stepModel)
|
||||||
}
|
}
|
||||||
return &stepActionBuiltin{
|
return &stepActionBuiltin{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
@@ -61,5 +61,5 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("unable to determine how to run job:%s step:%+v", rc.Run, stepModel)
|
return nil, fmt.Errorf("unable to determine how to run job:%s step:%+v", rc.Name, stepModel)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestStepFactoryInvalidStep(t *testing.T) {
|
|||||||
{Uses: "remote/action@v1", Run: "cmd"},
|
{Uses: "remote/action@v1", Run: "cmd"},
|
||||||
{Uses: "builtin:unknown"},
|
{Uses: "builtin:unknown"},
|
||||||
} {
|
} {
|
||||||
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{})
|
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
||||||
assert.Error(t, err, stepModel.Uses)
|
assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Host-mode background processes on Windows
|
# Host-mode background processes
|
||||||
|
|
||||||
Windows host jobs clean up background step processes at job completion. A step can opt out:
|
Host jobs clean up background step processes at job completion. A step can opt out:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
steps:
|
steps:
|
||||||
@@ -10,8 +10,8 @@ steps:
|
|||||||
run: Start-Process vendor-helper.exe
|
run: Start-Process vendor-helper.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
- Set the `RUNNER_TRACKING_ID` override in the step's `env` before it starts. Any value different from the job's ID opts out, including an empty string.
|
- Set the `RUNNER_TRACKING_ID` override in the step's `env` before it starts. Any value different from the job's ID opts out, including an empty string. On Linux and macOS a script can also set it before starting a process.
|
||||||
- Opted-out processes and descendants that inherit the override survive normal job cleanup, including workspace process sweeps and expired output waits. Detach helper output because the runner closes leftover output pipes after a bounded wait.
|
- Opted-out processes and descendants that inherit the override survive normal job cleanup and expired output waits. Detach helper output because the runner closes leftover output pipes after a bounded wait.
|
||||||
- Cancelling or timing out an active step still terminates its process tree.
|
- Cancelling or timing out an active step still terminates its process tree.
|
||||||
- The override does not preserve workspace or temporary files. Store persistent helper files outside those directories and arrange the helper's eventual shutdown.
|
- The override does not preserve workspace or temporary files. Store persistent helper files outside those directories and arrange the helper's eventual shutdown.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
@@ -24,22 +24,6 @@ func NewGroup() (*Group, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return &Group{job: job}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +49,7 @@ func (g *Group) Assign(p *os.Process) error {
|
|||||||
return windows.AssignProcessToJobObject(g.job, h)
|
return windows.AssignProcessToJobObject(g.job, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close drops the job handle, terminating every process still assigned to it.
|
// Close terminates every process still assigned to the job and drops its handle, nil-safe and idempotent.
|
||||||
// Nil-safe and idempotent.
|
|
||||||
func (g *Group) Close() error {
|
func (g *Group) Close() error {
|
||||||
if g == nil {
|
if g == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -80,5 +63,5 @@ func (g *Group) Close() error {
|
|||||||
|
|
||||||
h := g.job
|
h := g.job
|
||||||
g.job = 0
|
g.job = 0
|
||||||
return windows.CloseHandle(h)
|
return errors.Join(windows.TerminateJobObject(h, 1), windows.CloseHandle(h))
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ package process
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
// on cancellation (TerminateJobObject), which also closes the inherited pipe
|
// on cancellation (TerminateJobObject), which also closes the inherited pipe
|
||||||
// handles so cmd.Wait() can return.
|
// handles so cmd.Wait() can return.
|
||||||
type Killer struct {
|
type Killer struct {
|
||||||
|
mu sync.Mutex
|
||||||
job windows.Handle
|
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
|
// Kill terminates every process currently assigned to the job (the process and
|
||||||
// all of its descendants).
|
// all of its descendants).
|
||||||
func (k *Killer) Kill() error {
|
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 nil
|
||||||
}
|
}
|
||||||
return windows.TerminateJobObject(k.job, 1)
|
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.
|
// Close releases the job handle. It does not terminate the processes.
|
||||||
func (k *Killer) Close() error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
h := k.job
|
h := k.job
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ®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 "", 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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -36,9 +36,9 @@ type TreeKill struct {
|
|||||||
KeepLeftovers bool
|
KeepLeftovers bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTreeKill sets cmd.Cancel and cmd.WaitDelay. Call it before cmd.Start, then
|
// NewTreeKill sets cmd.Cancel, cmd.WaitDelay and CREATE_SUSPENDED. Call it after setting cmd.SysProcAttr, and Capture once after a successful Start.
|
||||||
// call Capture once after a successful Start.
|
|
||||||
func NewTreeKill(cmd *exec.Cmd) *TreeKill {
|
func NewTreeKill(cmd *exec.Cmd) *TreeKill {
|
||||||
|
startSuspended(cmd)
|
||||||
t := &TreeKill{}
|
t := &TreeKill{}
|
||||||
cmd.Cancel = func() error {
|
cmd.Cancel = func() error {
|
||||||
if k := t.killer.Load(); k != nil {
|
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
|
// 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
|
// 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
|
// 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) {
|
func (t *TreeKill) Capture(p *os.Process) (*Killer, error) {
|
||||||
k, err := NewKiller(p)
|
k, err := NewKiller(p)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return nil, err
|
t.killer.Store(k)
|
||||||
}
|
}
|
||||||
t.killer.Store(k)
|
if resumeErr := resume(p); resumeErr != nil {
|
||||||
return k, 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.
|
// Wait is cmd.Wait, but like GitHub's runner a leftover holding a successful command's output does not fail it.
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
timeout time.Duration
|
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "succeeds", timeout: time.Minute},
|
{name: "succeeds"},
|
||||||
{name: "fails once the context is done", timeout: 10 * time.Millisecond, wantErr: true},
|
{name: "fails once the context is done", wantErr: true},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), tc.timeout)
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 30 &")
|
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 30 &")
|
||||||
cmd.Stdout = io.Discard
|
cmd.Stdout = io.Discard
|
||||||
@@ -51,6 +50,9 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
|
|||||||
killer, err := tk.Capture(cmd.Process)
|
killer, err := tk.Capture(cmd.Process)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = killer.Kill() })
|
t.Cleanup(func() { _ = killer.Kill() })
|
||||||
|
if tc.wantErr {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
err = tk.Wait(ctx, cmd)
|
err = tk.Wait(ctx, cmd)
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
@@ -66,11 +68,11 @@ func TestTreeKillCaptureStoresKiller(t *testing.T) {
|
|||||||
cmd, ready := newTestProcess(t, "child", false)
|
cmd, ready := newTestProcess(t, "child", false)
|
||||||
tk := NewTreeKill(cmd)
|
tk := NewTreeKill(cmd)
|
||||||
require.NoError(t, cmd.Start())
|
require.NoError(t, cmd.Start())
|
||||||
ready()
|
|
||||||
|
|
||||||
killer, err := tk.Capture(cmd.Process)
|
killer, err := tk.Capture(cmd.Process)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, killer)
|
require.NotNil(t, killer)
|
||||||
|
ready()
|
||||||
require.NoError(t, cmd.Cancel())
|
require.NoError(t, cmd.Cancel())
|
||||||
require.NoError(t, killer.Close())
|
require.NoError(t, killer.Close())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ $shared = '\\host.lan\Data'
|
|||||||
try {
|
try {
|
||||||
Write-Output '==> Setting up Windows'
|
Write-Output '==> Setting up Windows'
|
||||||
Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem LongPathsEnabled 1
|
Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem LongPathsEnabled 1
|
||||||
|
Set-TimeZone -Id UTC # QEMU's clock is UTC, any other zone boots the cached install hours off and jumps when time syncs
|
||||||
foreach ($tool in @(
|
foreach ($tool in @(
|
||||||
@('git.7z.exe', 'https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.3/PortableGit-2.56.0.3-64-bit.7z.exe', '4fc27b779829a36df77911e3f4dea09c66c790dd6c5f7dfa3b6a5479f1fe354f'),
|
@('git.7z.exe', 'https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.3/PortableGit-2.56.0.3-64-bit.7z.exe', '4fc27b779829a36df77911e3f4dea09c66c790dd6c5f7dfa3b6a5479f1fe354f'),
|
||||||
@('node.zip', 'https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip', 'd2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5'),
|
@('node.zip', 'https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip', 'd2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5'),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ call C:\run.bat
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
kvm=Y cores=max
|
kvm=Y cores=max
|
||||||
|
cpuFlags=-amx-tile,-amx-int8,-amx-bf16,-amx-fp16,-amx-complex # AMX overflows Go's exception handling into the heap, https://github.com/golang/go/issues/81238
|
||||||
if ! docker run --rm --privileged --entrypoint sh "$image" -c 'test -w /dev/kvm'; then
|
if ! docker run --rm --privileged --entrypoint sh "$image" -c 'test -w /dev/kvm'; then
|
||||||
kvm=N cores=4 # more emulated vCPUs slow Windows down
|
kvm=N cores=4 # more emulated vCPUs slow Windows down
|
||||||
echo "==> The docker host has no usable /dev/kvm, Windows runs emulated and much slower"
|
echo "==> The docker host has no usable /dev/kvm, Windows runs emulated and much slower"
|
||||||
@@ -102,7 +103,7 @@ fi
|
|||||||
deadline=$((SECONDS + timeout * 60))
|
deadline=$((SECONDS + timeout * 60))
|
||||||
boot() {
|
boot() {
|
||||||
name="gitea-runner-windows-test-${work##*.}-$1" # a new name ends the log and monitor loops of the previous VM
|
name="gitea-runner-windows-test-${work##*.}-$1" # a new name ends the log and monitor loops of the previous VM
|
||||||
docker create --rm --name "$name" --privileged -v "$volume:/storage" "${vm[@]}" -e KVM="$kvm" -e CPU_CORES="$cores" -e DISK_CACHE=unsafe -e DISK_IO=threads \
|
docker create --rm --name "$name" --privileged -v "$volume:/storage" "${vm[@]}" -e KVM="$kvm" -e CPU_CORES="$cores" -e CPU_FLAGS="$cpuFlags" -e DISK_CACHE=unsafe -e DISK_IO=threads \
|
||||||
--entrypoint timeout "$image" "$(((deadline - SECONDS) / 60 + 10))m" /usr/bin/tini -s /run/entry.sh >/dev/null # the timeout stops the VM even when this script is killed before its cleanup runs
|
--entrypoint timeout "$image" "$(((deadline - SECONDS) / 60 + 10))m" /usr/bin/tini -s /run/entry.sh >/dev/null # the timeout stops the VM even when this script is killed before its cleanup runs
|
||||||
if [ "$cached" = Y ]; then
|
if [ "$cached" = Y ]; then
|
||||||
docker cp "$cache/storage/." "$name:/storage"
|
docker cp "$cache/storage/." "$name:/storage"
|
||||||
|
|||||||
Reference in new issue
Block a user