mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-05-08 08:13:25 +02:00
fix: serialize action-cache reads to prevent worktree race (#938)
`NewGitCloneExecutor` holds a per-directory mutex while it `git checkout --force`s a remote action into the shared `<ActionCacheDir>/<UsesHash>`, but four read sites ran unlocked: - `maybeCopyToActionDir`'s tar walk via `JobContainer.CopyDir` - `prepareActionExecutor`'s `readAction` parse of `action.yml` - `newReusableWorkflowExecutor`'s `model.NewWorkflowPlanner` after `cloneRemoteReusableWorkflow` released its lock - `execAsDocker` when `ActionCache == nil`: `docker build` walks `contextDir` for the daemon-side build context When two matrix jobs share a `uses:`, a read interleaved with a peer's checkout produces partial state — observed as `Cannot find module .../dist/index.js` and `setup-uv` failing on a half-written `action.yml`. Exports `acquireCloneLock` as `AcquireCloneLock` and takes it at all four sites. `container.ImageExistsLocally` / `NewDockerBuildExecutor` and `model.NewWorkflowPlanner` are indirected through package-level vars so the docker-action build path and the reusable-workflow read site are testable without a real daemon, mirroring `ContainerNewContainer`. Three regression tests cover the higher-risk sites (`maybeCopyToActionDir`, `execAsDocker`, `newReusableWorkflowExecutor`); each fails if its `AcquireCloneLock` is removed. Subsumed by https://gitea.com/gitea/runner/pulls/814 once that lands. Related: https://gitea.com/gitea/runner/pulls/930 --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Nicolas <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/938 Reviewed-by: Nicolas <bircni@icloud.com> Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: silverwind <me@silverwind.io> Co-committed-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -38,9 +38,11 @@ var (
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
)
|
||||
|
||||
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
|
||||
func acquireCloneLock(dir string) func() {
|
||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||
func AcquireCloneLock(dir string) func() {
|
||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
@@ -308,7 +310,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
defer acquireCloneLock(input.Dir)()
|
||||
defer AcquireCloneLock(input.Dir)()
|
||||
|
||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
|
||||
@@ -310,11 +310,11 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
t.Run("same directory serializes", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
unlock1 := acquireCloneLock(dir)
|
||||
unlock1 := AcquireCloneLock(dir)
|
||||
|
||||
secondAcquired := make(chan struct{})
|
||||
go func() {
|
||||
unlock := acquireCloneLock(dir)
|
||||
unlock := AcquireCloneLock(dir)
|
||||
close(secondAcquired)
|
||||
unlock()
|
||||
}()
|
||||
@@ -338,12 +338,12 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
dirA := t.TempDir()
|
||||
dirB := t.TempDir()
|
||||
|
||||
unlockA := acquireCloneLock(dirA)
|
||||
unlockA := AcquireCloneLock(dirA)
|
||||
defer unlockA()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
unlock := acquireCloneLock(dirB)
|
||||
unlock := AcquireCloneLock(dirB)
|
||||
unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
Reference in New Issue
Block a user