Skip to content

Commit 3707f4d

Browse files
harden cache publication across platforms
Keep Windows rename retries bounded and platform-specific, simplify response serialization and cleanup, and cover cache publication through public-client and Windows filesystem scenarios. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e0a2bb3-4485-45bd-bcbc-f426f9edbb7c
1 parent d8f2f73 commit 3707f4d

5 files changed

Lines changed: 489 additions & 80 deletions

File tree

‎pkg/api/cache.go‎

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ func (crt cacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
123123
// Allow an individual request to override cache options.
124124
func requestCacheOptions(req *http.Request) (string, time.Duration) {
125125
var dur time.Duration
126+
// Added alongside the TTL header in https://github.com/cli/go-gh/pull/49.
127+
// No production consumer of the directory override is known; retain it for
128+
// compatibility.
126129
dir := req.Header.Get("X-GH-CACHE-DIR")
127130
ttl := req.Header.Get("X-GH-CACHE-TTL")
128131
if ttl != "" {
@@ -170,62 +173,91 @@ func (fs *fileStorage) read(key string) (*http.Response, error) {
170173
return res, err
171174
}
172175

173-
func (fs *fileStorage) store(key string, res *http.Response) (storeErr error) {
174-
cacheFile := fs.filePath(key)
175-
176+
func (fs *fileStorage) store(key string, res *http.Response) error {
176177
fs.mu.Lock()
177178
defer fs.mu.Unlock()
178179

179-
dir := filepath.Dir(cacheFile)
180-
if storeErr = os.MkdirAll(dir, 0755); storeErr != nil {
181-
return
180+
cacheFilePath := fs.filePath(key)
181+
dir := filepath.Dir(cacheFilePath)
182+
if err := os.MkdirAll(dir, 0755); err != nil {
183+
return err
182184
}
183185

184-
// Write to a temporary file in the same directory, then rename into place
185-
// so concurrent processes and partial writes cannot corrupt the cache entry.
186-
var f *os.File
187-
if f, storeErr = os.CreateTemp(dir, ".gh-cache-*"); storeErr != nil {
188-
return
186+
// Finish writing before publishing the entry. Same-directory rename gives
187+
// atomic replacement on Unix; it is best-effort on other platforms.
188+
tmpCacheFile, err := os.CreateTemp(dir, ".gh-cache-*")
189+
if err != nil {
190+
return err
189191
}
190-
tmpName := f.Name()
192+
tmpCacheFileName := tmpCacheFile.Name()
193+
// Clean up on errors and panics too. After a successful rename, the
194+
// temporary path no longer exists, so removing it is harmless.
191195
defer func() {
192-
if storeErr != nil {
193-
_ = os.Remove(tmpName)
194-
}
196+
_ = tmpCacheFile.Close()
197+
_ = os.Remove(tmpCacheFileName)
195198
}()
196199

197-
var origBody io.ReadCloser
198-
if res.Body != nil {
199-
origBody, res.Body = copyStream(res.Body)
200-
defer res.Body.Close()
200+
if err := writeCacheResponse(tmpCacheFile, res); err != nil {
201+
return err
201202
}
202-
203-
storeErr = res.Write(f)
204-
if origBody != nil {
205-
res.Body = origBody
203+
if err := tmpCacheFile.Close(); err != nil {
204+
return err
206205
}
207206

208-
if cerr := f.Close(); storeErr == nil && cerr != nil {
209-
storeErr = cerr
210-
}
211-
if storeErr != nil {
212-
return
207+
return renameCacheFile(tmpCacheFileName, cacheFilePath)
208+
}
209+
210+
func writeCacheResponse(w io.Writer, res *http.Response) error {
211+
if res.Body == nil {
212+
// Serialize the HTTP response headers only, since there is no body.
213+
return res.Write(w)
213214
}
214215

215-
if err := os.Chmod(tmpName, 0600); err != nil {
216-
storeErr = err
217-
return
216+
// Buffer the bytes consumed during serialization so the caller can replay
217+
// them. Restore the replay reader even if writing fails or panics.
218+
buffer := &bytes.Buffer{}
219+
recorder := &errorRecordingReader{Reader: io.TeeReader(res.Body, buffer)}
220+
source := &readCloser{Reader: recorder, Closer: res.Body}
221+
res.Body = source
222+
defer source.Close()
223+
defer func() {
224+
res.Body = io.NopCloser(&errorReplayingReader{Reader: buffer, err: recorder.err})
225+
}()
226+
227+
return res.Write(w)
228+
}
229+
230+
type errorRecordingReader struct {
231+
io.Reader
232+
err error
233+
}
234+
235+
func (r *errorRecordingReader) Read(p []byte) (int, error) {
236+
n, err := r.Reader.Read(p)
237+
if err != nil && err != io.EOF {
238+
r.err = err
218239
}
240+
return n, err
241+
}
219242

220-
storeErr = os.Rename(tmpName, cacheFile)
221-
return
243+
type errorReplayingReader struct {
244+
io.Reader
245+
err error
246+
}
247+
248+
func (r *errorReplayingReader) Read(p []byte) (int, error) {
249+
n, err := r.Reader.Read(p)
250+
if err == io.EOF && r.err != nil {
251+
err = r.err
252+
r.err = nil
253+
}
254+
return n, err
222255
}
223256

224-
func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) {
225-
b := &bytes.Buffer{}
226-
nr := io.TeeReader(r, b)
227-
return io.NopCloser(b), &readCloser{
228-
Reader: nr,
229-
Closer: r,
257+
func copyStream(body io.ReadCloser) (replay, source io.ReadCloser) {
258+
buffer := &bytes.Buffer{}
259+
return io.NopCloser(buffer), &readCloser{
260+
Reader: io.TeeReader(body, buffer),
261+
Closer: body,
230262
}
231263
}

‎pkg/api/cache_rename_other.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !windows
2+
3+
package api
4+
5+
import "os"
6+
7+
func renameCacheFile(oldPath, newPath string) error {
8+
return os.Rename(oldPath, newPath)
9+
}

‎pkg/api/cache_rename_windows.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"os"
6+
"time"
7+
8+
"golang.org/x/sys/windows"
9+
)
10+
11+
func renameCacheFile(oldPath string, newPath string) error {
12+
return retryCacheRename(func() (error, bool) {
13+
err := os.Rename(oldPath, newPath)
14+
return err, isRetryableCacheRenameError(err)
15+
})
16+
}
17+
18+
type retryableFn func() (err error, retryable bool)
19+
20+
func retryCacheRename(rename retryableFn) error {
21+
// Caching is best-effort: allow brief Windows sharing conflicts to clear
22+
// without holding up an API response indefinitely.
23+
deadline := time.Now().Add(100 * time.Millisecond)
24+
for delay := time.Millisecond; ; delay *= 2 {
25+
err, retryable := rename()
26+
if err == nil {
27+
return nil
28+
}
29+
30+
if !retryable {
31+
return err
32+
}
33+
34+
remaining := time.Until(deadline)
35+
if remaining <= 0 {
36+
return err
37+
}
38+
time.Sleep(min(delay, remaining))
39+
}
40+
}
41+
42+
func isRetryableCacheRenameError(err error) bool {
43+
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
44+
errors.Is(err, windows.ERROR_ACCESS_DENIED)
45+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"testing/synctest"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/sys/windows"
14+
)
15+
16+
func TestCacheRenameStopsRetryingAfterBudget(t *testing.T) {
17+
t.Parallel()
18+
19+
// Given a destination whose reader prevents replacement for the entire retry budget.
20+
dir := t.TempDir()
21+
oldPath, newPath := filepath.Join(dir, "temp"), filepath.Join(dir, "cache")
22+
require.NoError(t, os.WriteFile(oldPath, []byte("replacement"), 0600))
23+
require.NoError(t, os.WriteFile(newPath, []byte("previous"), 0600))
24+
reader, err := os.Open(newPath)
25+
require.NoError(t, err)
26+
defer reader.Close()
27+
28+
synctest.Test(t, func(t *testing.T) {
29+
start := time.Now()
30+
31+
// When publication is attempted while the reader remains open.
32+
err := renameCacheFile(oldPath, newPath)
33+
34+
// Then it returns the sharing error within budget
35+
require.Error(t, err)
36+
assert.True(t, errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED),
37+
"expected a Windows sharing conflict, got %v", err)
38+
elapsed := time.Since(start)
39+
assert.Greater(t, elapsed, time.Duration(0), "expected retries before giving up")
40+
assert.LessOrEqual(t, elapsed, 100*time.Millisecond, "cache retries must stay within the latency budget")
41+
42+
// And the contents of both files are unchanged.
43+
previous, err := os.ReadFile(newPath)
44+
require.NoError(t, err)
45+
assert.Equal(t, "previous", string(previous))
46+
replacement, err := os.ReadFile(oldPath)
47+
require.NoError(t, err)
48+
assert.Equal(t, "replacement", string(replacement))
49+
})
50+
}
51+
52+
func TestCacheRenameWaitsForWindowsReader(t *testing.T) {
53+
t.Parallel()
54+
55+
// Given a destination held open without delete sharing by another reader.
56+
dir := t.TempDir()
57+
oldPath, newPath := filepath.Join(dir, "temp"), filepath.Join(dir, "cache")
58+
require.NoError(t, os.WriteFile(oldPath, []byte("replacement"), 0600))
59+
require.NoError(t, os.WriteFile(newPath, []byte("previous"), 0600))
60+
reader, err := os.Open(newPath)
61+
require.NoError(t, err)
62+
defer reader.Close()
63+
64+
synctest.Test(t, func(t *testing.T) {
65+
// When the reader closes while the rename is waiting to retry.
66+
done := make(chan error, 1)
67+
go func() { done <- renameCacheFile(oldPath, newPath) }()
68+
synctest.Wait()
69+
require.NoError(t, reader.Close())
70+
71+
// Then the replacement succeeds.
72+
require.NoError(t, <-done)
73+
data, err := os.ReadFile(newPath)
74+
require.NoError(t, err)
75+
assert.Equal(t, "replacement", string(data))
76+
})
77+
}
78+
79+
func TestCacheRenameReturnsMissingSourceImmediately(t *testing.T) {
80+
t.Parallel()
81+
82+
// Given a source file that does not exist.
83+
dir := t.TempDir()
84+
synctest.Test(t, func(t *testing.T) {
85+
start := time.Now()
86+
87+
// When it is renamed.
88+
err := renameCacheFile(filepath.Join(dir, "missing"), filepath.Join(dir, "cache"))
89+
90+
// Then the non-transient Windows error is returned without retrying.
91+
require.ErrorIs(t, err, os.ErrNotExist)
92+
assert.Zero(t, time.Since(start))
93+
})
94+
}

0 commit comments

Comments
 (0)