Skip to content

Commit bc21fdd

Browse files
committed
refactor
1 parent 8686ec5 commit bc21fdd

21 files changed

Lines changed: 186 additions & 291 deletions

File tree

cmd/serv.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"fmt"
1010
"net/url"
1111
"os"
12-
"os/exec"
1312
"path/filepath"
1413
"strconv"
1514
"strings"
@@ -289,7 +288,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
289288
return nil
290289
}
291290

292-
var command *exec.Cmd
291+
var command *process.Cmd
293292
gitBinPath := filepath.Dir(gitcmd.GitExecutable) // e.g. /usr/bin
294293
gitBinVerb := filepath.Join(gitBinPath, verb) // e.g. /usr/bin/git-upload-pack
295294
if _, err := os.Stat(gitBinVerb); err != nil {
@@ -298,15 +297,14 @@ func runServ(ctx context.Context, c *cli.Command) error {
298297
verbFields := strings.SplitN(verb, "-", 2)
299298
if len(verbFields) == 2 {
300299
// use git binary with the sub-command part: "C:\...\bin\git.exe", "upload-pack", ...
301-
command = exec.CommandContext(ctx, gitcmd.GitExecutable, verbFields[1], results.RepoStoragePath)
300+
command = process.CommandContext(ctx, gitcmd.GitExecutable, verbFields[1], results.RepoStoragePath)
302301
}
303302
}
304303
if command == nil {
305304
// by default, use the verb (it has been checked above by allowedCommands)
306-
command = exec.CommandContext(ctx, gitBinVerb, results.RepoStoragePath)
305+
command = process.CommandContext(ctx, gitBinVerb, results.RepoStoragePath)
307306
}
308307

309-
process.SetSysProcAttribute(command)
310308
command.Dir = setting.RepoRootPath
311309
command.Stdout = os.Stdout
312310
command.Stdin = os.Stdin

modules/git/gitcmd/command.go

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"fmt"
1212
"io"
1313
"os"
14-
"os/exec"
1514
"path/filepath"
1615
"strings"
1716
"time"
@@ -47,7 +46,7 @@ type Command struct {
4746
// otherwise some git commands might overwrite git dir internal files by a repo file.
4847
gitDir string
4948

50-
cmd *exec.Cmd
49+
cmd *process.Cmd
5150

5251
cmdCtx context.Context
5352
cmdCancel process.CancelCauseFunc
@@ -432,30 +431,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
432431

433432
c.cmdStartTime = time.Now()
434433

435-
c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
434+
c.cmd = process.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
436435
if c.cmdEnv == nil {
437436
c.cmd.Env = os.Environ()
438437
} else {
439438
c.cmd.Env = c.cmdEnv
440439
}
441440

442-
process.SetSysProcAttribute(c.cmd)
443441
c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...)
444442
c.cmd.Dir = c.gitDir
445443
c.cmd.Stdout = c.cmdStdout
446444
c.cmd.Stdin = c.cmdStdin
447445
c.cmd.Stderr = c.cmdStderr
448-
c.cmd.Cancel = func() error {
449-
// Golang's default cmd.Cancel only calls Process.Kill(), but here we need to close the parent pipes together:
450-
// * for some commands like "git --batch-xxx", Windows git might have 2 processes (a wrapper and a real git process)
451-
// * on Windows, if parent process is killed (context canceled), the children process won't be killed, and the pipe handles are still open.
452-
// * if we don't close the parent pipes here, the children process won't exit.
453-
//
454-
// There is no such problem on POSIX, while it won't make things worse by closing the parent pipes also on POSIX.
455-
err := process.KillCmd(c.cmd)
446+
c.cmd.WithOnCancelGracefully(func() error {
447+
// Need to close the pipes to notify all sub processes to exit.
448+
// Especially on Windows: there is no process group, and we didn't implement process job object (like process group).
456449
c.closePipeFiles(c.parentPipeFiles)
457-
return err
458-
}
450+
return nil
451+
})
459452
return c.cmd.Start()
460453
}
461454

modules/git/gpg.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type CommitSignSettings struct {
2727
cachedPublicKeyContent atomic.Pointer[string]
2828
}
2929

