Skip to content

Commit adb693d

Browse files
rremerRoyce Remer
authored andcommitted
fix(process): reap entire process group on cmd.Cancel, not just the parent
Assisted-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Royce Remer <rremer@salesforce.com>
1 parent 3005efa commit adb693d

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

modules/process/manager_unix.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import (
1212

1313
// SetSysProcAttribute sets the common SysProcAttrs for commands
1414
func SetSysProcAttribute(cmd *exec.Cmd) {
15-
// When Gitea runs SubProcessA -> SubProcessB and SubProcessA gets killed by context timeout, use setpgid to make sure the sub processes can be reaped instead of leaving defunct(zombie) processes.
15+
// Setpgid places the child in its own process group so that grandchildren
16+
// (e.g. git pack-objects spawned by git upload-pack) share that group.
1617
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
18+
// Override the default Cancel so that context cancellation kills the entire
19+
// process group, not just the direct child. '-' is the group, default
20+
// is the '+' pid id and would leave running orphaned children.
21+
cmd.Cancel = func() error {
22+
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
23+
}
1724
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
//go:build !windows
5+
6+
package process
7+
8+
import (
9+
"bufio"
10+
"context"
11+
"os"
12+
"os/exec"
13+
"strconv"
14+
"strings"
15+
"syscall"
16+
"testing"
17+
"time"
18+
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
// TestSetSysProcAttributeKillsProcessGroup verifies that cancelling a context
24+
// kills not only the direct child but also grandchildren it spawned.
25+
//
26+
// This mirrors the git upload-pack -> git pack-objects relationship during a
27+
// git clone: if an HTTP client disconnects mid-transfer, all subprocesses must
28+
// die. exec.CommandContext sends SIGKILL to the direct PID only; because
29+
// SetSysProcAttribute sets Setpgid:true the grandchild is in the same process
30+
// group but is NOT killed, leaking it until it finishes on its own.
31+
func TestSetSysProcAttributeKillsProcessGroup(t *testing.T) {
32+
ctx, cancel := context.WithCancel(t.Context())
33+
defer cancel()
34+
35+
// Spawn a shell that itself spawns a long-lived background process and
36+
// prints its PID — mimicking git upload-pack spawning git pack-objects.
37+
r, w, err := os.Pipe()
38+
require.NoError(t, err)
39+
40+
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 600 & echo $!; wait")
41+
cmd.Stdout = w
42+
SetSysProcAttribute(cmd)
43+
require.NoError(t, cmd.Start())
44+
w.Close() // parent keeps only the read end
45+
46+
// Always kill the process group on test exit so a failing assertion does
47+
// not leak the grandchild (sleep 600) as an orphan. Setpgid:true makes
48+
// the shell's PGID equal its own PID, so -cmd.Process.Pid targets the group.
49+
t.Cleanup(func() {
50+
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
51+
_ = cmd.Wait()
52+
})
53+
54+
// Block until the shell prints the grandchild PID.
55+
scanner := bufio.NewScanner(r)
56+
require.True(t, scanner.Scan(), "expected grandchild PID on stdout")
57+
grandchildPID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
58+
require.NoError(t, err)
59+
r.Close()
60+
61+
// Sanity: grandchild must be alive before we cancel.
62+
grandchild, err := os.FindProcess(grandchildPID)
63+
require.NoError(t, err)
64+
require.NoError(t, grandchild.Signal(syscall.Signal(0)), "grandchild should be alive before cancel")
65+
66+
// Cancel the context — exec.CommandContext should propagate the kill to the
67+
// whole process group, not just the direct child (the shell).
68+
cancel()
69+
_ = cmd.Wait()
70+
71+
// Poll until the grandchild is gone or the deadline is exceeded.
72+
// Signal(0) returns ESRCH (non-nil) once the process no longer exists.
73+
assert.Eventually(t, func() bool {
74+
return grandchild.Signal(syscall.Signal(0)) != nil
75+
}, 5*time.Second, 10*time.Millisecond,
76+
"grandchild process %d is still running after context cancel — process group was not killed (git pack-objects leak)", grandchildPID)
77+
}

0 commit comments

Comments
 (0)