Skip to content

Commit 46bdf43

Browse files
committed
Allow atomic recovery updates during concurrent reads
1 parent ed0be40 commit 46bdf43

7 files changed

Lines changed: 247 additions & 2 deletions

File tree

‎cmd/modify_test.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ package cmd
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"io"
67
"os"
78
"path/filepath"
9+
"strings"
10+
"sync"
11+
"sync/atomic"
812
"testing"
913
"time"
1014

@@ -109,6 +113,72 @@ func TestModifyStateAtomicWrite(t *testing.T) {
109113
assert.True(t, os.IsNotExist(err), "no .tmp file should remain after successful write")
110114
}
111115

116+
func TestModifyStateReadError(t *testing.T) {
117+
dir := t.TempDir()
118+
path := modify.StatePath(dir)
119+
require.NoError(t, os.Mkdir(path, 0700))
120+
got, err := modify.LoadState(dir)
121+
require.ErrorContains(t, err, "reading modify state")
122+
assert.Nil(t, got)
123+
var pathErr *os.PathError
124+
require.ErrorAs(t, err, &pathErr)
125+
assert.Equal(t, path, pathErr.Path)
126+
}
127+
128+
func TestModifyStateConcurrentReadWrite(t *testing.T) {
129+
dir := t.TempDir()
130+
state := &modify.StateFile{
131+
SchemaVersion: 1, Phase: modify.PhaseConflict,
132+
OriginalBranch: "initial", ConflictBranch: "initial",
133+
Snapshot: modify.Snapshot{StackMetadata: json.RawMessage("{}")},
134+
}
135+
require.NoError(t, modify.SaveState(dir, state))
136+
stop := make(chan struct{})
137+
errs := make(chan error, 2)
138+
var reads atomic.Int64
139+
var readers sync.WaitGroup
140+
for range 2 {
141+
readers.Go(func() {
142+
for {
143+
select {
144+
case <-stop:
145+
return
146+
default:
147+
}
148+
got, err := modify.LoadState(dir)
149+
if err != nil {
150+
errs <- err
151+
return
152+
}
153+
if got == nil || got.OriginalBranch == "" || got.OriginalBranch != got.ConflictBranch {
154+
errs <- fmt.Errorf("reader observed an incomplete modify state")
155+
return
156+
}
157+
reads.Add(1)
158+
}
159+
})
160+
}
161+
var writeErr error
162+
for i := range 50 {
163+
branch := strings.Repeat(fmt.Sprintf("%04d", i), 8192)
164+
state.OriginalBranch, state.ConflictBranch = branch, branch
165+
if writeErr = modify.SaveState(dir, state); writeErr != nil {
166+
break
167+
}
168+
}
169+
close(stop)
170+
readers.Wait()
171+
close(errs)
172+
require.NoError(t, writeErr)
173+
for err := range errs {
174+
require.NoError(t, err)
175+
}
176+
assert.Positive(t, reads.Load())
177+
got, err := modify.LoadState(dir)
178+
require.NoError(t, err)
179+
assert.Equal(t, state, got)
180+
}
181+
112182
func TestCheckModifyStateGuard(t *testing.T) {
113183
t.Run("no state file", func(t *testing.T) {
114184
gitDir := t.TempDir()

‎cmd/rebase.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ func saveRebaseState(gitDir string, state *rebaseState) error {
543543
}
544544

545545
func loadRebaseState(gitDir string) (*rebaseState, error) {
546-
data, err := os.ReadFile(filepath.Join(gitDir, rebaseStateFile))
546+
data, err := stack.ReadStateFile(filepath.Join(gitDir, rebaseStateFile))
547547
if err != nil {
548548
return nil, err
549549
}

‎cmd/rebase_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"os/exec"
1010
"path/filepath"
1111
"strings"
12+
"sync"
13+
"sync/atomic"
1214
"testing"
1315

1416
"github.com/github/gh-stack/internal/config"
@@ -953,6 +955,85 @@ func TestRebase_StateRoundTrip(t *testing.T) {
953955
assert.Equal(t, original.OntoOldBase, loaded.OntoOldBase)
954956
}
955957

958+
func TestRebase_StateReadErrors(t *testing.T) {
959+
for _, kind := range []string{"missing", "directory", "invalid JSON"} {
960+
t.Run(kind, func(t *testing.T) {
961+
dir := t.TempDir()
962+
path := filepath.Join(dir, rebaseStateFile)
963+
switch kind {
964+
case "directory":
965+
require.NoError(t, os.Mkdir(path, 0700))
966+
case "invalid JSON":
967+
require.NoError(t, os.WriteFile(path, []byte("{incomplete"), 0600))
968+
}
969+
got, err := loadRebaseState(dir)
970+
require.Error(t, err)
971+
assert.Nil(t, got)
972+
switch kind {
973+
case "missing":
974+
assert.ErrorIs(t, err, os.ErrNotExist)
975+
case "directory":
976+
var pathErr *os.PathError
977+
require.ErrorAs(t, err, &pathErr)
978+
assert.Equal(t, path, pathErr.Path)
979+
case "invalid JSON":
980+
var syntaxErr *json.SyntaxError
981+
assert.ErrorAs(t, err, &syntaxErr)
982+
}
983+
})
984+
}
985+
}
986+
987+
func TestRebase_StateConcurrentReadWrite(t *testing.T) {
988+
dir := t.TempDir()
989+
state := &rebaseState{OriginalBranch: "initial", ConflictBranch: "initial"}
990+
require.NoError(t, saveRebaseState(dir, state))
991+
stop := make(chan struct{})
992+
errs := make(chan error, 2)
993+
var reads atomic.Int64
994+
var readers sync.WaitGroup
995+
for range 2 {
996+
readers.Go(func() {
997+
for {
998+
select {
999+
case <-stop:
1000+
return
1001+
default:
1002+
}
1003+
got, err := loadRebaseState(dir)
1004+
if err != nil {
1005+
errs <- err
1006+
return
1007+
}
1008+
if got == nil || got.OriginalBranch == "" || got.OriginalBranch != got.ConflictBranch {
1009+
errs <- fmt.Errorf("reader observed an incomplete rebase state")
1010+
return
1011+
}
1012+
reads.Add(1)
1013+
}
1014+
})
1015+
}
1016+
var writeErr error
1017+
for i := range 50 {
1018+
branch := strings.Repeat(fmt.Sprintf("%04d", i), 8192)
1019+
state.OriginalBranch, state.ConflictBranch = branch, branch
1020+
if writeErr = saveRebaseState(dir, state); writeErr != nil {
1021+
break
1022+
}
1023+
}
1024+
close(stop)
1025+
readers.Wait()
1026+
close(errs)
1027+
require.NoError(t, writeErr)
1028+
for err := range errs {
1029+
require.NoError(t, err)
1030+
}
1031+
assert.Positive(t, reads.Load())
1032+
got, err := loadRebaseState(dir)
1033+
require.NoError(t, err)
1034+
assert.Equal(t, state, got)
1035+
}
1036+
9561037
// TestRebase_Continue_RebasesRemainingBranches verifies the --continue success
9571038
// path: RebaseContinue is called, remaining branches are rebased via RebaseOnto,
9581039
// the state file is cleaned up, and the original branch is restored.

‎internal/modify/state.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func StatePath(gitDir string) string {
7777
// LoadState reads the modify state file from the git directory.
7878
// Returns nil, nil if the file does not exist.
7979
func LoadState(gitDir string) (*StateFile, error) {
80-
data, err := os.ReadFile(StatePath(gitDir))
80+
data, err := stack.ReadStateFile(StatePath(gitDir))
8181
if err != nil {
8282
if errors.Is(err, os.ErrNotExist) {
8383
return nil, nil

‎internal/stack/atomic.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import (
77
"path/filepath"
88
)
99

10+
// ReadStateFile reads a complete state file, allowing WriteAtomic to replace it
11+
// while the read is in progress. On Windows, its handle permits delete sharing.
12+
func ReadStateFile(path string) ([]byte, error) {
13+
return readStateFile(path)
14+
}
15+
1016
// WriteAtomic publishes data at path using a fully written temporary file in
1117
// the same directory. It preserves an existing regular file's permissions and
1218
// uses 0644 for a new file. The parent directory must already exist.

‎internal/stack/atomic_windows_test.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,47 @@ func TestAtomicPublication_HeldWindowsReader(t *testing.T) {
5757
})
5858
}
5959
}
60+
61+
func TestReadStateFile_WindowsSharing(t *testing.T) {
62+
for _, shareDelete := range []bool{false, true} {
63+
name := "exclusive handle"
64+
if shareDelete {
65+
name = "shared delete handle"
66+
}
67+
t.Run(name, func(t *testing.T) {
68+
path := filepath.Join(t.TempDir(), "state")
69+
original, replacement := []byte("original state"), []byte("new state")
70+
require.NoError(t, WriteAtomic(path, original))
71+
name, err := windowsFilePath(path)
72+
require.NoError(t, err)
73+
var shareMode uint32
74+
if shareDelete {
75+
shareMode = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE
76+
}
77+
handle, err := windows.CreateFile(&name[0], windows.GENERIC_READ|windows.DELETE,
78+
shareMode, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
79+
require.NoError(t, err)
80+
reader := os.NewFile(uintptr(handle), path)
81+
t.Cleanup(func() { assert.NoError(t, reader.Close()) })
82+
83+
// An existing DELETE-access handle requires new readers to share delete.
84+
_, err = os.ReadFile(path)
85+
require.ErrorIs(t, err, windows.ERROR_SHARING_VIOLATION)
86+
got, err := ReadStateFile(path)
87+
if !shareDelete {
88+
require.ErrorIs(t, err, windows.ERROR_SHARING_VIOLATION)
89+
return
90+
}
91+
require.NoError(t, err)
92+
assert.Equal(t, original, got)
93+
94+
require.NoError(t, WriteAtomic(path, replacement))
95+
got, err = ReadStateFile(path)
96+
require.NoError(t, err)
97+
assert.Equal(t, replacement, got)
98+
old, err := io.ReadAll(reader)
99+
require.NoError(t, err)
100+
assert.Equal(t, original, old)
101+
})
102+
}
103+
}

‎internal/stack/lock_test.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,50 @@ func TestAtomicPublication_PreservesExistingFiles(t *testing.T) {
504504
})
505505
}
506506

507+
func TestReadStateFile(t *testing.T) {
508+
t.Run("reads complete bytes", func(t *testing.T) {
509+
path := filepath.Join(t.TempDir(), "state")
510+
for _, want := range [][]byte{
511+
{},
512+
{0x00, 0xff, 0x0a},
513+
[]byte(strings.Repeat("state\x00\xff\n", 16384)),
514+
} {
515+
require.NoError(t, os.WriteFile(path, want, 0600))
516+
got, err := ReadStateFile(path)
517+
require.NoError(t, err)
518+
assert.Equal(t, want, got)
519+
}
520+
})
521+
522+
t.Run("missing file", func(t *testing.T) {
523+
path := filepath.Join(t.TempDir(), "missing")
524+
_, err := ReadStateFile(path)
525+
require.ErrorIs(t, err, os.ErrNotExist)
526+
assert.NoFileExists(t, path)
527+
})
528+
529+
t.Run("directory", func(t *testing.T) {
530+
path := t.TempDir()
531+
_, err := ReadStateFile(path)
532+
var pathErr *os.PathError
533+
require.ErrorAs(t, err, &pathErr)
534+
assert.Equal(t, path, pathErr.Path)
535+
assert.DirExists(t, path)
536+
})
537+
538+
t.Run("unreadable file", func(t *testing.T) {
539+
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
540+
t.Skip("requires Unix permission enforcement")
541+
}
542+
path := filepath.Join(t.TempDir(), "state")
543+
require.NoError(t, os.WriteFile(path, []byte("private state"), 0600))
544+
require.NoError(t, os.Chmod(path, 0))
545+
t.Cleanup(func() { assert.NoError(t, os.Chmod(path, 0600)) })
546+
_, err := ReadStateFile(path)
547+
require.ErrorIs(t, err, os.ErrPermission)
548+
})
549+
}
550+
507551
func TestWriteAtomic(t *testing.T) {
508552
t.Run("creates and replaces exact bytes", func(t *testing.T) {
509553
path := filepath.Join(t.TempDir(), "gh-stack-rebase-state")

0 commit comments

Comments
 (0)