30-
func (css *CommitSignSettings) PublicKeyContent() (string, error) {
30+
func (css *CommitSignSettings) PublicKeyContent(ctx context.Context) (string, error) {
3131
cached := css.cachedPublicKeyContent.Load()
3232
if cached != nil {
3333
return *cached, nil
@@ -43,7 +43,7 @@ func (css *CommitSignSettings) PublicKeyContent() (string, error) {
4343
return s, nil
4444
}
4545

46-
content, stderr, err := process.GetManager().Exec("gpg -a --export", "gpg", "-a", "--export", css.KeyID)
46+
content, stderr, err := process.CommandContext(ctx, "gpg", "-a", "--export", css.KeyID).OutputString()
4747
if err != nil {
4848
return "", fmt.Errorf("unable to get default signing key: %s, %s, %w", css.KeyID, stderr, err)
4949
}

modules/graceful/manager.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111

1212
"gitea.dev/modules/gtprof"
1313
"gitea.dev/modules/log"
14-
"gitea.dev/modules/process"
1514
"gitea.dev/modules/setting"
1615
)
1716

@@ -62,9 +61,6 @@ func InitManager(ctx context.Context) {
6261
func initManager(ctx context.Context) {
6362
initOnce.Do(func() {
6463
manager = newGracefulManager(ctx)
65-
66-
// Set the process default context to the HammerContext
67-
process.DefaultContext = manager.HammerContext()
6864
})
6965
}
7066

modules/markup/external/external.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"fmt"
1010
"io"
1111
"os"
12-
"os/exec"
1312
"strings"
1413

1514
"gitea.dev/modules/markup"
@@ -147,7 +146,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
147146
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
148147
defer finished()
149148

150-
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
149+
cmd := process.CommandContext(processCtx, cmdProg, cmdArgs...)
151150
cmd.Env = append(
152151
os.Environ(),
153152
"GITEA_PREFIX_SRC="+baseLinkSrc,
@@ -159,7 +158,6 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
159158
var stderr bytes.Buffer
160159
cmd.Stdout = output
161160
cmd.Stderr = &stderr
162-
process.SetSysProcAttribute(cmd)
163161

164162
if err := cmd.Run(); err != nil {
165163
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())

modules/process/command.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package process
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"os/exec"
10+
)
11+
12+
type Cmd struct {
13+
*exec.Cmd
14+
15+
onCancelUserFunc func() error
16+
termGraceful bool
17+
}
18+
19+
func (c *Cmd) WithOnCancelGracefully(userFunc func() error) *Cmd {
20+
c.termGraceful, c.onCancelUserFunc = true, userFunc
21+
return c
22+
}
23+
24+
func (c *Cmd) WithOnCancelForceKill(userFunc func() error) *Cmd {
25+
c.termGraceful, c.onCancelUserFunc = false, userFunc
26+
return c
27+
}
28+
29+
func (c *Cmd) WithDir(dir string) *Cmd {
30+
c.Cmd.Dir = dir
31+
return c
32+
}
33+
34+
func (c *Cmd) OutputString() (string, string, error) {
35+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
36+
c.Cmd.Stdout = stdout
37+
c.Cmd.Stderr = stderr
38+
err := c.Cmd.Run()
39+
return stdout.String(), stderr.String(), err
40+
}
41+
42+
// CommandContext returns a wrapped exec.Cmd which kills the process group when the context is canceled.
43+
// By default, it uses graceful termination (SIGTERM) on Unix-like systems to make the subprocesses have chances
44+
// to clean up (e.g. remove temporary files or lock files).
45+
func CommandContext(ctx context.Context, name string, arg ...string) *Cmd {
46+
c := &Cmd{Cmd: exec.CommandContext(ctx, name, arg...)}
47+
setSysProcAttribute(c.Cmd)
48+
c.Cmd.Cancel = c.onCancel
49+
c.termGraceful = true
50+
return c
51+
}

modules/process/command_unix.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package process
5+
6+
import (
7+
"os/exec"
8+
"syscall"
9+
10+
"gitea.dev/modules/util"
11+
)
12+
13+
func setSysProcAttribute(cmd *exec.Cmd) {
14+
// When Gitea runs SubProcessA -> SubProcessB and SubProcessA gets killed by context cancel,
15+
// use process group to make sure the sub processes can be killed and reaped instead of leaving defunct(zombie) processes.
16+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
17+
}
18+
19+
func (c *Cmd) onCancel() error {
20+
if c.onCancelUserFunc != nil {
21+
if err := c.onCancelUserFunc(); err != nil {
22+
return err
23+
}
24+
}
25+
sig := util.Iif(c.termGraceful, syscall.SIGTERM, syscall.SIGKILL)
26+
// kill the whole process group
27+
return syscall.Kill(-c.Process.Pid, sig)
28+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 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+
"strconv"
13+
"strings"
14+
"syscall"
15+
"testing"
16+
"time"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
func TestCommandContextCancelKillProcessGroup(t *testing.T) {
23+
ctx, cancel := context.WithCancel(t.Context())
24+
defer cancel()
25+
26+
// When executing external commands, there can be multiple subprocesses involved.
27+
// e.g.: Gitea -> "git upload-pack" -> "git pack-objects".
28+
// If the context is canceled (HTTP client disconnects), all subprocesses must terminate.
29+
30+
// Spawn a shell that itself spawns a long-lived background process and
31+
// prints its PID — mimicking git upload-pack spawning git pack-objects.
32+
33+
r, w, err := os.Pipe()
34+
require.NoError(t, err)
35+
36+
cmd := CommandContext(ctx, "sh", "-c", "sleep 600 & echo $!; wait")
37+
cmd.Stdout = w
38+
require.NoError(t, cmd.Start())
39+
_ = w.Close() // parent keeps only the read end
40+
41+
t.Cleanup(func() {
42+
// make sure our test doesn't leave a zombie process even if test fails
43+
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
44+
_ = cmd.Wait()
45+
})
46+
47+
// Block until the shell prints the grandchild PID.
48+
scanner := bufio.NewScanner(r)
49+
require.True(t, scanner.Scan(), "expected grandchild PID on stdout")
50+
grandchildPID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
51+
require.NoError(t, err)
52+
_ = r.Close()
53+
54+
// Sanity: grandchild must be alive before we cancel.
55+
grandchild, err := os.FindProcess(grandchildPID)
56+
require.NoError(t, err)
57+
require.NoError(t, grandchild.Signal(syscall.Signal(0)), "grandchild should be alive before cancel")
58+
59+
// Cancel the context
60+
cancel()
61+
_ = cmd.Wait()
62+
63+
// Subprocess should not exist after context cancel (killed by process group)
64+
assert.Eventually(t, func() bool {
65+
return grandchild.Signal(syscall.Signal(0)) != nil
66+
}, 5*time.Second, 10*time.Millisecond)
67+
}

modules/process/command_windows.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package process
5+
6+
import "os/exec"
7+
8+
// There is no graceful way to kill a process on Windows at the moment
9+
10+
func setSysProcAttribute(cmd *exec.Cmd) {}
11+
12+
func (c *Cmd) onCancel() error {
13+
if c.onCancelUserFunc != nil {
14+
if err := c.onCancelUserFunc(); err != nil {
15+
return err
16+
}
17+
}
18+
return c.Process.Kill()
19+
}

modules/process/error.go

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)