commit e9582f881b4cc2462eb5e02b88174c52d5894805
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T04:13:57Z |
| subject | Fail fast |
commit e9582f881b4cc2462eb5e02b88174c52d5894805
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-14T04:13:57Z
Fail fast
---
internal/gitcmd/cancel_other.go | 7 +++++
internal/gitcmd/cancel_unix.go | 22 +++++++++++++
internal/gitcmd/gitcmd.go | 24 +++++++++++++-
internal/gitcmd/gitcmd_test.go | 70 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 122 insertions(+), 1 deletion(-)
diff --git a/internal/gitcmd/cancel_other.go b/internal/gitcmd/cancel_other.go
new file mode 100644
index 0000000..31719bd
--- /dev/null
+++ b/internal/gitcmd/cancel_other.go
@@ -0,0 +1,7 @@
+//go:build !unix
+
+package gitcmd
+
+import "os/exec"
+
+func configureCancellation(cmd *exec.Cmd) {}
diff --git a/internal/gitcmd/cancel_unix.go b/internal/gitcmd/cancel_unix.go
new file mode 100644
index 0000000..840fa0f
--- /dev/null
+++ b/internal/gitcmd/cancel_unix.go
@@ -0,0 +1,22 @@
+//go:build unix
+
+package gitcmd
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+func configureCancellation(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ // Kill the whole process group, including git-remote-https / ssh.
+ err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ if errors.Is(err, syscall.ESRCH) {
+ return os.ErrProcessDone
+ }
+ return err
+ }
+}
diff --git a/internal/gitcmd/gitcmd.go b/internal/gitcmd/gitcmd.go
index bb185f5..8a0c913 100644
--- a/internal/gitcmd/gitcmd.go
+++ b/internal/gitcmd/gitcmd.go
@@ -32,12 +32,19 @@ func New(gitCommand string, logger *slog.Logger) *Runner {
func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Duration) error {
const attempts = 5
+ // A reachability probe must not consume the clone/fetch transfer budget.
+ if timeout <= 0 || timeout > 5*time.Second {
+ timeout = 5 * time.Second
+ }
backoff := 500 * time.Millisecond
var lastErr error
for i := 0; i < attempts; i++ {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
probeCtx, cancel := context.WithTimeout(ctx, timeout)
_, err := r.Output(probeCtx, "", "ls-remote", "--heads", url)
@@ -49,10 +56,17 @@ func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Du
}
lastErr = err
+ if err := ctx.Err(); err != nil {
+ return err
+ }
if i == attempts-1 {
break
}
+ if r.Logger != nil {
+ r.Logger.Warn("remote probe retry", "attempt", i+1,
+ "max_attempts", attempts, "timeout", timeout, "retry_in", backoff)
+ }
select {
case <-ctx.Done():
@@ -63,7 +77,7 @@ func (r *Runner) waitRemoteLive(ctx context.Context, url string, timeout time.Du
backoff *= 2
}
- return lastErr
+ return fmt.Errorf("remote probe failed after %d attempts: %w", attempts, lastErr)
}
func (r *Runner) EnsureMirror(ctx context.Context, repo config.Repo, cloneTimeout, fetchTimeout time.Duration) error {
@@ -123,6 +137,10 @@ func (r *Runner) EnsureMirror(ctx context.Context, repo config.Repo, cloneTimeou
func (r *Runner) Output(ctx context.Context, dir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, r.GitCommand, args...)
cmd.Env = os.Environ()
+ // Git spawns transport helpers that can otherwise keep the output pipes open
+ // after CommandContext kills the Git process.
+ configureCancellation(cmd)
+ cmd.WaitDelay = time.Second
if dir != "" {
cmd.Dir = dir
@@ -157,9 +175,13 @@ func (r *Runner) Output(ctx context.Context, dir string, args ...string) ([]byte
"dur", dur,
"stdout_bytes", stdout.Len(),
"stderr", msg,
+ "context_error", ctx.Err(),
)
}
+ if ctx.Err() != nil {
+ return stdout.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), ctx.Err(), msg)
+ }
return stdout.Bytes(), fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
diff --git a/internal/gitcmd/gitcmd_test.go b/internal/gitcmd/gitcmd_test.go
new file mode 100644
index 0000000..a4da09e
--- /dev/null
+++ b/internal/gitcmd/gitcmd_test.go
@@ -0,0 +1,70 @@
+//go:build unix
+
+package gitcmd
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func fakeGit(t *testing.T, script string) *Runner {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "git")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return New(path, nil)
+}
+
+func TestRemoteProbeRetriesThenSucceeds(t *testing.T) {
+ count := filepath.Join(t.TempDir(), "attempts")
+ t.Setenv("FLATGIT_TEST_ATTEMPTS", count)
+ r := fakeGit(t, `echo attempt >> "$FLATGIT_TEST_ATTEMPTS"
+if [ "$(wc -l < "$FLATGIT_TEST_ATTEMPTS")" -lt 2 ]; then exit 1; fi
+`)
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ if err := r.waitRemoteLive(ctx, "test", time.Second); err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(count)
+ if err != nil || strings.Count(string(b), "attempt") != 2 {
+ t.Fatalf("attempts = %q, error = %v", b, err)
+ }
+}
+
+func TestOutputTimeoutKillsTransportHelper(t *testing.T) {
+ // The child inherits stdout/stderr, like git-remote-https. Killing only
+ // the shell would leave cmd.Run waiting for the child's pipes to close.
+ r := fakeGit(t, "sleep 30 &\nwait\n")
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ _, err := r.Output(ctx, "", "ls-remote")
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("error = %v", err)
+ }
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Fatalf("timeout took %s", elapsed)
+ }
+}
+
+func TestRemoteProbeCancellationStopsRetries(t *testing.T) {
+ count := filepath.Join(t.TempDir(), "attempts")
+ t.Setenv("FLATGIT_TEST_ATTEMPTS", count)
+ r := fakeGit(t, "echo attempt >> \"$FLATGIT_TEST_ATTEMPTS\"\nexit 1\n")
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if err := r.waitRemoteLive(ctx, "test", time.Second); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("error = %v", err)
+ }
+ b, err := os.ReadFile(count)
+ if err != nil || strings.Count(string(b), "attempt") != 1 {
+ t.Fatalf("attempts = %q, error = %v", b, err)
+ }
+}