Skip to content

Commit 51530a1

Browse files
committed
Adjustments to remove dangling repository locks
Signed-off-by: Bruno Sofiato <[email protected]>
1 parent ecbb03d commit 51530a1

File tree

5 files changed

+204
-2
lines changed

5 files changed

+204
-2
lines changed

custom/conf/app.example.ini

+3
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,9 @@ LEVEL = Info
10391039
;; Allow fork repositories without maximum number limit
10401040
;ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT = true
10411041

1042+
;; The elapsed time for dangling repository lock to be removed
1043+
;DANGLING_LOCK_THRESHOLD = 1h
1044+
10421045
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
10431046
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
10441047
;[repository.editor]

modules/git/command.go

+36-1
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,43 @@ func CommonCmdServEnvs() []string {
269269

270270
var ErrBrokenCommand = errors.New("git command is broken")
271271

272-
// Run runs the command with the RunOpts
273272
func (c *Command) Run(opts *RunOpts) error {
273+
// Check if there is some dangling locks older than the given threshold
274+
if err := ForciblyUnlockRepositoryIfNeeded(c.parentContext, opts.Dir); err != nil {
275+
log.Error("Error while trying to unlock repository: %v", err)
276+
return err
277+
}
278+
// Execute the git command
279+
if err := c.doRun(opts); err != nil {
280+
unlockUponCrashing(c.parentContext, err, opts.Dir)
281+
return err
282+
}
283+
return nil
284+
}
285+
286+
func unlockUponCrashing(ctx context.Context, originalError error, repoDir string) {
287+
if hasGitProcessCrashed(originalError) {
288+
log.Warn("The git process has crashed. Attempting to forcbily unlock the underlying repo at %s", repoDir)
289+
if err := ForciblyUnlockRepository(ctx, repoDir); err != nil {
290+
log.Error("Error while trying to unlock repository at %v", err)
291+
}
292+
}
293+
}
294+
295+
func hasGitProcessCrashed(err error) bool {
296+
if exitError, ok := err.(*exec.ExitError); ok {
297+
if runtime.GOOS == "windows" {
298+
log.Warn("Cannot realiably detected if the git process has crashed in windows. Assuming it hasn't [exitCode: %s, pid: %s]", exitError.ExitCode(), exitError.Pid())
299+
return false
300+
}
301+
return exitError.ExitCode() > 128
302+
}
303+
log.Debug("The given error is not an ExitError [err: %v]. Assuming it the git process hasn't crashed", err)
304+
return false
305+
}
306+
307+
// Run runs the command with the RunOpts
308+
func (c *Command) doRun(opts *RunOpts) error {
274309
if len(c.brokenArgs) != 0 {
275310
log.Error("git command is broken: %s, broken args: %s", c.String(), strings.Join(c.brokenArgs, " "))
276311
return ErrBrokenCommand

modules/git/repo_cleanup.go

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package git
5+
6+
import (
7+
"context"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"code.gitea.io/gitea/modules/log"
14+
"code.gitea.io/gitea/modules/setting"
15+
)
16+
17+
func ForciblyUnlockRepository(ctx context.Context, repoPath string) error {
18+
return cleanLocksIfNeeded(repoPath, time.Now())
19+
}
20+
21+
func ForciblyUnlockRepositoryIfNeeded(ctx context.Context, repoPath string) error {
22+
lockThreshold := time.Now().Add(-1 * setting.Repository.DanglingLockThreshold)
23+
return cleanLocksIfNeeded(repoPath, lockThreshold)
24+
}
25+
26+
func cleanLocksIfNeeded(repoPath string, threshold time.Time) error {
27+
if repoPath == "" {
28+
return nil
29+
}
30+
log.Trace("Checking if repository %s is locked [lock threshold is %s]", repoPath, threshold)
31+
return filepath.Walk(repoPath, func(filePath string, fileInfo os.FileInfo, err error) error {
32+
if err != nil {
33+
return err
34+
}
35+
if err := cleanLockIfNeeded(filePath, fileInfo, threshold); err != nil {
36+
log.Error("Failed to remove lock file %s: %v", filePath, err)
37+
return err
38+
}
39+
return nil
40+
})
41+
}
42+
43+
func cleanLockIfNeeded(filePath string, fileInfo os.FileInfo, threshold time.Time) error {
44+
if isLock(fileInfo) {
45+
if fileInfo.ModTime().Before(threshold) {
46+
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
47+
return err
48+
}
49+
log.Info("Lock file %s has been removed since its older than %s [timestamp: %s]", filePath, threshold, fileInfo.ModTime())
50+
return nil
51+
}
52+
log.Warn("Cannot exclude lock file %s because it is younger than the threshold %s [timestamp: %s]", filePath, threshold, fileInfo.ModTime())
53+
return nil
54+
}
55+
return nil
56+
}
57+
58+
func isLock(lockFile os.FileInfo) bool {
59+
return !lockFile.IsDir() && strings.HasSuffix(lockFile.Name(), ".lock")
60+
}

modules/git/repo_cleanup_test.go

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package git
5+
6+
import (
7+
"context"
8+
"os"
9+
"os/exec"
10+
"runtime"
11+
"testing"
12+
"time"
13+
14+
"code.gitea.io/gitea/modules/setting"
15+
"code.gitea.io/gitea/modules/test"
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
// This test mimics a repository having dangling locks. If the locks are older than the threshold, they should be
20+
// removed. Otherwise, they'll remain and the command will fail.
21+
22+
func TestMaintainExistentLock(t *testing.T) {
23+
if runtime.GOOS != "linux" {
24+
// Need to use touch to change the last access time of the lock files
25+
t.Skip("Skipping test on non-linux OS")
26+
}
27+
28+
shouldRemainLocked := func(lockFiles []string, err error) {
29+
assert.Error(t, err)
30+
for _, lockFile := range lockFiles {
31+
assert.FileExists(t, lockFile)
32+
}
33+
}
34+
35+
shouldBeUnlocked := func(lockFiles []string, err error) {
36+
assert.NoError(t, err)
37+
for _, lockFile := range lockFiles {
38+
assert.NoFileExists(t, lockFile)
39+
}
40+
}
41+
42+
t.Run("2 days lock file (1 hour threshold)", func(t *testing.T) {
43+
doTestLockCleanup(t, "2 days", time.Hour, shouldBeUnlocked)
44+
})
45+
46+
t.Run("1 hour lock file (1 hour threshold)", func(t *testing.T) {
47+
doTestLockCleanup(t, "1 hour", time.Hour, shouldBeUnlocked)
48+
})
49+
50+
t.Run("1 minutes lock file (1 hour threshold)", func(t *testing.T) {
51+
doTestLockCleanup(t, "1 minutes", time.Hour, shouldRemainLocked)
52+
})
53+
54+
t.Run("1 hour lock file (2 hour threshold)", func(t *testing.T) {
55+
doTestLockCleanup(t, "1 hour", 2*time.Hour, shouldRemainLocked)
56+
})
57+
}
58+
59+
func doTestLockCleanup(t *testing.T, lockAge string, threshold time.Duration, expectedResult func(lockFiles []string, err error)) {
60+
defer test.MockVariableValue(&setting.Repository, setting.Repository)()
61+
62+
setting.Repository.DanglingLockThreshold = threshold
63+
64+
if tmpDir, err := os.MkdirTemp("", "cleanup-after-crash"); err != nil {
65+
t.Fatal(err)
66+
} else {
67+
defer os.RemoveAll(tmpDir)
68+
69+
if err := os.CopyFS(tmpDir, os.DirFS("../../tests/gitea-repositories-meta/org3/repo3.git")); err != nil {
70+
t.Fatal(err)
71+
}
72+
73+
lockFiles := lockFilesFor(tmpDir)
74+
75+
os.MkdirAll(tmpDir+"/objects/info/commit-graphs", os.ModeSticky|os.ModePerm)
76+
77+
for _, lockFile := range lockFiles {
78+
createLockFiles(t, lockFile, lockAge)
79+
}
80+
81+
cmd := NewCommand(context.Background(), "fetch")
82+
_, _, cmdErr := cmd.RunStdString(&RunOpts{Dir: tmpDir})
83+
84+
expectedResult(lockFiles, cmdErr)
85+
}
86+
}
87+
88+
func lockFilesFor(path string) []string {
89+
return []string{
90+
path + "/config.lock",
91+
path + "/HEAD.lock",
92+
path + "/objects/info/commit-graphs/commit-graph-chain.lock",
93+
}
94+
}
95+
96+
func createLockFiles(t *testing.T, file, lockAge string) {
97+
cmd := exec.Command("touch", "-m", "-a", "-d", "-"+lockAge, file)
98+
if err := cmd.Run(); err != nil {
99+
t.Error(err)
100+
}
101+
}

modules/setting/repository.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path"
99
"path/filepath"
1010
"strings"
11+
"time"
1112

1213
"code.gitea.io/gitea/modules/log"
1314
)
@@ -53,7 +54,7 @@ var (
5354
AllowDeleteOfUnadoptedRepositories bool
5455
DisableDownloadSourceArchives bool
5556
AllowForkWithoutMaximumLimit bool
56-
57+
DanglingLockThreshold time.Duration
5758
// Repository editor settings
5859
Editor struct {
5960
LineWrapExtensions []string
@@ -283,6 +284,8 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
283284
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https")
284285
Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1)
285286
Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch)
287+
Repository.DanglingLockThreshold = sec.Key("DANGLING_LOCK_THRESHOLD").MustDuration(time.Hour)
288+
286289
RepoRootPath = sec.Key("ROOT").MustString(path.Join(AppDataPath, "gitea-repositories"))
287290
if !filepath.IsAbs(RepoRootPath) {
288291
RepoRootPath = filepath.Join(AppWorkPath, RepoRootPath)

0 commit comments

Comments
 (0)