Files
act_runner/internal/pkg/process/group_windows.go
bircni 7bec310002 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>
2026-07-22 15:04:07 +00:00

87 lines
2.0 KiB
Go

// 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)
}