-
Notifications
You must be signed in to change notification settings - Fork 0
fix: stop treating a Windows sharing collision as a lost job record #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| //go:build !windows | ||
|
|
||
| package atomicfile | ||
|
|
||
| // isShareViolation is always false away from Windows. | ||
| // | ||
| // POSIX renames succeed with the destination open, and a reader holding an | ||
| // unlinked inode keeps reading it, so there is no transient state to retry | ||
| // through. The retry loops below therefore run exactly once here: same syscall | ||
| // count, same behaviour, no sleep. | ||
| func isShareViolation(error) bool { return false } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package atomicfile | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "sync" | ||
| "sync/atomic" | ||
| "testing" | ||
| ) | ||
|
|
||
| // A reader and a writer contending on one path must both succeed. | ||
| // | ||
| // Without the retry this failed on Windows in roughly a quarter of attempts: | ||
| // os.Rename onto a path a reader has open returns ERROR_ACCESS_DENIED, and a | ||
| // read landing inside a rename returns ERROR_SHARING_VIOLATION. Neither means | ||
| // the file is bad, and callers that treated them as permanent lost records. | ||
| // | ||
| // On POSIX this asserts the same invariant, where it has always held. | ||
| func TestWriteAndReadContendOnOnePathWithoutFailing(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "record.json") | ||
| if err := os.WriteFile(path, []byte(`{"state":"running"}`), 0o600); err != nil { | ||
| t.Fatalf("seed: %v", err) | ||
| } | ||
|
|
||
| const writes = 300 | ||
| var readErr, writeErr atomic.Value | ||
| var reads int64 | ||
|
|
||
| done := make(chan struct{}) | ||
| var wg sync.WaitGroup | ||
|
|
||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| for { | ||
| select { | ||
| case <-done: | ||
| return | ||
| default: | ||
| if _, err := ReadFile(path); err != nil { | ||
| readErr.Store(err) | ||
| return | ||
| } | ||
| atomic.AddInt64(&reads, 1) | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| defer close(done) | ||
| for i := 0; i < writes; i++ { | ||
| if err := Write(path, []byte(`{"state":"completed"}`), 0o600, ".record.*.json.tmp"); err != nil { | ||
| writeErr.Store(err) | ||
| return | ||
| } | ||
| } | ||
| }() | ||
| wg.Wait() | ||
|
|
||
| if err, ok := writeErr.Load().(error); ok && err != nil { | ||
| t.Fatalf("a write lost a race with a concurrent reader: %v", err) | ||
| } | ||
| if err, ok := readErr.Load().(error); ok && err != nil { | ||
| t.Fatalf("a read lost a race with a concurrent writer: %v", err) | ||
| } | ||
| if got := atomic.LoadInt64(&reads); got == 0 { | ||
| t.Fatal("the reader never completed a read, so nothing was contended") | ||
| } | ||
| // No temp files may be left behind by a retried rename. | ||
| entries, err := os.ReadDir(dir) | ||
| if err != nil { | ||
| t.Fatalf("read dir: %v", err) | ||
| } | ||
| for _, e := range entries { | ||
| if e.Name() != "record.json" { | ||
| t.Errorf("leftover file after contended writes: %s", e.Name()) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| //go:build windows | ||
|
|
||
| package atomicfile | ||
|
|
||
| import ( | ||
| "errors" | ||
| "syscall" | ||
|
|
||
| "golang.org/x/sys/windows" | ||
| ) | ||
|
|
||
| // isShareViolation reports whether err is Windows refusing an operation only | ||
| // because someone else has the file open at this instant. | ||
| // | ||
| // Windows opens deny by default. Go's os.ReadFile asks for FILE_SHARE_READ and | ||
| // FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so while any reader holds a | ||
| // handle, a rename onto that path fails with ERROR_ACCESS_DENIED -- and while a | ||
| // rename is replacing the file, a reader fails with ERROR_SHARING_VIOLATION. | ||
| // Neither says anything is wrong with the file or the caller; both mean "try | ||
| // again in a moment", which is what POSIX does implicitly by allowing the | ||
| // rename to proceed under an open handle. | ||
| // | ||
| // Measured on this repository's own job records: with one reader and one writer | ||
| // contending on a single path, 809 of 2000 renames failed with errno 5 and 857 | ||
| // reads failed with errno 32. Treating those as permanent is what let a job | ||
| // record vanish and be reported as malformed. | ||
| func isShareViolation(err error) bool { | ||
| if err == nil { | ||
| return false | ||
| } | ||
| var errno syscall.Errno | ||
| if !errors.As(err, &errno) { | ||
| return false | ||
| } | ||
| return errno == syscall.Errno(windows.ERROR_ACCESS_DENIED) || | ||
| errno == syscall.Errno(windows.ERROR_SHARING_VIOLATION) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an editor, antivirus scanner, or another process keeps the destination share-locked beyond this retry window,
renameRetryingstill returnsERROR_ACCESS_DENIED; however, terminal job persistence discards that result atinternal/jobs/manager.go:1339and other save sites. The temporary file is then removed byWrite, so the terminal snapshot is neither retried nor reported, and a restart can reconcile the older running record as abandoned—the same silent durability failure this change intends to fix. Preserve the failed snapshot for a later retry or propagate/report the persistence error instead of relying solely on this short retry window.Useful? React with 👍 / 👎.