detached 725406b

test(snapshot): verify cursor close failures

commit
725406b0ff34217059b91b6d6b64b6faa4e1456b
author
brice · · 2026-07-21 21:34 UTC
parent
91d6720
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +135 -23
M internal/adapters/snapshot/maintenance.go +48 -11
@@ -246,7 +246,7 @@ func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]ma
246246 r.maintenanceCursors[id] = cursor
247247 }
248248
249- entries, done, err := readMaintenanceDirent(dir, n, cursor)
249+ entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
250250 if err != nil {
251251 delete(r.maintenanceCursors, id)
252252 return nil, false, err
@@ -257,17 +257,55 @@ func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]ma
257257 return entries, done, nil
258258 }
259259
260+// maintenanceDirectory is the small syscall surface needed to resume a
261+// bounded getdents cursor. It permits deterministic fault injection without
262+// retaining directory descriptors between maintenance passes.
263+type maintenanceDirectory interface {
264+ Seek(int64, int) (int64, error)
265+ ReadDirent([]byte) (int, error)
266+ Close() error
267+}
268+
269+type osMaintenanceDirectory struct{ file *os.File }
270+
271+func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
272+ return syscall.Seek(int(d.file.Fd()), offset, whence)
273+}
274+
275+func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
276+ return syscall.ReadDirent(int(d.file.Fd()), buffer)
277+}
278+
279+func (d osMaintenanceDirectory) Close() error { return d.file.Close() }
280+
281+func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) {
282+ if open := r.hooks.openMaintenanceDirectory; open != nil {
283+ return open(dir)
284+ }
285+ file, err := os.Open(dir)
286+ if err != nil {
287+ return nil, err
288+ }
289+ return osMaintenanceDirectory{file: file}, nil
290+}
291+
292+// maintenanceDirectoryError adds stable operation context and removes any
293+// filesystem path while retaining the underlying cause for errors.Is.
294+func maintenanceDirectoryError(operation string, err error) error {
295+ return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
296+}
297+
260298 // readMaintenanceDirent uses the Linux getdents seek cookie from each record.
261299 // syscall.Dirent's Off field is the d_off member of linux_dirent64 and
262300 // syscall.Seek is lseek(2), as defined by Go's syscall Linux sources.
263-func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
264- file, err := os.Open(dir)
301+func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
302+ file, err := r.openMaintenanceDirectory(dir)
265303 if err != nil {
266- return nil, false, err
304+ return nil, false, maintenanceDirectoryError("open maintenance directory", err)
267305 }
268306 defer func() {
269307 if closeErr := file.Close(); closeErr != nil {
270- closeErr = fmt.Errorf("close maintenance directory %q: %w", dir, closeErr)
308+ closeErr = maintenanceDirectoryError("close maintenance directory", closeErr)
271309 if err != nil {
272310 err = errors.Join(err, closeErr)
273311 } else {
@@ -277,19 +315,18 @@ func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (en
277315 }
278316 }
279317 }()
280- fd := int(file.Fd())
281318 if cursor.offset != 0 {
282- if _, err := syscall.Seek(fd, cursor.offset, io.SeekStart); err != nil {
283- return nil, false, err
319+ if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil {
320+ return nil, false, maintenanceDirectoryError("seek maintenance directory", err)
284321 }
285322 }
286323
287324 entries = make([]maintenanceDirEntry, 0, limit)
288325 buffer := make([]byte, 8192)
289326 for len(entries) < limit {
290- count, err := syscall.ReadDirent(fd, buffer)
327+ count, err := file.ReadDirent(buffer)
291328 if err != nil {
292- return nil, false, err
329+ return nil, false, maintenanceDirectoryError("read maintenance directory", err)
293330 }
294331 if count == 0 {
295332 return entries, true, nil
@@ -319,7 +356,7 @@ func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (en
319356 continue
320357 }
321358 if err != nil {
322- return nil, false, err
359+ return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
323360 }
324361 entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()})
325362 }
@@ -246,7 +246,7 @@ func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]ma
246 r.maintenanceCursors[id] = cursor246 r.maintenanceCursors[id] = cursor
247 }247 }
248 248
249- entries, done, err := readMaintenanceDirent(dir, n, cursor)249+ entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
250 if err != nil {250 if err != nil {
251 delete(r.maintenanceCursors, id)251 delete(r.maintenanceCursors, id)
252 return nil, false, err252 return nil, false, err
@@ -257,17 +257,55 @@ func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]ma
257 return entries, done, nil257 return entries, done, nil
258 }258 }
259 259
260+// maintenanceDirectory is the small syscall surface needed to resume a
261+// bounded getdents cursor. It permits deterministic fault injection without
262+// retaining directory descriptors between maintenance passes.
263+type maintenanceDirectory interface {
264+ Seek(int64, int) (int64, error)
265+ ReadDirent([]byte) (int, error)
266+ Close() error
267+}
268+
269+type osMaintenanceDirectory struct{ file *os.File }
270+
271+func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
272+ return syscall.Seek(int(d.file.Fd()), offset, whence)
273+}
274+
275+func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
276+ return syscall.ReadDirent(int(d.file.Fd()), buffer)
277+}
278+
279+func (d osMaintenanceDirectory) Close() error { return d.file.Close() }
280+
281+func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) {
282+ if open := r.hooks.openMaintenanceDirectory; open != nil {
283+ return open(dir)
284+ }
285+ file, err := os.Open(dir)
286+ if err != nil {
287+ return nil, err
288+ }
289+ return osMaintenanceDirectory{file: file}, nil
290+}
291+
292+// maintenanceDirectoryError adds stable operation context and removes any
293+// filesystem path while retaining the underlying cause for errors.Is.
294+func maintenanceDirectoryError(operation string, err error) error {
295+ return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
296+}
297+
260 // readMaintenanceDirent uses the Linux getdents seek cookie from each record.298 // readMaintenanceDirent uses the Linux getdents seek cookie from each record.
261 // syscall.Dirent's Off field is the d_off member of linux_dirent64 and299 // syscall.Dirent's Off field is the d_off member of linux_dirent64 and
262 // syscall.Seek is lseek(2), as defined by Go's syscall Linux sources.300 // syscall.Seek is lseek(2), as defined by Go's syscall Linux sources.
263-func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {301+func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
264- file, err := os.Open(dir)302+ file, err := r.openMaintenanceDirectory(dir)
265 if err != nil {303 if err != nil {
266- return nil, false, err304+ return nil, false, maintenanceDirectoryError("open maintenance directory", err)
267 }305 }
268 defer func() {306 defer func() {
269 if closeErr := file.Close(); closeErr != nil {307 if closeErr := file.Close(); closeErr != nil {
270- closeErr = fmt.Errorf("close maintenance directory %q: %w", dir, closeErr)308+ closeErr = maintenanceDirectoryError("close maintenance directory", closeErr)
271 if err != nil {309 if err != nil {
272 err = errors.Join(err, closeErr)310 err = errors.Join(err, closeErr)
273 } else {311 } else {
@@ -277,19 +315,18 @@ func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (en
277 }315 }
278 }316 }
279 }()317 }()
280- fd := int(file.Fd())
281 if cursor.offset != 0 {318 if cursor.offset != 0 {
282- if _, err := syscall.Seek(fd, cursor.offset, io.SeekStart); err != nil {319+ if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil {
283- return nil, false, err320+ return nil, false, maintenanceDirectoryError("seek maintenance directory", err)
284 }321 }
285 }322 }
286 323
287 entries = make([]maintenanceDirEntry, 0, limit)324 entries = make([]maintenanceDirEntry, 0, limit)
288 buffer := make([]byte, 8192)325 buffer := make([]byte, 8192)
289 for len(entries) < limit {326 for len(entries) < limit {
290- count, err := syscall.ReadDirent(fd, buffer)327+ count, err := file.ReadDirent(buffer)
291 if err != nil {328 if err != nil {
292- return nil, false, err329+ return nil, false, maintenanceDirectoryError("read maintenance directory", err)
293 }330 }
294 if count == 0 {331 if count == 0 {
295 return entries, true, nil332 return entries, true, nil
@@ -319,7 +356,7 @@ func readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (en
319 continue356 continue
320 }357 }
321 if err != nil {358 if err != nil {
322- return nil, false, err359+ return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
323 }360 }
324 entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()})361 entries = append(entries, maintenanceDirEntry{name: name, isDir: info.IsDir()})
325 }362 }
M internal/adapters/snapshot/maintenance_test.go +74 -0
@@ -13,6 +13,80 @@ import (
1313 "github.com/bnema/vev/internal/ports"
1414 )
1515
16+func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) {
17+ privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory")
18+ closeCause := errors.New("injected close failure")
19+ seekCause := errors.New("injected seek failure")
20+ readCause := errors.New("injected read failure")
21+
22+ for _, tc := range []struct {
23+ name string
24+ offset int64
25+ seekErr error
26+ readErr error
27+ wantCause []error
28+ wantOps []string
29+ }{
30+ {
31+ name: "close only",
32+ wantCause: []error{closeCause},
33+ wantOps: []string{"close maintenance directory"},
34+ },
35+ {
36+ name: "seek and close",
37+ offset: 1,
38+ seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause},
39+ wantCause: []error{seekCause, closeCause},
40+ wantOps: []string{"seek maintenance directory", "close maintenance directory"},
41+ },
42+ {
43+ name: "read and close",
44+ readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause},
45+ wantCause: []error{readCause, closeCause},
46+ wantOps: []string{"read maintenance directory", "close maintenance directory"},
47+ },
48+ } {
49+ t.Run(tc.name, func(t *testing.T) {
50+ repo := NewRepository(privateDir(t))
51+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
52+ return fakeMaintenanceDirectory{
53+ seekErr: tc.seekErr,
54+ readErr: tc.readErr,
55+ closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause},
56+ }, nil
57+ }
58+
59+ _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset})
60+ if err == nil {
61+ t.Fatal("readMaintenanceDirent error = nil, want failure")
62+ }
63+ for _, cause := range tc.wantCause {
64+ if !errors.Is(err, cause) {
65+ t.Errorf("errors.Is(%v, %v) = false, want true", err, cause)
66+ }
67+ }
68+ for _, operation := range tc.wantOps {
69+ if !strings.Contains(err.Error(), operation) {
70+ t.Errorf("error = %q, want stable operation context %q", err, operation)
71+ }
72+ }
73+ if strings.Contains(err.Error(), privatePath) {
74+ t.Errorf("error leaks private path: %q", err)
75+ }
76+ })
77+ }
78+}
79+
80+type fakeMaintenanceDirectory struct {
81+ seekErr error
82+ readErr error
83+ closeErr error
84+}
85+
86+func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return 0, d.seekErr }
87+func (d fakeMaintenanceDirectory) ReadDirent([]byte) (int, error) { return 0, d.readErr }
88+func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
89+
1690 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
1791 repo := NewRepository(privateDir(t))
1892 if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
@@ -13,6 +13,80 @@ import (
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 )14 )
15 15
16+func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) {
17+ privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory")
18+ closeCause := errors.New("injected close failure")
19+ seekCause := errors.New("injected seek failure")
20+ readCause := errors.New("injected read failure")
21+
22+ for _, tc := range []struct {
23+ name string
24+ offset int64
25+ seekErr error
26+ readErr error
27+ wantCause []error
28+ wantOps []string
29+ }{
30+ {
31+ name: "close only",
32+ wantCause: []error{closeCause},
33+ wantOps: []string{"close maintenance directory"},
34+ },
35+ {
36+ name: "seek and close",
37+ offset: 1,
38+ seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause},
39+ wantCause: []error{seekCause, closeCause},
40+ wantOps: []string{"seek maintenance directory", "close maintenance directory"},
41+ },
42+ {
43+ name: "read and close",
44+ readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause},
45+ wantCause: []error{readCause, closeCause},
46+ wantOps: []string{"read maintenance directory", "close maintenance directory"},
47+ },
48+ } {
49+ t.Run(tc.name, func(t *testing.T) {
50+ repo := NewRepository(privateDir(t))
51+ repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
52+ return fakeMaintenanceDirectory{
53+ seekErr: tc.seekErr,
54+ readErr: tc.readErr,
55+ closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause},
56+ }, nil
57+ }
58+
59+ _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset})
60+ if err == nil {
61+ t.Fatal("readMaintenanceDirent error = nil, want failure")
62+ }
63+ for _, cause := range tc.wantCause {
64+ if !errors.Is(err, cause) {
65+ t.Errorf("errors.Is(%v, %v) = false, want true", err, cause)
66+ }
67+ }
68+ for _, operation := range tc.wantOps {
69+ if !strings.Contains(err.Error(), operation) {
70+ t.Errorf("error = %q, want stable operation context %q", err, operation)
71+ }
72+ }
73+ if strings.Contains(err.Error(), privatePath) {
74+ t.Errorf("error leaks private path: %q", err)
75+ }
76+ })
77+ }
78+}
79+
80+type fakeMaintenanceDirectory struct {
81+ seekErr error
82+ readErr error
83+ closeErr error
84+}
85+
86+func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return 0, d.seekErr }
87+func (d fakeMaintenanceDirectory) ReadDirent([]byte) (int, error) { return 0, d.readErr }
88+func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
89+
16 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {90 func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
17 repo := NewRepository(privateDir(t))91 repo := NewRepository(privateDir(t))
18 if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {92 if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
M internal/adapters/snapshot/repository.go +13 -12
@@ -48,18 +48,19 @@ type Repository struct {
4848 // immediately before their respective syscall and are intentionally package
4949 // private so production callers cannot weaken repository guarantees.
5050 type repositoryHooks struct {
51- beforeBlobWrite func(string) error
52- beforeManifestWrite func(string) error
53- beforeHeadWrite func(string) error
54- createTemp func(string) error
55- writeTemp func(string) error
56- syncFile func(string) error
57- closeFile func(string) error
58- installImmutable func(string) error
59- rename func(string) error
60- syncDirectory func(string) error
61- remove func(string) error
62- beforeSessionLock func(string)
51+ beforeBlobWrite func(string) error
52+ beforeManifestWrite func(string) error
53+ beforeHeadWrite func(string) error
54+ createTemp func(string) error
55+ writeTemp func(string) error
56+ syncFile func(string) error
57+ closeFile func(string) error
58+ installImmutable func(string) error
59+ rename func(string) error
60+ syncDirectory func(string) error
61+ remove func(string) error
62+ openMaintenanceDirectory func(string) (maintenanceDirectory, error)
63+ beforeSessionLock func(string)
6364 }
6465
6566 var _ ports.SnapshotRepository = (*Repository)(nil)
@@ -48,18 +48,19 @@ type Repository struct {
48 // immediately before their respective syscall and are intentionally package48 // immediately before their respective syscall and are intentionally package
49 // private so production callers cannot weaken repository guarantees.49 // private so production callers cannot weaken repository guarantees.
50 type repositoryHooks struct {50 type repositoryHooks struct {
51- beforeBlobWrite func(string) error51+ beforeBlobWrite func(string) error
52- beforeManifestWrite func(string) error52+ beforeManifestWrite func(string) error
53- beforeHeadWrite func(string) error53+ beforeHeadWrite func(string) error
54- createTemp func(string) error54+ createTemp func(string) error
55- writeTemp func(string) error55+ writeTemp func(string) error
56- syncFile func(string) error56+ syncFile func(string) error
57- closeFile func(string) error57+ closeFile func(string) error
58- installImmutable func(string) error58+ installImmutable func(string) error
59- rename func(string) error59+ rename func(string) error
60- syncDirectory func(string) error60+ syncDirectory func(string) error
61- remove func(string) error61+ remove func(string) error
62- beforeSessionLock func(string)62+ openMaintenanceDirectory func(string) (maintenanceDirectory, error)
63+ beforeSessionLock func(string)
63 }64 }
64 65
65 var _ ports.SnapshotRepository = (*Repository)(nil)66 var _ ports.SnapshotRepository = (*Repository)(nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close