fix(sync): 120s deadline on git subprocesses — hung remote no longer wedges sync (campaign F3)
runGitInDir had no context: a hanging remote parked the sync goroutine in cmd.Run(), the doSync defer never ran, `syncing` stayed true, and every manual + periodic sync was refused with "Szinkronizálás már folyamatban" until a controller restart. Each git command now runs under exec.CommandContext with a fresh per-command gitCmdTimeout (120s); the deadline error names the timeout and the (masked) git args. Debounce and failed-sync-arms-debounce unchanged. Tests: T-C1 cancelled-context kills the subprocess promptly (red-proof: pre-fix exec.Command shape runs to completion → test FAILS); T-C2 failed sync releases `syncing` and a post-debounce retry EXECUTES. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -2,6 +2,7 @@ package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -18,6 +19,13 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// gitCmdTimeout bounds each individual git subprocess (campaign finding F3, 2026-07-06):
|
||||
// without a deadline, a hung remote parks the sync goroutine inside cmd.Run() — the doSync
|
||||
// defer never runs, `syncing` stays true, and every manual + periodic sync is refused with
|
||||
// "Szinkronizálás már folyamatban" until a controller restart. 120s is generous for the
|
||||
// shallow catalog clone/fetch.
|
||||
const gitCmdTimeout = 120 * time.Second
|
||||
|
||||
// Syncer handles periodic git sync of the app catalog to the local stacks directory.
|
||||
type Syncer struct {
|
||||
cfg *config.Config
|
||||
@@ -270,10 +278,10 @@ func (s *Syncer) gitCloneOrPull() error {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [sync] git fetch --depth 1 origin %s in %s", s.cfg.Git.Branch, s.cacheDir)
|
||||
}
|
||||
if err := s.runGitInDir(s.cacheDir, "fetch", "--depth", "1", "origin", s.cfg.Git.Branch); err != nil {
|
||||
if err := s.gitCmd(s.cacheDir, "fetch", "--depth", "1", "origin", s.cfg.Git.Branch); err != nil {
|
||||
return fmt.Errorf("git fetch: %w", err)
|
||||
}
|
||||
if err := s.runGitInDir(s.cacheDir, "reset", "--hard", "origin/"+s.cfg.Git.Branch); err != nil {
|
||||
if err := s.gitCmd(s.cacheDir, "reset", "--hard", "origin/"+s.cfg.Git.Branch); err != nil {
|
||||
return fmt.Errorf("git reset: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -414,14 +422,22 @@ func copyIfChanged(src, dst string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// runGit executes a git command with the given args.
|
||||
// runGit executes a git command with the given args under the standard per-command deadline.
|
||||
func (s *Syncer) runGit(args ...string) error {
|
||||
return s.runGitInDir("", args...)
|
||||
return s.gitCmd("", args...)
|
||||
}
|
||||
|
||||
// runGitInDir executes a git command in the specified directory.
|
||||
func (s *Syncer) runGitInDir(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
// gitCmd runs one git command in dir under a fresh gitCmdTimeout deadline (per-command,
|
||||
// not per-sync — a multi-step sync gets a full budget for each subprocess).
|
||||
func (s *Syncer) gitCmd(dir string, args ...string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gitCmdTimeout)
|
||||
defer cancel()
|
||||
return s.runGitInDir(ctx, dir, args...)
|
||||
}
|
||||
|
||||
// runGitInDir executes a git command in the specified directory, killed when ctx expires.
|
||||
func (s *Syncer) runGitInDir(ctx context.Context, dir string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
@@ -433,7 +449,13 @@ func (s *Syncer) runGitInDir(dir string, args ...string) error {
|
||||
s.logger.Printf("[DEBUG] [sync] Running: git %s", maskRepoURL(strings.Join(args, " ")))
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git %s: %w\nstderr: %s", strings.Join(args, " "), err, stderr.String())
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
// Subprocess was killed by the deadline/cancellation — name it explicitly so the
|
||||
// failure is diagnosable from SyncResult/logs (never a silent hang).
|
||||
return fmt.Errorf("git %s: %v (subprocess killed at the %s deadline): %w\nstderr: %s",
|
||||
maskRepoURL(strings.Join(args, " ")), ctxErr, gitCmdTimeout, err, stderr.String())
|
||||
}
|
||||
return fmt.Errorf("git %s: %w\nstderr: %s", maskRepoURL(strings.Join(args, " ")), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user