Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor git.Command.Run*, introduce RunWithContextString and RunWithContextBytes #19266

Merged
merged 10 commits into from
Mar 31, 2022
138 changes: 58 additions & 80 deletions modules/git/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"os/exec"
"strings"
"time"
"unsafe"

"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
Expand Down Expand Up @@ -93,32 +94,6 @@ func (c *Command) AddArguments(args ...string) *Command {
return c
}

// RunInDirTimeoutEnvPipeline executes the command in given directory with given timeout,
// it pipes stdout and stderr to given io.Writer.
func (c *Command) RunInDirTimeoutEnvPipeline(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer) error {
return c.RunInDirTimeoutEnvFullPipeline(env, timeout, dir, stdout, stderr, nil)
}

// RunInDirTimeoutEnvFullPipeline executes the command in given directory with given timeout,
// it pipes stdout and stderr to given io.Writer and passes in an io.Reader as stdin.
func (c *Command) RunInDirTimeoutEnvFullPipeline(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader) error {
return c.RunInDirTimeoutEnvFullPipelineFunc(env, timeout, dir, stdout, stderr, stdin, nil)
}

// RunInDirTimeoutEnvFullPipelineFunc executes the command in given directory with given timeout,
// it pipes stdout and stderr to given io.Writer and passes in an io.Reader as stdin. Between cmd.Start and cmd.Wait the passed in function is run.
func (c *Command) RunInDirTimeoutEnvFullPipelineFunc(env []string, timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader, fn func(context.Context, context.CancelFunc) error) error {
return c.RunWithContext(&RunContext{
Env: env,
Timeout: timeout,
Dir: dir,
Stdout: stdout,
Stderr: stderr,
Stdin: stdin,
PipelineFunc: fn,
})
}

