mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-07-22 20:47:45 +00:00
fix: stop host-mode jobs from leaking processes on Windows (#1080)
## Problem On host-mode Windows runners, a job can leave processes running after it finishes, and those leftovers hold file handles that block deletion of the workspace. Today a step's tree is only torn down when the step is *cancelled* (`process.Killer`). A step that completes leaves whatever it spawned alive, and the existing workspace scan in `terminateRunningProcesses` misses two shapes of leftover: orphans whose parent already exited (no tree to walk, and their executable often lives outside the workspace), and processes that merely *run in* the workspace but reference no path from it — `Win32_Process` exposes no working directory, so the scan cannot match them. ## Solution Two additions in `internal/pkg/process`, both no-ops outside Windows: - **`process.Group`** — a job-scoped Windows Job Object created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Step processes are assigned to it before they get their own `Killer`, so the step's job nests inside the job's: cancellation still kills exactly the step's tree, while `Remove` closing the group makes the kernel terminate everything still assigned, whatever its parentage. The kernel also drops the handle when the runner exits, so a crashed runner cannot strand processes. - **`process.KillProcessesWithCWDUnder`** — a best-effort net for processes that never joined the job (started via a service or scheduled task). It reads each process's working directory from its PEB and terminates those under a workspace dir. Processes it cannot open are skipped. --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1080 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
86
internal/pkg/process/group_windows.go
Normal file
86
internal/pkg/process/group_windows.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"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).
|
||||
type Group struct {
|
||||
mu sync.Mutex
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
// NewGroup creates the job object. Closing it is what terminates the leftovers,
|
||||
// so the caller must Close it when the job ends.
|
||||
func NewGroup() (*Group, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
if _, err := windows.SetInformationJobObject(
|
||||
job,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Group{job: job}, nil
|
||||
}
|
||||
|
||||
// Assign adds a started process to the job; anything it spawns afterwards joins
|
||||
// too. Call before NewKiller so the step's job nests inside this one. Nil-safe.
|
||||
func (g *Group) Assign(p *os.Process) error {
|
||||
if g == nil || p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.job == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(h) }()
|
||||
|
||||
return windows.AssignProcessToJobObject(g.job, h)
|
||||
}
|
||||
|
||||
// Close drops the job handle, terminating every process still assigned to it.
|
||||
// Nil-safe and idempotent.
|
||||
func (g *Group) Close() error {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.job == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
h := g.job
|
||||
g.job = 0
|
||||
return windows.CloseHandle(h)
|
||||
}
|
||||
Reference in New Issue
Block a user