diff --git a/plugins/content/local/store.go b/plugins/content/local/store.go index 1ca303b93b..7f276b8c15 100644 --- a/plugins/content/local/store.go +++ b/plugins/content/local/store.go @@ -632,7 +632,7 @@ func (s *store) writer(ctx context.Context, ref string, total int64, expected di // be cancelled. Any resources associated with the ingest will be cleaned. func (s *store) Abort(ctx context.Context, ref string) error { root := s.ingestRoot(ref) - if err := os.RemoveAll(root); err != nil { + if err := removePath(root); err != nil { if os.IsNotExist(err) { return fmt.Errorf("ingest ref %q: %w", ref, errdefs.ErrNotFound) } @@ -684,7 +684,7 @@ func readFileString(path string) (string, error) { // readFileTimestamp reads a file with just a timestamp present. func readFileTimestamp(p string) (time.Time, error) { - b, err := os.ReadFile(p) + b, err := readFileWithRetry(p) if err != nil { if os.IsNotExist(err) { err = fmt.Errorf("%s: %w", err.Error(), errdefs.ErrNotFound) diff --git a/plugins/content/local/store_other.go b/plugins/content/local/store_other.go new file mode 100644 index 0000000000..1d2aa63b96 --- /dev/null +++ b/plugins/content/local/store_other.go @@ -0,0 +1,29 @@ +//go:build !windows + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package local + +import "os" + +func removePath(path string) error { + return os.RemoveAll(path) +} + +func readFileWithRetry(path string) ([]byte, error) { + return os.ReadFile(path) +} diff --git a/plugins/content/local/store_windows.go b/plugins/content/local/store_windows.go index bce8499790..437f24618a 100644 --- a/plugins/content/local/store_windows.go +++ b/plugins/content/local/store_windows.go @@ -17,10 +17,81 @@ package local import ( + "errors" "os" "time" + + "golang.org/x/sys/windows" ) func getATime(fi os.FileInfo) time.Time { return fi.ModTime() } + +const ( + // maxFileRetries is the number of attempts for file operations that + // may fail due to transient sharing violations on Windows. + maxFileRetries = 5 + + // fileRetryDelay is the base delay between retries. Each attempt + // waits (attempt number) * fileRetryDelay. + fileRetryDelay = 100 * time.Millisecond +) + +// isRetryableError returns true for Windows errors that are known to be +// transient when a file handle has not been fully released by the OS. +func isRetryableError(err error) bool { + return errors.Is(err, windows.ERROR_SHARING_VIOLATION) || + errors.Is(err, windows.ERROR_LOCK_VIOLATION) || + errors.Is(err, windows.ERROR_DIR_NOT_EMPTY) +} + +// removePath wraps os.RemoveAll with a retry loop for Windows. +// +// On Windows, a file that has been closed by the application may +// still be held briefly by the OS. Unlike Unix, Windows does not +// allow deleting a file while any handle is open, so os.RemoveAll +// can fail with a sharing violation if it races with a recent Close. +// Retrying after a short delay allows the OS to finish releasing the handle. +func removePath(path string) error { + var err error + for i := range maxFileRetries { + err = os.RemoveAll(path) + if err == nil { + return nil + } + if !isRetryableError(err) { + return err + } + if i < maxFileRetries-1 { + time.Sleep(time.Duration(i+1) * fileRetryDelay) + } + } + return err +} + +// readFileWithRetry wraps os.ReadFile with a retry loop for Windows. +// +// On Windows, a file that has been closed by the application may +// still be held briefly by the OS. Unlike Unix, Windows does not +// allow reading a file while an exclusive handle is open, so +// os.ReadFile can fail with a sharing violation if it races with +// a recent Close. Retrying after a short delay allows the OS to +// finish releasing the handle. +func readFileWithRetry(path string) ([]byte, error) { + var err error + for i := range maxFileRetries { + var b []byte + b, err = os.ReadFile(path) + if err == nil { + return b, nil + } + if os.IsNotExist(err) || !isRetryableError(err) { + return nil, err + } + if i < maxFileRetries-1 { + time.Sleep(time.Duration(i+1) * fileRetryDelay) + } + } + return nil, err +}