// RunContext represents parameters to run the command
type RunContext struct {
Env []string
Expand All @@ -131,7 +106,7 @@ type RunContext struct {

// RunWithContext run the command with context
func (c *Command) RunWithContext(rc *RunContext) error {
if rc.Timeout == -1 {
if rc.Timeout <= 0 {
rc.Timeout = defaultCommandExecutionTimeout
}

Expand Down Expand Up @@ -203,58 +178,73 @@ func (c *Command) RunWithContext(rc *RunContext) error {
return ctx.Err()
}

// RunInDirTimeoutPipeline executes the command in given directory with given timeout,
// it pipes stdout and stderr to given io.Writer.
func (c *Command) RunInDirTimeoutPipeline(timeout time.Duration, dir string, stdout, stderr io.Writer) error {
return c.RunInDirTimeoutEnvPipeline(nil, timeout, dir, stdout, stderr)
type RunError interface {
error
Stderr() string
}

// RunInDirTimeoutFullPipeline executes the command in given directory with given timeout,
// it pipes stdout and stderr to given io.Writer, and stdin from the given io.Reader
func (c *Command) RunInDirTimeoutFullPipeline(timeout time.Duration, dir string, stdout, stderr io.Writer, stdin io.Reader) error {
return c.RunInDirTimeoutEnvFullPipeline(nil, timeout, dir, stdout, stderr, stdin)
type runError struct {
err error
stderr string
errMsg string
}

// RunInDirTimeout executes the command in given directory with given timeout,
// and returns stdout in []byte and error (combined with stderr).
func (c *Command) RunInDirTimeout(timeout time.Duration, dir string) ([]byte, error) {
return c.RunInDirTimeoutEnv(nil, timeout, dir)
func (r *runError) Error() string {
// the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")`
if r.errMsg == "" {
r.errMsg = ConcatenateError(r.err, r.stderr).Error()
}
return r.errMsg
}

// RunInDirTimeoutEnv executes the command in given directory with given timeout,
// and returns stdout in []byte and error (combined with stderr).
func (c *Command) RunInDirTimeoutEnv(env []string, timeout time.Duration, dir string) ([]byte, error) {
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
if err := c.RunInDirTimeoutEnvPipeline(env, timeout, dir, stdout, stderr); err != nil {
return nil, ConcatenateError(err, stderr.String())
}
if stdout.Len() > 0 && log.IsTrace() {
tracelen := stdout.Len()
if tracelen > 1024 {
tracelen = 1024
}
log.Trace("Stdout:\n %s", stdout.Bytes()[:tracelen])
}
return stdout.Bytes(), nil
func (r *runError) Unwrap() error {
return r.err
}

func (r *runError) Stderr() string {
return r.stderr
}

// RunInDirPipeline executes the command in given directory,
// it pipes stdout and stderr to given io.Writer.
func (c *Command) RunInDirPipeline(dir string, stdout, stderr io.Writer) error {
return c.RunInDirFullPipeline(dir, stdout, stderr, nil)
func bytesToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b)) // that's what Golang's strings.Builder.String() does (go/src/strings/builder.go)
}

// RunInDirFullPipeline executes the command in given directory,
// it pipes stdout and stderr to given io.Writer.
func (c *Command) RunInDirFullPipeline(dir string, stdout, stderr io.Writer, stdin io.Reader) error {
return c.RunInDirTimeoutFullPipeline(-1, dir, stdout, stderr, stdin)
// RunWithContextString run the command with context and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr).
func (c *Command) RunWithContextString(rc *RunContext) (stdout, stderr string, runErr RunError) {
stdoutBytes, stderrBytes, err := c.RunWithContextBytes(rc)
stdout = bytesToString(stdoutBytes)
stderr = bytesToString(stderrBytes)
if err != nil {
return stdout, stderr, &runError{err: err, stderr: stderr}
}
// even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are
return stdout, stderr, nil
}

// RunWithContextBytes run the command with context and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr).
func (c *Command) RunWithContextBytes(rc *RunContext) (stdout, stderr []byte, runErr RunError) {
if rc.Stdout != nil || rc.Stderr != nil {
// we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug
panic("stdout and stderr field must be nil when using RunWithContextBytes")
}
stdoutBuf := &bytes.Buffer{}
stderrBuf := &bytes.Buffer{}
rc.Stdout = stdoutBuf
rc.Stderr = stderrBuf
err := c.RunWithContext(rc)
stderr = stderrBuf.Bytes()
if err != nil {
return nil, stderr, &runError{err: err, stderr: string(stderr)}
}
// even if there is no err, there could still be some stderr output
return stdoutBuf.Bytes(), stderr, nil
}

// RunInDirBytes executes the command in given directory
// and returns stdout in []byte and error (combined with stderr).
func (c *Command) RunInDirBytes(dir string) ([]byte, error) {
return c.RunInDirTimeout(-1, dir)
stdout, _, err := c.RunWithContextBytes(&RunContext{Dir: dir})
return stdout, err
}

// RunInDir executes the command in given directory
Expand All @@ -266,27 +256,15 @@ func (c *Command) RunInDir(dir string) (string, error) {
// RunInDirWithEnv executes the command in given directory
// and returns stdout in string and error (combined with stderr).
func (c *Command) RunInDirWithEnv(dir string, env []string) (string, error) {
stdout, err := c.RunInDirTimeoutEnv(env, -1, dir)
if err != nil {
return "", err
}
return string(stdout), nil
}

// RunTimeout executes the command in default working directory with given timeout,
// and returns stdout in string and error (combined with stderr).
func (c *Command) RunTimeout(timeout time.Duration) (string, error) {
stdout, err := c.RunInDirTimeout(timeout, "")
if err != nil {
return "", err
}
return string(stdout), nil
stdout, _, err := c.RunWithContextString(&RunContext{Env: env, Dir: dir})
return stdout, err
}

// Run executes the command in default working directory
// and returns stdout in string and error (combined with stderr).
func (c *Command) Run() (string, error) {
return c.RunTimeout(-1)
stdout, _, err := c.RunWithContextString(&RunContext{})
return stdout, err
}

// AllowLFSFiltersArgs return globalCommandArgs with lfs filter, it should only be used for tests
Expand Down
40 changes: 40 additions & 0 deletions modules/git/command_race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

//go:build race
// +build race

package git

import (
"context"
"testing"
"time"
)

func TestRunWithContextNoTimeout(t *testing.T) {
maxLoops := 10

// 'git --version' does not block so it must be finished before the timeout triggered.
cmd := NewCommand(context.Background(), "--version")
for i := 0; i < maxLoops; i++ {
if err := cmd.RunWithContext(&RunContext{}); err != nil {
t.Fatal(err)
}
}
}

func TestRunWithContextTimeout(t *testing.T) {
maxLoops := 10

// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
cmd := NewCommand(context.Background(), "hash-object", "--stdin")
for i := 0; i < maxLoops; i++ {
if err := cmd.RunWithContext(&RunContext{Timeout: 1 * time.Millisecond}); err != nil {
if err != context.DeadlineExceeded {
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
}
}
}
}
41 changes: 15 additions & 26 deletions modules/git/command_test.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,29 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

//go:build race
// +build race

package git

import (
"context"
"testing"
"time"
)

func TestRunInDirTimeoutPipelineNoTimeout(t *testing.T) {
maxLoops := 1000
"github.com/stretchr/testify/assert"
)

// 'git --version' does not block so it must be finished before the timeout triggered.
func TestRunWithContextStd(t *testing.T) {
cmd := NewCommand(context.Background(), "--version")
for i := 0; i < maxLoops; i++ {
if err := cmd.RunInDirTimeoutPipeline(-1, "", nil, nil); err != nil {
t.Fatal(err)
}
}
}

func TestRunInDirTimeoutPipelineAlwaysTimeout(t *testing.T) {
maxLoops := 1000
stdout, stderr, err := cmd.RunWithContextString(&RunContext{})
assert.NoError(t, err)
assert.Empty(t, stderr)
assert.Contains(t, stdout, "git version")

// 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered.
cmd := NewCommand(context.Background(), "hash-object", "--stdin")
for i := 0; i < maxLoops; i++ {
if err := cmd.RunInDirTimeoutPipeline(1*time.Microsecond, "", nil, nil); err != nil {
if err != context.DeadlineExceeded {
t.Fatalf("Testing %d/%d: %v", i, maxLoops, err)
}
}
cmd = NewCommand(context.Background(), "--no-such-arg")
stdout, stderr, err = cmd.RunWithContextString(&RunContext{})
if assert.Error(t, err) {
assert.Equal(t, stderr, err.Stderr())
assert.Contains(t, err.Stderr(), "unknown option:")
assert.Contains(t, err.Error(), "exit status 129 - unknown option:")
assert.Empty(t, stdout)
}
}
11 changes: 4 additions & 7 deletions modules/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ func VersionInfo() string {
func Init(ctx context.Context) error {
DefaultContext = ctx

defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
if setting.Git.Timeout.Default > 0 {
defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
}

if err := SetExecutablePath(setting.Git.Path); err != nil {
return err
Expand Down Expand Up @@ -295,10 +297,5 @@ func checkAndRemoveConfig(key, value string) error {

// Fsck verifies the connectivity and validity of the objects in the database
func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...string) error {
// Make sure timeout makes sense.
if timeout <= 0 {
timeout = -1
}
_, err := NewCommand(ctx, "fsck").AddArguments(args...).RunInDirTimeout(timeout, repoPath)
return err
return NewCommand(ctx, "fsck").AddArguments(args...).RunWithContext(&RunContext{Timeout: timeout, Dir: repoPath})
}
6 changes: 5 additions & 1 deletion modules/process/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ func (pm *Manager) AddContext(parent context.Context, description string) (ctx c
// Most processes will not need to use the cancel function but there will be cases whereby you want to cancel the process but not immediately remove it from the
// process table.
func (pm *Manager) AddContextTimeout(parent context.Context, timeout time.Duration, description string) (ctx context.Context, cancel context.CancelFunc, finshed FinishedFunc) {
if timeout <= 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like there are some user-defined timeouts being passed to this function, I'm not sure that in the middle of normal operations a panic should occur from a process...

Reference:

ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm that would mean we either should fail on gitea init or put out a warn and ignore this setting

Copy link
Contributor Author

@wxiaoguang wxiaoguang Mar 31, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gusted

When passing timeout<=0 into AddContextTimeout:

  • Before: then the context is cancelled immediately (a non-obvious bug, which may lead to more strange problems)
  • Now: a panic is reported as early as possible, when we catch the bug and can fix it.

From my experience, in a big and complex system, everything should be defined as a clear behavior, if there is something unexpected happening, the error should be reported in the first time. We should always expose mistakes, instead of hiding mistakes.

So I think this new mechanism is better and more stable, this refactoring is only done in 1.17 and we have enough time to catch more bugs in future.

And fortunately, the SendmailTimeout has a default value: SendmailTimeout: sec.Key("SENDMAIL_TIMEOUT").MustDuration(5 * time.Minute),, so there will be no problem now. As an exmple, if user set SendmailTimeout=-1 in old code, the user only sees some non-sense errors like context cancelled. Now, the user knows that the timeout is wrong. And when he reports the bug to gitea issues, the panic message is also very clear for maintainers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or we could ignore the timeout context if timeout <= 0 but not panic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, ignoring non-sense values in low-level frameworks is wrong. Many Go functions also panic if there is an invalid input.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And when he reports the bug to gitea issues, the panic message is also very clear for maintainers.

I think we should prevent such issues from being reported. I'm not sure at which level the context cancelled are being reported to the admin, but otherwise it might be their "first time" that they observe that their configuration is incorrect. I think we should have a little note in the 1.17 announcement that a negative timeout can lead to panics and errors and that 0 should be used instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many Go functions also panic if there is an invalid input.

Many were written without being able to return errors, and given they don't want to break backwards compatibility, they instead panic.

Copy link
Contributor Author

@wxiaoguang wxiaoguang Mar 31, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And when he reports the bug to gitea issues, the panic message is also very clear for maintainers.

I think we should prevent such issues from being reported. I'm not sure at which level the context cancelled are being reported to the admin, but otherwise it might be their "first time" that they observe that their configuration is incorrect. I think we should have a little note in the 1.17 announcement that a negative timeout can lead to panics and errors and that 0 should be used instead.

Why do we need to mention that? If users were using -1 or 0, how can their system work correctly? And 0 is also incorrect for a timeout, it should be handled by Gitea's setting module to set to a default timeout, this work can not be done automatically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to mention that? If users were using -1 or 0, how can their system work correctly?

I have no clue, but from what I've learned is that people can somehow survive with incorrect configurations without noticing. Anyhow, approving.

Copy link
Contributor Author

@wxiaoguang wxiaoguang Mar 31, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear:

If a user were using -1 or 0, and their system works correctly, after this PR, nothing changes. Because these values were processed to defaults correctly by Gitea already.

If a user were using -1 or 0 and their system didn't work correctly, before this PR they knew nothing, after this PR they know that there is something wrong. That's a improvement.

ps: I agree "people can somehow survive with incorrect configurations without noticing.", however, once they meet some errors and report "bugs", it makes maintainers frustrated ..... nobody can guess what's wrong on user side if mistakes are hidden. We really need a clear system 😊

// it's meaningless to use timeout <= 0, and it must be a bug! so we must panic here to tell developers to make the timeout correct
panic("the timeout must be greater than zero, otherwise the context will be cancelled immediately")
}
ctx, cancel = context.WithTimeout(parent, timeout)

ctx, pid, finshed := pm.Add(ctx, description, cancel)
Expand Down Expand Up @@ -239,7 +243,7 @@ func (pm *Manager) ExecDirEnv(ctx context.Context, timeout time.Duration, dir, d
// Returns its complete stdout and stderr
// outputs and an error, if any (including timeout)
func (pm *Manager) ExecDirEnvStdIn(ctx context.Context, timeout time.Duration, dir, desc string, env []string, stdIn io.Reader, cmdName string, args ...string) (string, string, error) {
if timeout == -1 {
if timeout <= 0 {
timeout = 60 * time.Second
}

Expand Down
2 changes: 1 addition & 1 deletion routers/web/repo/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ func GetInfoRefs(ctx *context.Context) {
}
h.environ = append(os.Environ(), h.environ...)

refs, err := git.NewCommand(ctx, service, "--stateless-rpc", "--advertise-refs", ".").RunInDirTimeoutEnv(h.environ, -1, h.dir)
refs, _, err := git.NewCommand(ctx, service, "--stateless-rpc", "--advertise-refs", ".").RunWithContextBytes(&git.RunContext{Env: h.environ, Dir: h.dir})
if err != nil {
log.Error(fmt.Sprintf("%v - %s", err, string(refs)))
}
Expand Down
10 changes: 1 addition & 9 deletions services/repository/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,7 @@ func GitGcRepos(ctx context.Context, timeout time.Duration, args ...string) erro
SetDescription(fmt.Sprintf("Repository Garbage Collection: %s", repo.FullName()))
var stdout string
var err error
if timeout > 0 {
var stdoutBytes []byte
stdoutBytes, err = command.RunInDirTimeout(
timeout,
repo.RepoPath())
stdout = string(stdoutBytes)
} else {
stdout, err = command.RunInDir(repo.RepoPath())
}
stdout, _, err = command.RunWithContextString(&git.RunContext{Timeout: timeout, Dir: repo.RepoPath()})

if err != nil {
log.Error("Repository garbage collection failed for %v. Stdout: %s\nError: %v", repo, stdout, err)
Expand Down
4 changes: 2 additions & 2 deletions services/repository/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
needsRollback = true

repoPath := repo_model.RepoPath(owner.Name, repo.Name)
if stdout, err := git.NewCommand(txCtx,
if stdout, _, err := git.NewCommand(txCtx,
"clone", "--bare", oldRepoPath, repoPath).
SetDescription(fmt.Sprintf("ForkRepository(git clone): %s to %s", opts.BaseRepo.FullName(), repo.FullName())).
RunInDirTimeout(10*time.Minute, ""); err != nil {
RunWithContextBytes(&git.RunContext{Timeout: 10 * time.Minute}); err != nil {
log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, opts.BaseRepo, stdout, err)
return fmt.Errorf("git clone: %v", err)
}
Expand Down